From 89589bbf3d4a9df9bf0ceb0f5d878244eb8c1351 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Sat, 21 Sep 2024 11:19:27 -0700 Subject: [PATCH] fix: Memory Leaks --- src/LiveViews.ts | 67 +++++++++++++++++++---- src/main.ts | 24 ++++---- src/markdownView/InvalidLinkExtension.ts | 21 +++++-- src/y-codemirror.next/LiveEditPlugin.ts | 20 ++++--- src/y-codemirror.next/RemoteSelections.ts | 20 +++++-- 5 files changed, 115 insertions(+), 37 deletions(-) diff --git a/src/LiveViews.ts b/src/LiveViews.ts index 4fc9a68..d028edd 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -1,5 +1,10 @@ import type { Extension } from "@codemirror/state"; -import { Compartment } from "@codemirror/state"; +import { + StateEffect, + StateField, + EditorState, + Compartment, +} from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { App, @@ -20,10 +25,7 @@ import { SharedFolder, SharedFolders } from "./SharedFolder"; import { curryLog } from "./debug"; import { promiseWithTimeout } from "./promiseUtils"; import { Banner } from "./ui/Banner"; -import { - LiveEdit, - connectionManagerFacet, -} from "./y-codemirror.next/LiveEditPlugin"; +import { LiveEdit } from "./y-codemirror.next/LiveEditPlugin"; import { yRemoteSelections, yRemoteSelectionsTheme, @@ -265,9 +267,19 @@ export class LiveView implements S3View { return stale; } + _workaroundCM6StateFieldInitialization() { + const editorView = (this.view.editor as any).cm as EditorView; + const field = editorView.state.field(ConnectionManagerStateField, false); + if (field === undefined) { + this._parent.reconfigure(editorView); + } + } + attach(): Promise { // can be called multiple times, whereas release is only ever called once this.setConnectionDot(); + this._workaroundCM6StateFieldInitialization(); + return new Promise((resolve) => { return this.document .whenReady() @@ -307,9 +319,20 @@ export class LiveView implements S3View { this.document.disconnect(); } + _workaroundCM6MemoryLeak() { + // CM6 memory leak + // CM6 will hold references to state fields in config.dynamicSlots + // for us this is a big problem because LiveViewManager has references + // to basically everything. + const editor = this.view.editor; + const editorView = (editor as any).cm as EditorView; + (editorView.state as any).config.dynamicSlots.length = 0; + } + destroy() { this.release(); this.clearViewActions(); + this._workaroundCM6MemoryLeak(); this._parent = null as any; this.view = null as any; this.document = null as any; @@ -320,8 +343,7 @@ export class LiveViewManager { workspace: Workspace; views: S3View[]; private _activePromise?: Promise | null; - private _stale: string; - private _compartment: Compartment; + _compartment: Compartment; private loginManager: LoginManager; private offListeners: (() => void)[] = []; private folderListeners: Map void> = new Map(); @@ -346,12 +368,11 @@ export class LiveViewManager { this.sharedFolders = sharedFolders; this.views = []; this.extensions = []; - this._compartment = new Compartment(); this._activePromise = null; - this._stale = ""; this.loginManager = loginManager; this.networkStatus = networkStatus; this.refreshQueue = []; + this._compartment = new Compartment(); this.log = curryLog("[LiveViews]", "log"); this.warn = curryLog("[LiveViews]", "warn"); @@ -411,6 +432,16 @@ export class LiveViewManager { ); } + reconfigure(editorView: EditorView) { + editorView.dispatch({ + effects: this._compartment.reconfigure([ + ConnectionManagerStateField.init(() => { + return this; + }), + ]), + }); + } + onMeta(tfile: TFile, cb: (data: string, cache: CachedMetadata) => void) { this.metadataListeners.set(tfile, cb); } @@ -721,7 +752,11 @@ export class LiveViewManager { this.wipe(); if (this.views.length > 0) { this.extensions.push([ - this._compartment.of(connectionManagerFacet.of(this)), + this._compartment.of( + ConnectionManagerStateField.init(() => { + return this; + }), + ), LiveEdit, yRemoteSelectionsTheme, yRemoteSelections, @@ -744,9 +779,19 @@ export class LiveViewManager { this.views = []; this.wipe(); this.sharedFolders = null as any; - this._compartment = null as any; this.refreshQueue = null as any; this.networkStatus = null as any; this._activePromise = null as any; } } + +export const ConnectionManagerStateField = StateField.define< + LiveViewManager | undefined +>({ + create(state: EditorState) { + return undefined; + }, + update(currentManager, transaction) { + return currentManager; + }, +}); diff --git a/src/main.ts b/src/main.ts index 88a3cd6..6fb594e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ "use strict"; +import { type Extension, StateField, EditorState } from "@codemirror/state"; import { TFolder, Notice, MarkdownView, Vault, FileManager } from "obsidian"; import { Platform } from "obsidian"; import { SharedFolder } from "./SharedFolder"; @@ -66,7 +67,6 @@ export default class Live extends Plugin { _offFlagUpdates!: Unsubscriber; relayManager!: RelayManager; settingsTab!: LiveSettingsTab; - _extensions!: []; log!: (message: string, ...args: unknown[]) => void; warn!: (message: string, ...args: unknown[]) => void; private _liveViews!: LiveViewManager; @@ -499,18 +499,20 @@ export default class Live extends Plugin { // We want to unload the visual components but not the data this.settingsFileLocked = true; - this._offFlagUpdates(); - if (this._offSaveSettings) { - this._offSaveSettings(); - } + this._offFlagUpdates?.(); + this._offFlagUpdates = null as any; + + this._offSaveSettings?.(); + this._offSaveSettings = null as any; - this.timeProvider.destroy(); + this.timeProvider?.destroy(); this.folderNavDecorations?.destroy(); this.app.workspace.detachLeavesOfType(VIEW_TYPE_DIFFERENCES); - this.backgroundSync.destroy(); + this.backgroundSync?.destroy(); + this.backgroundSync = null as any; this._liveViews?.destroy(); this._liveViews = null as any; @@ -530,11 +532,11 @@ export default class Live extends Plugin { this.sharedFolders.destroy(); this.sharedFolders = null as any; - this.settingsTab.destroy(); - - this.loginManager.destroy(); + this.settingsTab?.destroy(); + this.settingsTab = null as any; - this.app.workspace.updateOptions(); + this.loginManager?.destroy(); + this.loginManager = null as any; FeatureFlagManager.destroy(); PostOffice.destroy(); diff --git a/src/markdownView/InvalidLinkExtension.ts b/src/markdownView/InvalidLinkExtension.ts index 588c3bb..fa95bff 100644 --- a/src/markdownView/InvalidLinkExtension.ts +++ b/src/markdownView/InvalidLinkExtension.ts @@ -8,7 +8,11 @@ import { } from "@codemirror/view"; import { WidgetType } from "@codemirror/view"; import { connectionManagerFacet } from "src/y-codemirror.next/LiveEditPlugin"; -import { type S3View, LiveViewManager } from "../LiveViews"; +import { + type S3View, + LiveViewManager, + ConnectionManagerStateField, +} from "../LiveViews"; import { curryLog } from "src/debug"; import type { CachedMetadata, MetadataCache } from "obsidian"; @@ -40,15 +44,16 @@ export class InvalidLinkPluginValue { metadata: Map; editor: EditorView; view?: S3View; - connectionManager: LiveViewManager | null; + connectionManager?: LiveViewManager; decorationAnchors: number[]; decorations: DecorationSet; log: (message: string) => void = (message: string) => {}; - offMetadataUpdates = () => {}; constructor(editor: EditorView) { this.editor = editor; - this.connectionManager = this.editor.state.facet(connectionManagerFacet); + this.connectionManager = this.editor.state.field( + ConnectionManagerStateField, + ); this.decorations = Decoration.none; this.decorationAnchors = []; this.metadata = new Map(); @@ -278,6 +283,14 @@ export class InvalidLinkPluginValue { } return this.decorations; } + + destroy() { + this.connectionManager = null as any; + this.view = undefined; + this.editor = null as any; + this.metadata.clear(); + this.metadata = null as any; + } } export const InvalidLinkPlugin = ViewPlugin.fromClass(InvalidLinkPluginValue, { diff --git a/src/y-codemirror.next/LiveEditPlugin.ts b/src/y-codemirror.next/LiveEditPlugin.ts index 6910366..598c0f6 100644 --- a/src/y-codemirror.next/LiveEditPlugin.ts +++ b/src/y-codemirror.next/LiveEditPlugin.ts @@ -2,16 +2,20 @@ // License // [The MIT License](./LICENSE) © Kevin Jahns -import { Facet, Annotation } from "@codemirror/state"; +import { Facet, Annotation, StateField } from "@codemirror/state"; import type { ChangeSpec } from "@codemirror/state"; import { EditorView, ViewUpdate, ViewPlugin } from "@codemirror/view"; import type { PluginValue } from "@codemirror/view"; -import { type S3View, LiveViewManager, isLive } from "../LiveViews"; +import { + type S3View, + LiveViewManager, + isLive, + ConnectionManagerStateField, +} from "../LiveViews"; import { YText, YTextEvent, Transaction } from "yjs/dist/src/internals"; import { curryLog } from "src/debug"; import { FeatureFlagManager, withFlag } from "src/flagManager"; import { flag } from "src/flags"; - export const connectionManagerFacet: Facet = Facet.define({ combine(inputs) { @@ -24,7 +28,7 @@ export const ySyncAnnotation = Annotation.define(); export class LiveCMPluginValue implements PluginValue { editor: EditorView; view?: S3View; - connectionManager: LiveViewManager; + connectionManager?: LiveViewManager; initialSet = false; _observer?: (event: YTextEvent, tr: Transaction) => void; _ytext?: YText; @@ -32,8 +36,10 @@ export class LiveCMPluginValue implements PluginValue { constructor(editor: EditorView) { this.editor = editor; - this.connectionManager = this.editor.state.facet(connectionManagerFacet); - this.view = this.connectionManager.findView(editor); + this.connectionManager = this.editor.state.field( + ConnectionManagerStateField, + ); + this.view = this.connectionManager?.findView(editor); if (!this.view) { return; } @@ -140,7 +146,7 @@ export class LiveCMPluginValue implements PluginValue { return; } const editor: EditorView = update.view; - this.view = this.connectionManager.findView(editor); + this.view = this.connectionManager?.findView(editor); const ytext = this.view?.document?.ytext; if (!ytext) { return; diff --git a/src/y-codemirror.next/RemoteSelections.ts b/src/y-codemirror.next/RemoteSelections.ts index c398e96..b3094df 100644 --- a/src/y-codemirror.next/RemoteSelections.ts +++ b/src/y-codemirror.next/RemoteSelections.ts @@ -16,7 +16,12 @@ import { import type { PluginValue, DecorationSet } from "@codemirror/view"; -import { type S3View, LiveViewManager, LiveView } from "../LiveViews"; +import { + type S3View, + LiveViewManager, + LiveView, + ConnectionManagerStateField, +} from "../LiveViews"; import * as Y from "yjs"; import { connectionManagerFacet } from "./LiveEditPlugin"; @@ -155,7 +160,7 @@ type AwarenessChangeHandler = ( export class YRemoteSelectionsPluginValue implements PluginValue { editor: EditorView; - connectionManager: LiveViewManager; + connectionManager?: LiveViewManager; view?: S3View; decorations: DecorationSet; _awareness?: Awareness; @@ -164,8 +169,11 @@ export class YRemoteSelectionsPluginValue implements PluginValue { constructor(editor: EditorView) { this.editor = editor; this.decorations = RangeSet.of([]); - this.connectionManager = this.editor.state.facet(connectionManagerFacet); - const view = this.connectionManager.findView(editor); + this.connectionManager = this.editor.state.field( + ConnectionManagerStateField, + ); + + const view = this.connectionManager?.findView(editor); if (view && view instanceof LiveView) { this.view = view; const provider = this.view.document?._provider; @@ -189,7 +197,11 @@ export class YRemoteSelectionsPluginValue implements PluginValue { destroy() { if (this._listener) { this._awareness?.off("change", this._listener); + this._listener = undefined; } + this.connectionManager = null as any; + this.view = null as any; + this.editor = null as any; } update(update: ViewUpdate) {