diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 7402a6d6..b8bd7d69 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -254,4 +254,8 @@ export class FileOperations { return newName; } + + public reset(): void { + this.fs.reset(); + } } diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts index 30d47f77..9b3273e4 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -138,4 +138,8 @@ export class SafeFileSystemOperations implements FileSystemOperations { } } } + + public reset(): void { + this.locks.reset(); + } } diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts index 1ad5af71..91d0e568 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -133,7 +133,7 @@ export class Database { toUpdate.metadata = metadata; - this.save(); + this.saveInTheBackground(); } public removeDocumentPromise(promise: Promise): void { @@ -153,7 +153,7 @@ export class Database { public removeDocument(find: DocumentRecord): void { this.documents = this.documents.filter((document) => document !== find); - this.save(); + this.saveInTheBackground(); } public getLatestDocumentByRelativePath( @@ -210,7 +210,7 @@ export class Database { }; this.documents.push(entry); - this.save(); + this.saveInTheBackground(); return entry; } @@ -234,7 +234,7 @@ export class Database { }; this.documents.push(entry); - this.save(); + this.saveInTheBackground(); return entry; } @@ -271,7 +271,7 @@ export class Database { oldDocument.parallelVersion = newDocument !== undefined ? newDocument.parallelVersion + 1 : 0; - this.save(); + this.saveInTheBackground(); } public delete(relativePath: RelativePath): void { @@ -290,7 +290,7 @@ export class Database { public setHasInitialSyncCompleted(value: boolean): void { this.hasInitialSyncCompleted = value; - this.save(); + this.saveInTheBackground(); } public getLastSeenUpdateId(): VaultUpdateId { @@ -301,13 +301,13 @@ export class Database { const previousMin = this.lastSeenUpdateIds.min; this.lastSeenUpdateIds.add(value); if (previousMin !== this.lastSeenUpdateIds.min) { - this.save(); + this.saveInTheBackground(); } } public setLastSeenUpdateId(value: number): void { this.lastSeenUpdateIds.min = value; - this.save(); + this.saveInTheBackground(); } public reset(): void { @@ -316,12 +316,18 @@ export class Database { 0 // the first updateId will be 1 which is the first integer after -1 ); this.hasInitialSyncCompleted = false; - this.save(); + this.saveInTheBackground(); } - private save(): void { + private saveInTheBackground(): void { this.ensureConsistency(); - void this.saveData({ + void this.save().catch((error: unknown) => { + this.logger.error(`Error saving data: ${error}`); + }); + } + + public save(): Promise { + return this.saveData({ documents: this.resolvedDocuments.map( ({ relativePath, documentId, metadata }) => ({ documentId, @@ -332,8 +338,6 @@ export class Database { ), lastSeenUpdateId: this.lastSeenUpdateIds.min, hasInitialSyncCompleted: this.hasInitialSyncCompleted - }).catch((error: unknown) => { - this.logger.error(`Error saving data: ${error}`); }); } diff --git a/frontend/sync-client/src/services/fetch-controller.ts b/frontend/sync-client/src/services/fetch-controller.ts index 38dfcb48..1719532d 100644 --- a/frontend/sync-client/src/services/fetch-controller.ts +++ b/frontend/sync-client/src/services/fetch-controller.ts @@ -77,7 +77,7 @@ export class FetchController { */ public finishReset(): void { if (!this.isResetting) { - throw new Error("Cannot finish reset when not resetting"); + return; } this.isResetting = false; diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index cf6e3928..af48b1ad 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -21,13 +21,13 @@ export class WebSocketManager { cursors: ClientCursors[] ) => Promise)[] = []; - private webSocket: WebSocket | undefined; - private isStopped = true; private resolveDisconnectingPromise: null | (() => unknown) = null; private reconnectTimeoutId: ReturnType | undefined; private readonly outstandingPromises: Promise[] = []; + + private webSocket: WebSocket | undefined; private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; public constructor( diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 4bd27228..575f8797 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -29,6 +29,8 @@ import { DIFF_CACHE_SIZE_MB } from "./consts"; export class SyncClient { private hasStartedOfflineSync = false; private hasFinishedOfflineSync = false; + private hasStarted = false; + private hasBeenDestroyed = false; private unloadTelemetry?: () => void; private constructor( @@ -43,6 +45,7 @@ export class SyncClient { private readonly cursorTracker: CursorTracker, private readonly fileChangeNotifier: FileChangeNotifier, private readonly contentCache: FixedSizeDocumentCache, + private readonly fileOperations: FileOperations, private readonly persistence: PersistenceProvider< Partial<{ settings: Partial; @@ -52,7 +55,17 @@ export class SyncClient { ) {} public async start(): Promise { - if (this.settings.getSettings().enableTelemetry) { + this.checkIfDestroyed(); + + if (this.hasStarted) { + throw new Error("SyncClient has already been started"); + } + this.hasStarted = true; + + if ( + !this.unloadTelemetry && + this.settings.getSettings().enableTelemetry + ) { this.unloadTelemetry = setUpTelemetry(); } @@ -73,10 +86,14 @@ export class SyncClient { } } - // Reload settings from disk overriding current in-memory settings. - // Missing values will be filled in from DEFAULT_SETTINGS rather than - // retaining current in-memory settings. + /** + * Reload settings from disk overriding current in-memory settings. + * Missing values will be filled in from DEFAULT_SETTINGS rather than + * retaining current in-memory settings. + */ public async reloadSettings(): Promise { + this.checkIfDestroyed(); + const state = (await this.persistence.load()) ?? { settings: undefined }; @@ -93,15 +110,20 @@ export class SyncClient { newSettings: SyncSettings, oldSettings: SyncSettings ): Promise { - if (newSettings.vaultName !== oldSettings.vaultName) { - await this.reset(); + this.checkIfDestroyed(); + + if ( + newSettings.vaultName !== oldSettings.vaultName || + newSettings.remoteUri !== oldSettings.remoteUri + ) { + await this.applyChangedConnectionSettings(); } if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { if (newSettings.isSyncEnabled) { await this.startSyncing(); } else { - this.stop(); + await this.pause(); } } @@ -119,10 +141,14 @@ export class SyncClient { } public get documentCount(): number { + this.checkIfDestroyed(); + return this.database.length; } public get isWebSocketConnected(): boolean { + this.checkIfDestroyed(); + return this.webSocketManager.isWebSocketConnected; } @@ -203,7 +229,6 @@ export class SyncClient { const fileOperations = new FileOperations( logger, database, - settings, fs, nativeLineEndings ); @@ -258,6 +283,7 @@ export class SyncClient { cursorTracker, fileChangeNotifier, contentCache, + fileOperations, persistence ); @@ -267,6 +293,8 @@ export class SyncClient { } public async checkConnection(): Promise { + this.checkIfDestroyed(); + const server = await this.syncService.checkConnection(); return { isSuccessful: server.isSuccessful, @@ -276,59 +304,94 @@ export class SyncClient { } public getHistoryEntries(): readonly HistoryEntry[] { + this.checkIfDestroyed(); + return this.history.entries; } public addSyncHistoryUpdateListener( listener: (stats: HistoryStats) => unknown ): void { + this.checkIfDestroyed(); + this.history.addSyncHistoryUpdateListener(listener); } private async startSyncing(): Promise { + this.checkIfDestroyed(); + if (!this.hasStartedOfflineSync) { this.hasStartedOfflineSync = true; await this.syncer.scheduleSyncForOfflineChanges(); } this.hasFinishedOfflineSync = true; - this.webSocketManager.start(); - } - - private stop(): void { - this.hasFinishedOfflineSync = false; - this.webSocketManager.stop(); - - this.unloadTelemetry?.(); - } - - public async waitUntilStopped(): Promise { - await this.syncer.waitUntilFinished(); - } - - public async applyChangedConnectionSettings(): Promise { - this.fetchController.startReset(); - this.webSocketManager.stop(); - - this.webSocketManager.start(); this.fetchController.finishReset(); + this.webSocketManager.start(); } - /// Wait for the in-flight operations to finish, reset all tracking, - /// and the local database but retain the settings. - /// The SyncClient can be used again after calling this method. - private async reset(): Promise { - this.stop(); - this.fetchController.startReset(); - this.contentCache.clear(); - await this.syncer.reset(); - this.history.reset(); + /** + * Wait for the in-flight operations to finish, reset all tracking, + * and the local database but retain the settings. + * The SyncClient can be used again after calling this method. + */ + public async applyChangedConnectionSettings(): Promise { + this.checkIfDestroyed(); + + this.logger.info( + "Stopping SyncClient to apply changed connection settings" + ); + await this.pause(); + + // clear all local state + this.logger.info("Resetting SyncClient's local state"); this.database.reset(); - this.logger.reset(); + await this.database.save(); // ensure the new database reads as empty + this.resetInMemoryState(); + this.hasStartedOfflineSync = false; + this.hasFinishedOfflineSync = false; + + // restart syncing this.fetchController.finishReset(); await this.startSyncing(); } + /** + * Completely destroy the SyncClient, cancelling all in-progress operations. + * After calling this method, the SyncClient cannot be used again. + */ + public async destroy(): Promise { + this.checkIfDestroyed(); + + // cancel everything that's in progress + this.fetchController.startReset(); + await this.pause(); + + // clean-up memory early + this.resetInMemoryState(); + + this.logger.info("SyncClient has been successfully disposed"); + + this.unloadTelemetry?.(); + } + + private async pause(): Promise { + this.checkIfDestroyed(); + + this.fetchController.startReset(); + await this.webSocketManager.stop(); + await this.syncer.waitUntilFinished(); + await this.database.save(); // flush all changes to disk + } + + private resetInMemoryState(): void { + this.history.reset(); + this.contentCache.reset(); + this.logger.reset(); + this.cursorTracker.reset(); + this.syncer.reset(); + this.fileOperations.reset(); + } public getSettings(): SyncSettings { return this.settings.getSettings(); } @@ -420,4 +483,12 @@ export class SyncClient { ): void { this.cursorTracker.addRemoteCursorsUpdateListener(listener); } + + private checkIfDestroyed(): void { + if (this.hasBeenDestroyed) { + throw new Error( + "SyncClient has been destroyed and can no longer be used." + ); + } + } } diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index dc5e4cd7..e68cfae7 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -250,4 +250,11 @@ export class CursorTracker { ? DocumentUpToDateness.UpToDate : DocumentUpToDateness.Prior; } + + public reset(): void { + this.knownRemoteCursors = []; + this.lastLocalCursorState = []; + this.lastLocalCursorStateWithoutDirtyDocuments = []; + this.updateLock.reset(); + } } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index cf35a909..e1361302 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -32,7 +32,6 @@ export class Syncer { private readonly syncQueue: PQueue; private _isFirstSyncComplete = false; - private runningScheduleSyncForOfflineChanges: Promise | undefined; public constructor( @@ -514,4 +513,11 @@ export class Syncer { this.database.setHasInitialSyncCompleted(true); } + + public reset(): void { + this._isFirstSyncComplete = false; + this.syncQueue.clear(); + this.remoteDocumentsLock.reset(); + this.runningScheduleSyncForOfflineChanges = undefined; + } } diff --git a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.test.ts b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.test.ts index 4a24aafb..a118815b 100644 --- a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.test.ts +++ b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.test.ts @@ -89,7 +89,7 @@ describe("fixedSizeDocumentCache", () => { assert.equal(cache.get(1), doc1); assert.equal(cache.get(2), doc2); - cache.clear(); + cache.reset(); assert.equal(cache.get(1), undefined); assert.equal(cache.get(2), undefined); diff --git a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts index 8984b790..1541d72f 100644 --- a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts +++ b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts @@ -57,7 +57,7 @@ export class FixedSizeDocumentCache { this.fitBelowMaxSize(); } - public clear(): void { + public reset(): void { this.cache.clear(); this.head = null; this.tail = null; diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index eda89800..6d566f3d 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -131,6 +131,11 @@ export class Locks { this.locked.delete(key); } } + + public reset(): void { + this.locked.clear(); + this.waiters.clear(); + } } export class Lock { @@ -143,4 +148,8 @@ export class Lock { public async withLock(fn: () => R | Promise): Promise { return this.locks.withLock(true, fn); } + + public reset(): void { + this.locks.reset(); + } }