From 18be9f4dd85d3d9915992cc4e696b4b6b51ed57d Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 23 Nov 2025 20:27:16 +0000 Subject: [PATCH] Fix lint --- frontend/local-client-cli/src/cli.ts | 2 +- .../src/obsidian-file-system.ts | 4 +- .../obsidian-plugin/src/vault-link-plugin.ts | 18 +- .../src/file-operations/file-operations.ts | 10 +- .../safe-filesystem-operations.ts | 8 +- .../sync-client/src/persistence/database.ts | 14 +- .../src/services/fetch-controller.test.ts | 5 +- .../src/services/fetch-controller.ts | 20 +- .../src/services/websocket-manager.ts | 99 ++++--- frontend/sync-client/src/sync-client.ts | 258 ++++++++++-------- .../src/sync-operations/cursor-tracker.ts | 14 +- .../sync-client/src/sync-operations/syncer.ts | 14 +- frontend/sync-client/src/utils/await-all.ts | 3 + .../src/utils/data-structures/locks.test.ts | 36 +-- .../src/utils/data-structures/locks.ts | 10 +- .../debugging/slow-web-socket-factory.ts | 1 + frontend/test-client/src/agent/mock-agent.ts | 5 +- frontend/test-client/src/agent/mock-client.ts | 4 +- frontend/test-client/src/cli.ts | 2 + 19 files changed, 301 insertions(+), 226 deletions(-) diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index af5b8a95..625a7bcf 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -226,7 +226,7 @@ async function main(): Promise { ); fileWatcher.stop(); - await client.waitAndStop(); + await client.destroy(); process.exit(1); } } diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index a699433a..bc8265fd 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -1,8 +1,6 @@ import type { Stat, Vault, Workspace } from "obsidian"; import { MarkdownView, normalizePath } from "obsidian"; -import type { - CursorPosition, - TextWithCursors} from "sync-client"; +import type { CursorPosition, TextWithCursors } from "sync-client"; import { utils, type FileSystemOperations, diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 2d14c4eb..336f9750 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -49,7 +49,7 @@ export default class VaultLinkPlugin extends Plugin { this.registerEditorEvents(client); - this.register(() => client.destroy()); + this.register(async () => client.destroy()); await client.start(); }); } @@ -58,8 +58,16 @@ export default class VaultLinkPlugin extends Plugin { new Notice( "VaultLink has been enabled, check out the docs for tips on getting started!" ); - this.activateView(LogsView.TYPE); - this.activateView(HistoryView.TYPE); + void this.activateView(HistoryView.TYPE).catch((e: unknown) => { + this.syncClient?.logger.error( + `Failed to open history view on enable: ${e}` + ); + }); + void this.activateView(LogsView.TYPE).catch((e: unknown) => { + this.syncClient?.logger.error( + `Failed to open logs view on enable: ${e}` + ); + }); this.openSettings(); } @@ -169,7 +177,9 @@ export default class VaultLinkPlugin extends Plugin { client, this.app.workspace ); - this.register(() => cursorListener.dispose); + this.register(() => { + cursorListener.dispose(); + }); this.app.workspace.updateOptions(); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index b8bd7d69..387178f4 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -25,7 +25,7 @@ export class FileOperations { ): [RelativePath, RelativePath] { const pathParts = path.split("/"); const fileName = pathParts.pop(); - if (!fileName || fileName === "") { + if (fileName == null || fileName === "") { throw new Error(`Path '${path}' cannot be empty`); } @@ -166,6 +166,10 @@ export class FileOperations { await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); } + public reset(): void { + this.fs.reset(); + } + private async deletingEmptyParentDirectoriesOfDeletedFile( path: RelativePath ): Promise { @@ -254,8 +258,4 @@ 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 9b3273e4..72aa158d 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -105,6 +105,10 @@ export class SafeFileSystemOperations implements FileSystemOperations { ); } + public reset(): void { + this.locks.reset(); + } + /** * Decorate an operation to ensure that the file exists before running it. * If the operation fails, it will check if the file still exists and throw @@ -138,8 +142,4 @@ 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 03ca7772..2babdadf 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -319,13 +319,6 @@ export class Database { this.saveInTheBackground(); } - private saveInTheBackground(): void { - this.ensureConsistency(); - void this.save().catch((error: unknown) => { - this.logger.error(`Error saving data: ${error}`); - }); - } - public async save(): Promise { return this.saveData({ documents: this.resolvedDocuments.map( @@ -362,4 +355,11 @@ export class Database { ); } } + + private saveInTheBackground(): void { + this.ensureConsistency(); + void this.save().catch((error: unknown) => { + this.logger.error(`Error saving data: ${error}`); + }); + } } diff --git a/frontend/sync-client/src/services/fetch-controller.test.ts b/frontend/sync-client/src/services/fetch-controller.test.ts index 724df3ba..4ff57c55 100644 --- a/frontend/sync-client/src/services/fetch-controller.test.ts +++ b/frontend/sync-client/src/services/fetch-controller.test.ts @@ -1,3 +1,4 @@ +import type { Mock } from "node:test"; import { describe, it, mock, beforeEach, afterEach } from "node:test"; import assert from "node:assert"; import { FetchController } from "./fetch-controller"; @@ -6,7 +7,9 @@ import { SyncResetError } from "./sync-reset-error"; import { sleep } from "../utils/sleep"; describe("FetchController", () => { - const createMockFetch = (shouldSleep: boolean) => + const createMockFetch = ( + shouldSleep: boolean + ): Mock<() => Promise> => mock.fn(async () => { if (shouldSleep) { await sleep(30); diff --git a/frontend/sync-client/src/services/fetch-controller.ts b/frontend/sync-client/src/services/fetch-controller.ts index 1719532d..1e93c853 100644 --- a/frontend/sync-client/src/services/fetch-controller.ts +++ b/frontend/sync-client/src/services/fetch-controller.ts @@ -24,16 +24,6 @@ export class FetchController { createPromise(); } - private static getUrlFromInput(input: RequestInfo | URL): string { - if (input instanceof URL) { - return input.href; - } - if (typeof input === "string") { - return input; - } - return input.url; - } - /** * Whether the fetch implementation can immediately send requests once outside of a reset. */ @@ -58,6 +48,16 @@ export class FetchController { } } + private static getUrlFromInput(input: RequestInfo | URL): string { + if (input instanceof URL) { + return input.href; + } + if (typeof input === "string") { + return input; + } + return input.url; + } + /** * Starts a reset, causing all ongoing and future fetches to be rejected * with a SyncResetError until finishReset is called. diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 0f764b4f..f5cb64a1 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -82,7 +82,7 @@ export class WebSocketManager { } public async stop(): Promise { - const [promise, resolve] = createPromise(); + const [promise, resolve] = createPromise(); this.resolveDisconnectingPromise = resolve; this.isStopped = true; @@ -99,7 +99,7 @@ export class WebSocketManager { await promise; } - await awaitAll(this.outstandingPromises).then(() => {}); + await awaitAll(this.outstandingPromises); } public sendHandshakeMessage( @@ -164,10 +164,25 @@ export class WebSocketManager { ); }; - this.webSocket.onmessage = async (event): Promise => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const message = JSON.parse(event.data) as WebSocketServerMessage; - return this.handleWebSocketMessage(message); + this.webSocket.onmessage = (event): void => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = JSON.parse( + event.data + ) as WebSocketServerMessage; + + void this.handleWebSocketMessage(message).catch( + (error: unknown) => { + this.logger.error( + `Error handling WebSocket message: ${String(error)}` + ); + } + ); + } catch (error) { + this.logger.error( + `Error parsing WebSocket message: ${String(error)}` + ); + } }; this.webSocket.onclose = (event): void => { @@ -194,42 +209,58 @@ export class WebSocketManager { message: WebSocketServerMessage ): Promise { if (message.type === "vaultUpdate") { - this.outstandingPromises.push( - ...this.remoteVaultUpdateListeners.map(async (listener) => { - const promise = listener(message); - return promise.finally(() => { - if (this.outstandingPromises.includes(promise)) { - this.outstandingPromises.splice( - this.outstandingPromises.indexOf(promise), - 1 + const promises = this.remoteVaultUpdateListeners.map( + async (listener) => { + const trackedPromise = listener(message) + .catch((error: unknown) => { + this.logger.error( + `Error in vault update listener: ${String(error)}` ); - } - }); - }) + }) + .finally(() => { + const index = + this.outstandingPromises.indexOf( + trackedPromise + ); + if (index !== -1) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.outstandingPromises.splice(index, 1); + } + }); + await trackedPromise; + } ); + this.outstandingPromises.push(...promises); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (message.type === "cursorPositions") { this.logger.debug( `Received cursor positions for ${JSON.stringify(message.clients)}` ); - this.outstandingPromises.push( - ...this.remoteCursorsUpdateListeners.map(async (listener) => { - const promise = listener( - message.clients.filter( - (client) => client.deviceId !== this.deviceId - ) - ); - - return promise.finally(() => { - if (this.outstandingPromises.includes(promise)) { - this.outstandingPromises.splice( - this.outstandingPromises.indexOf(promise), - 1 - ); - } - }); - }) + const filteredClients = message.clients.filter( + (client) => client.deviceId !== this.deviceId ); + const promises = this.remoteCursorsUpdateListeners.map( + async (listener) => { + const trackedPromise = listener(filteredClients) + .catch((error: unknown) => { + this.logger.error( + `Error in cursor positions listener: ${String(error)}` + ); + }) + .finally(() => { + const index = + this.outstandingPromises.indexOf( + trackedPromise + ); + if (index !== -1) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.outstandingPromises.splice(index, 1); + } + }); + await trackedPromise; + } + ); + this.outstandingPromises.push(...promises); } else { this.logger.warn( `Received unknown message type: ${JSON.stringify(message)}` diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index a9624ccb..6c6bb137 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -30,7 +30,7 @@ export class SyncClient { private hasStartedOfflineSync = false; private hasFinishedOfflineSync = false; private hasStarted = false; - private readonly hasBeenDestroyed = false; + private hasBeenDestroyed = false; private unloadTelemetry?: () => void; private constructor( @@ -54,92 +54,6 @@ export class SyncClient { > ) {} - public async start(): Promise { - 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(); - } - - this.logger.addOnMessageListener((log): void => { - if (log.level === LogLevel.ERROR && Sentry.isInitialized()) { - Sentry.captureMessage(log.message); - } - }); - - this.settings.addOnSettingsChangeListener( - this.onSettingsChange.bind(this) - ); - - if (this.settings.getSettings().isSyncEnabled) { - this.logger.info("Starting SyncClient"); - await this.startSyncing(); - this.logger.info("SyncClient has successfully started"); - } - } - - /** - * 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 - }; - - const settings = { - ...DEFAULT_SETTINGS, - ...(state.settings ?? {}) - }; - - this.setSettings(settings); - } - - private async onSettingsChange( - newSettings: SyncSettings, - oldSettings: SyncSettings - ): Promise { - 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 { - await this.pause(); - } - } - - if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) { - this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024); - } - - if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) { - if (newSettings.enableTelemetry) { - this.unloadTelemetry = setUpTelemetry(); - } else { - this.unloadTelemetry?.(); - } - } - } - public get documentCount(): number { this.checkIfDestroyed(); @@ -151,7 +65,6 @@ export class SyncClient { return this.webSocketManager.isWebSocketConnected; } - public static async create({ fs, persistence, @@ -292,6 +205,58 @@ export class SyncClient { return client; } + public async start(): Promise { + 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(); + } + + this.logger.addOnMessageListener((log): void => { + if (log.level === LogLevel.ERROR && Sentry.isInitialized()) { + Sentry.captureMessage(log.message); + } + }); + + this.settings.addOnSettingsChangeListener( + this.onSettingsChange.bind(this) + ); + + if (this.settings.getSettings().isSyncEnabled) { + this.logger.info("Starting SyncClient"); + await this.startSyncing(); + this.logger.info("SyncClient has successfully started"); + } + } + + /** + * 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 + }; + + const settings = { + ...DEFAULT_SETTINGS, + ...(state.settings ?? {}) + }; + + await this.setSettings(settings); + } + public async checkConnection(): Promise { this.checkIfDestroyed(); @@ -317,19 +282,6 @@ export class SyncClient { this.history.addSyncHistoryUpdateListener(listener); } - private async startSyncing(): Promise { - this.checkIfDestroyed(); - - if (!this.hasStartedOfflineSync) { - this.hasStartedOfflineSync = true; - await this.syncer.scheduleSyncForOfflineChanges(); - } - - this.hasFinishedOfflineSync = true; - 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. @@ -367,6 +319,8 @@ export class SyncClient { this.fetchController.startReset(); await this.pause(); + this.hasBeenDestroyed = true; + // clean-up memory early this.resetInMemoryState(); @@ -375,24 +329,9 @@ export class SyncClient { this.unloadTelemetry?.(); } - private async pause(): Promise { + public getSettings(): SyncSettings { 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(); } @@ -400,32 +339,44 @@ export class SyncClient { key: T, value: SyncSettings[T] ): Promise { + this.checkIfDestroyed(); + await this.settings.setSetting(key, value); } public async setSettings(value: Partial): Promise { + this.checkIfDestroyed(); + await this.settings.setSettings(value); } public addOnSettingsChangeListener( listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown ): void { + this.checkIfDestroyed(); + this.settings.addOnSettingsChangeListener(listener); } public addRemainingSyncOperationsListener( listener: (remainingOperations: number) => unknown ): void { + this.checkIfDestroyed(); + this.syncer.addRemainingOperationsListener(listener); } public addWebSocketStatusChangeListener(listener: () => unknown): void { + this.checkIfDestroyed(); + this.webSocketManager.addWebSocketStatusChangeListener(listener); } public async syncLocallyCreatedFile( relativePath: RelativePath ): Promise { + this.checkIfDestroyed(); + this.fileChangeNotifier.notifyOfFileChange(relativePath); return this.syncer.syncLocallyCreatedFile(relativePath); } @@ -433,6 +384,8 @@ export class SyncClient { public async syncLocallyDeletedFile( relativePath: RelativePath ): Promise { + this.checkIfDestroyed(); + this.fileChangeNotifier.notifyOfFileChange(relativePath); return this.syncer.syncLocallyDeletedFile(relativePath); } @@ -444,6 +397,8 @@ export class SyncClient { oldPath?: RelativePath; relativePath: RelativePath; }): Promise { + this.checkIfDestroyed(); + this.fileChangeNotifier.notifyOfFileChange(relativePath); return this.syncer.syncLocallyUpdatedFile({ oldPath, @@ -454,6 +409,8 @@ export class SyncClient { public getDocumentSyncingStatus( relativePath: RelativePath ): DocumentSyncStatus { + this.checkIfDestroyed(); + if (!this.settings.getSettings().isSyncEnabled) { return DocumentSyncStatus.SYNCING_IS_DISABLED; } @@ -475,15 +432,82 @@ export class SyncClient { public async updateLocalCursors( documentToCursors: Record ): Promise { + this.checkIfDestroyed(); + await this.cursorTracker.sendLocalCursorsToServer(documentToCursors); } public addRemoteCursorsUpdateListener( listener: (cursors: MaybeOutdatedClientCursors[]) => unknown ): void { + this.checkIfDestroyed(); + this.cursorTracker.addRemoteCursorsUpdateListener(listener); } + private async startSyncing(): Promise { + this.checkIfDestroyed(); + + if (!this.hasStartedOfflineSync) { + this.hasStartedOfflineSync = true; + await this.syncer.scheduleSyncForOfflineChanges(); + } + + this.hasFinishedOfflineSync = true; + this.fetchController.finishReset(); + this.webSocketManager.start(); + } + + private async pause(): Promise { + 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(); + } + + private async onSettingsChange( + newSettings: SyncSettings, + oldSettings: SyncSettings + ): Promise { + 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 { + await this.pause(); + } + } + + if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) { + this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024); + } + + if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) { + if (newSettings.enableTelemetry) { + this.unloadTelemetry = setUpTelemetry(); + } else { + this.unloadTelemetry?.(); + } + } + } + private checkIfDestroyed(): void { if (this.hasBeenDestroyed) { throw new Error( diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index e68cfae7..d4cf3c53 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -157,6 +157,13 @@ export class CursorTracker { }); } + public reset(): void { + this.knownRemoteCursors = []; + this.lastLocalCursorState = []; + this.lastLocalCursorStateWithoutDirtyDocuments = []; + this.updateLock.reset(); + } + private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] { const result: MaybeOutdatedClientCursors[] = []; const included = new Set(); @@ -250,11 +257,4 @@ 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 e1361302..43df0a85 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -299,6 +299,13 @@ export class Syncer { } } + public reset(): void { + this._isFirstSyncComplete = false; + this.syncQueue.clear(); + this.remoteDocumentsLock.reset(); + this.runningScheduleSyncForOfflineChanges = undefined; + } + private sendHandshakeMessage(): void { const message: WebSocketClientMessage = { type: "handshake", @@ -513,11 +520,4 @@ 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/await-all.ts b/frontend/sync-client/src/utils/await-all.ts index 07e3859f..b8d50250 100644 --- a/frontend/sync-client/src/utils/await-all.ts +++ b/frontend/sync-client/src/utils/await-all.ts @@ -9,6 +9,7 @@ type ResolvedTuple = { export const awaitAll = async ( promises: PromiseTuple ): Promise> => { + // eslint-disable-next-line no-restricted-properties const result = await Promise.allSettled(promises); for (const res of result) { if (res.status === "rejected") { @@ -16,7 +17,9 @@ export const awaitAll = async ( } } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return result.map( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (res) => (res as PromiseFulfilledResult).value ) as ResolvedTuple; }; diff --git a/frontend/sync-client/src/utils/data-structures/locks.test.ts b/frontend/sync-client/src/utils/data-structures/locks.test.ts index 460f984d..a13bb274 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.test.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert"; import { Logger } from "../../tracing/logger"; import type { RelativePath } from "../../persistence/database"; import { Locks } from "./locks"; +import { awaitAll } from "../await-all"; +import { sleep } from "../sleep"; describe("withLock", () => { const testPath: RelativePath = "test/document/path"; @@ -31,7 +33,7 @@ describe("withLock", () => { let executionCount = 0; const result = await locks.withLock(testPath, async () => { executionCount++; - await new Promise((resolve) => setTimeout(resolve, 10)); + await sleep(10); return "async-success"; }); @@ -56,19 +58,19 @@ describe("withLock", () => { // Start two concurrent operations with keys in different orders const promise1 = locks.withLock([testPath2, testPath], async () => { executionOrder.push("operation1-start"); - await new Promise((resolve) => setTimeout(resolve, 50)); + await sleep(50); executionOrder.push("operation1-end"); return "result1"; }); const promise2 = locks.withLock([testPath, testPath2], async () => { executionOrder.push("operation2-start"); - await new Promise((resolve) => setTimeout(resolve, 50)); + await sleep(50); executionOrder.push("operation2-end"); return "result2"; }); - const [result1, result2] = await Promise.all([promise1, promise2]); + const [result1, result2] = await awaitAll([promise1, promise2]); assert.strictEqual(result1, "result1"); assert.strictEqual(result2, "result2"); @@ -86,19 +88,19 @@ describe("withLock", () => { const promise1 = locks.withLock(testPath, async () => { executionOrder.push("operation1-start"); - await new Promise((resolve) => setTimeout(resolve, 50)); + await sleep(50); executionOrder.push("operation1-end"); return "result1"; }); const promise2 = locks.withLock(testPath, async () => { executionOrder.push("operation2-start"); - await new Promise((resolve) => setTimeout(resolve, 30)); + await sleep(30); executionOrder.push("operation2-end"); return "result2"; }); - const [result1, result2] = await Promise.all([promise1, promise2]); + const [result1, result2] = await awaitAll([promise1, promise2]); assert.strictEqual(result1, "result1"); assert.strictEqual(result2, "result2"); @@ -115,19 +117,20 @@ describe("withLock", () => { const promise1 = locks.withLock(testPath, async () => { executionOrder.push("operation1-start"); - await new Promise((resolve) => setTimeout(resolve, 50)); + await sleep(50); + executionOrder.push("operation1-end"); return "result1"; }); const promise2 = locks.withLock(testPath2, async () => { executionOrder.push("operation2-start"); - await new Promise((resolve) => setTimeout(resolve, 30)); + await sleep(30); executionOrder.push("operation2-end"); return "result2"; }); - const [result1, result2] = await Promise.all([promise1, promise2]); + const [result1, result2] = await awaitAll([promise1, promise2]); assert.strictEqual(result1, "result1"); assert.strictEqual(result2, "result2"); @@ -159,7 +162,8 @@ describe("withLock", () => { await assert.rejects( locks.withLock(testPath, async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); + await sleep(10); + throw error; }), { message: "async test error" } @@ -184,30 +188,30 @@ describe("withLock", () => { // Start first operation that holds the lock const firstPromise = locks.withLock(testPath, async () => { executionOrder.push("first-start"); - await new Promise((resolve) => setTimeout(resolve, 100)); + await sleep(100); executionOrder.push("first-end"); return "first"; }); // Small delay to ensure first operation starts - await new Promise((resolve) => setTimeout(resolve, 10)); + await sleep(10); // Queue second and third operations const secondPromise = locks.withLock(testPath, async () => { executionOrder.push("second-start"); - await new Promise((resolve) => setTimeout(resolve, 30)); + await sleep(50); executionOrder.push("second-end"); return "second"; }); const thirdPromise = locks.withLock(testPath, async () => { executionOrder.push("third-start"); - await new Promise((resolve) => setTimeout(resolve, 20)); + await sleep(20); executionOrder.push("third-end"); return "third"; }); - const [first, second, third] = await Promise.all([ + const [first, second, third] = await awaitAll([ firstPromise, secondPromise, thirdPromise diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index 6d566f3d..c2e7d73a 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -66,6 +66,11 @@ export class Locks { } } + public reset(): void { + this.locked.clear(); + this.waiters.clear(); + } + /** * Attempts to acquire a lock immediately without waiting. * Must call `unlock()` if successful. @@ -131,11 +136,6 @@ export class Locks { this.locked.delete(key); } } - - public reset(): void { - this.locked.clear(); - this.waiters.clear(); - } } export class Lock { diff --git a/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts b/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts index ea77117a..117e9b2f 100644 --- a/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts +++ b/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts @@ -6,6 +6,7 @@ export function slowWebSocketFactory( jitterScaleInSeconds: number, logger: Logger ): typeof WebSocket { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return class FlakyWebSocket extends WebSocket { private static readonly RECEIVE_KEY = "websocket-receive"; private static readonly SEND_KEY = "websocket-send"; diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 980da34b..22d6afcc 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -127,8 +127,9 @@ export class MockAgent extends MockClient { public async finish(): Promise { await this.client.setSetting("isSyncEnabled", true); - await Promise.allSettled(this.pendingActions); - await this.client.waitAndStop(); + // eslint-disable-next-line no-restricted-properties + await Promise.all(this.pendingActions); + await this.client.destroy(); } public assertFileSystemsAreConsistent(otherAgent: MockAgent): void { diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 34186ce7..3121db29 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -1,6 +1,4 @@ -import type { StoredDatabase , - TextWithCursors -} from "sync-client"; +import type { StoredDatabase, TextWithCursors } from "sync-client"; import { assert } from "../utils/assert"; import { type RelativePath, diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index 4a3aab4f..9ae920ac 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -53,10 +53,12 @@ async function runTest({ } try { + // eslint-disable-next-line no-restricted-properties await Promise.all(clients.map(async (client) => client.init())); for (let i = 0; i < iterations; i++) { console.info(`Iteration ${i + 1}/${iterations}`); + // eslint-disable-next-line no-restricted-properties await Promise.all(clients.map(async (client) => client.act())); await sleep(100); }