This commit is contained in:
Andras Schmelczer 2025-11-23 20:27:16 +00:00
parent 9139b4fa4d
commit ca42f614e0
19 changed files with 301 additions and 226 deletions

View file

@ -226,7 +226,7 @@ async function main(): Promise<void> {
); );
fileWatcher.stop(); fileWatcher.stop();
await client.waitAndStop(); await client.destroy();
process.exit(1); process.exit(1);
} }
} }

View file

@ -1,8 +1,6 @@
import type { Stat, Vault, Workspace } from "obsidian"; import type { Stat, Vault, Workspace } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian"; import { MarkdownView, normalizePath } from "obsidian";
import type { import type { CursorPosition, TextWithCursors } from "sync-client";
CursorPosition,
TextWithCursors} from "sync-client";
import { import {
utils, utils,
type FileSystemOperations, type FileSystemOperations,

View file

@ -49,7 +49,7 @@ export default class VaultLinkPlugin extends Plugin {
this.registerEditorEvents(client); this.registerEditorEvents(client);
this.register(() => client.destroy()); this.register(async () => client.destroy());
await client.start(); await client.start();
}); });
} }
@ -58,8 +58,16 @@ export default class VaultLinkPlugin extends Plugin {
new Notice( new Notice(
"VaultLink has been enabled, check out the docs for tips on getting started!" "VaultLink has been enabled, check out the docs for tips on getting started!"
); );
this.activateView(LogsView.TYPE); void this.activateView(HistoryView.TYPE).catch((e: unknown) => {
this.activateView(HistoryView.TYPE); 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(); this.openSettings();
} }
@ -169,7 +177,9 @@ export default class VaultLinkPlugin extends Plugin {
client, client,
this.app.workspace this.app.workspace
); );
this.register(() => cursorListener.dispose); this.register(() => {
cursorListener.dispose();
});
this.app.workspace.updateOptions(); this.app.workspace.updateOptions();

View file

@ -25,7 +25,7 @@ export class FileOperations {
): [RelativePath, RelativePath] { ): [RelativePath, RelativePath] {
const pathParts = path.split("/"); const pathParts = path.split("/");
const fileName = pathParts.pop(); const fileName = pathParts.pop();
if (!fileName || fileName === "") { if (fileName == null || fileName === "") {
throw new Error(`Path '${path}' cannot be empty`); throw new Error(`Path '${path}' cannot be empty`);
} }
@ -166,6 +166,10 @@ export class FileOperations {
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
} }
public reset(): void {
this.fs.reset();
}
private async deletingEmptyParentDirectoriesOfDeletedFile( private async deletingEmptyParentDirectoriesOfDeletedFile(
path: RelativePath path: RelativePath
): Promise<void> { ): Promise<void> {
@ -254,8 +258,4 @@ export class FileOperations {
return newName; return newName;
} }
public reset(): void {
this.fs.reset();
}
} }

View file

@ -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. * 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 * 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();
}
} }

View file

@ -319,13 +319,6 @@ export class Database {
this.saveInTheBackground(); this.saveInTheBackground();
} }
private saveInTheBackground(): void {
this.ensureConsistency();
void this.save().catch((error: unknown) => {
this.logger.error(`Error saving data: ${error}`);
});
}
public async save(): Promise<void> { public async save(): Promise<void> {
return this.saveData({ return this.saveData({
documents: this.resolvedDocuments.map( 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}`);
});
}
} }

View file

@ -1,3 +1,4 @@
import type { Mock } from "node:test";
import { describe, it, mock, beforeEach, afterEach } from "node:test"; import { describe, it, mock, beforeEach, afterEach } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { FetchController } from "./fetch-controller"; import { FetchController } from "./fetch-controller";
@ -6,7 +7,9 @@ import { SyncResetError } from "./sync-reset-error";
import { sleep } from "../utils/sleep"; import { sleep } from "../utils/sleep";
describe("FetchController", () => { describe("FetchController", () => {
const createMockFetch = (shouldSleep: boolean) => const createMockFetch = (
shouldSleep: boolean
): Mock<() => Promise<Response>> =>
mock.fn(async () => { mock.fn(async () => {
if (shouldSleep) { if (shouldSleep) {
await sleep(30); await sleep(30);

View file

@ -24,16 +24,6 @@ export class FetchController {
createPromise<symbol>(); createPromise<symbol>();
} }
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. * 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 * Starts a reset, causing all ongoing and future fetches to be rejected
* with a SyncResetError until finishReset is called. * with a SyncResetError until finishReset is called.

View file

@ -82,7 +82,7 @@ export class WebSocketManager {
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
const [promise, resolve] = createPromise<void>(); const [promise, resolve] = createPromise();
this.resolveDisconnectingPromise = resolve; this.resolveDisconnectingPromise = resolve;
this.isStopped = true; this.isStopped = true;
@ -99,7 +99,7 @@ export class WebSocketManager {
await promise; await promise;
} }
await awaitAll(this.outstandingPromises).then(() => {}); await awaitAll(this.outstandingPromises);
} }
public sendHandshakeMessage( public sendHandshakeMessage(
@ -164,10 +164,25 @@ export class WebSocketManager {
); );
}; };
this.webSocket.onmessage = async (event): Promise<void> => { this.webSocket.onmessage = (event): void => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion try {
const message = JSON.parse(event.data) as WebSocketServerMessage; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return this.handleWebSocketMessage(message); 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 => { this.webSocket.onclose = (event): void => {
@ -194,42 +209,58 @@ export class WebSocketManager {
message: WebSocketServerMessage message: WebSocketServerMessage
): Promise<void> { ): Promise<void> {
if (message.type === "vaultUpdate") { if (message.type === "vaultUpdate") {
this.outstandingPromises.push( const promises = this.remoteVaultUpdateListeners.map(
...this.remoteVaultUpdateListeners.map(async (listener) => { async (listener) => {
const promise = listener(message); const trackedPromise = listener(message)
return promise.finally(() => { .catch((error: unknown) => {
if (this.outstandingPromises.includes(promise)) { this.logger.error(
this.outstandingPromises.splice( `Error in vault update listener: ${String(error)}`
this.outstandingPromises.indexOf(promise),
1
); );
} })
}); .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 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (message.type === "cursorPositions") { } else if (message.type === "cursorPositions") {
this.logger.debug( this.logger.debug(
`Received cursor positions for ${JSON.stringify(message.clients)}` `Received cursor positions for ${JSON.stringify(message.clients)}`
); );
this.outstandingPromises.push( const filteredClients = message.clients.filter(
...this.remoteCursorsUpdateListeners.map(async (listener) => { (client) => client.deviceId !== this.deviceId
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 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 { } else {
this.logger.warn( this.logger.warn(
`Received unknown message type: ${JSON.stringify(message)}` `Received unknown message type: ${JSON.stringify(message)}`

View file

@ -30,7 +30,7 @@ export class SyncClient {
private hasStartedOfflineSync = false; private hasStartedOfflineSync = false;
private hasFinishedOfflineSync = false; private hasFinishedOfflineSync = false;
private hasStarted = false; private hasStarted = false;
private readonly hasBeenDestroyed = false; private hasBeenDestroyed = false;
private unloadTelemetry?: () => void; private unloadTelemetry?: () => void;
private constructor( private constructor(
@ -54,92 +54,6 @@ export class SyncClient {
> >
) {} ) {}
public async start(): Promise<void> {
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<void> {
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<void> {
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 { public get documentCount(): number {
this.checkIfDestroyed(); this.checkIfDestroyed();
@ -151,7 +65,6 @@ export class SyncClient {
return this.webSocketManager.isWebSocketConnected; return this.webSocketManager.isWebSocketConnected;
} }
public static async create({ public static async create({
fs, fs,
persistence, persistence,
@ -292,6 +205,58 @@ export class SyncClient {
return client; return client;
} }
public async start(): Promise<void> {
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<void> {
this.checkIfDestroyed();
const state = (await this.persistence.load()) ?? {
settings: undefined
};
const settings = {
...DEFAULT_SETTINGS,
...(state.settings ?? {})
};
await this.setSettings(settings);
}
public async checkConnection(): Promise<NetworkConnectionStatus> { public async checkConnection(): Promise<NetworkConnectionStatus> {
this.checkIfDestroyed(); this.checkIfDestroyed();
@ -317,19 +282,6 @@ export class SyncClient {
this.history.addSyncHistoryUpdateListener(listener); this.history.addSyncHistoryUpdateListener(listener);
} }
private async startSyncing(): Promise<void> {
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, * Wait for the in-flight operations to finish, reset all tracking,
* and the local database but retain the settings. * and the local database but retain the settings.
@ -367,6 +319,8 @@ export class SyncClient {
this.fetchController.startReset(); this.fetchController.startReset();
await this.pause(); await this.pause();
this.hasBeenDestroyed = true;
// clean-up memory early // clean-up memory early
this.resetInMemoryState(); this.resetInMemoryState();
@ -375,24 +329,9 @@ export class SyncClient {
this.unloadTelemetry?.(); this.unloadTelemetry?.();
} }
private async pause(): Promise<void> { public getSettings(): SyncSettings {
this.checkIfDestroyed(); 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(); return this.settings.getSettings();
} }
@ -400,32 +339,44 @@ export class SyncClient {
key: T, key: T,
value: SyncSettings[T] value: SyncSettings[T]
): Promise<void> { ): Promise<void> {
this.checkIfDestroyed();
await this.settings.setSetting(key, value); await this.settings.setSetting(key, value);
} }
public async setSettings(value: Partial<SyncSettings>): Promise<void> { public async setSettings(value: Partial<SyncSettings>): Promise<void> {
this.checkIfDestroyed();
await this.settings.setSettings(value); await this.settings.setSettings(value);
} }
public addOnSettingsChangeListener( public addOnSettingsChangeListener(
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
): void { ): void {
this.checkIfDestroyed();
this.settings.addOnSettingsChangeListener(listener); this.settings.addOnSettingsChangeListener(listener);
} }
public addRemainingSyncOperationsListener( public addRemainingSyncOperationsListener(
listener: (remainingOperations: number) => unknown listener: (remainingOperations: number) => unknown
): void { ): void {
this.checkIfDestroyed();
this.syncer.addRemainingOperationsListener(listener); this.syncer.addRemainingOperationsListener(listener);
} }
public addWebSocketStatusChangeListener(listener: () => unknown): void { public addWebSocketStatusChangeListener(listener: () => unknown): void {
this.checkIfDestroyed();
this.webSocketManager.addWebSocketStatusChangeListener(listener); this.webSocketManager.addWebSocketStatusChangeListener(listener);
} }
public async syncLocallyCreatedFile( public async syncLocallyCreatedFile(
relativePath: RelativePath relativePath: RelativePath
): Promise<void> { ): Promise<void> {
this.checkIfDestroyed();
this.fileChangeNotifier.notifyOfFileChange(relativePath); this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyCreatedFile(relativePath); return this.syncer.syncLocallyCreatedFile(relativePath);
} }
@ -433,6 +384,8 @@ export class SyncClient {
public async syncLocallyDeletedFile( public async syncLocallyDeletedFile(
relativePath: RelativePath relativePath: RelativePath
): Promise<void> { ): Promise<void> {
this.checkIfDestroyed();
this.fileChangeNotifier.notifyOfFileChange(relativePath); this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyDeletedFile(relativePath); return this.syncer.syncLocallyDeletedFile(relativePath);
} }
@ -444,6 +397,8 @@ export class SyncClient {
oldPath?: RelativePath; oldPath?: RelativePath;
relativePath: RelativePath; relativePath: RelativePath;
}): Promise<void> { }): Promise<void> {
this.checkIfDestroyed();
this.fileChangeNotifier.notifyOfFileChange(relativePath); this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyUpdatedFile({ return this.syncer.syncLocallyUpdatedFile({
oldPath, oldPath,
@ -454,6 +409,8 @@ export class SyncClient {
public getDocumentSyncingStatus( public getDocumentSyncingStatus(
relativePath: RelativePath relativePath: RelativePath
): DocumentSyncStatus { ): DocumentSyncStatus {
this.checkIfDestroyed();
if (!this.settings.getSettings().isSyncEnabled) { if (!this.settings.getSettings().isSyncEnabled) {
return DocumentSyncStatus.SYNCING_IS_DISABLED; return DocumentSyncStatus.SYNCING_IS_DISABLED;
} }
@ -475,15 +432,82 @@ export class SyncClient {
public async updateLocalCursors( public async updateLocalCursors(
documentToCursors: Record<RelativePath, CursorSpan[]> documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> { ): Promise<void> {
this.checkIfDestroyed();
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors); await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
} }
public addRemoteCursorsUpdateListener( public addRemoteCursorsUpdateListener(
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
): void { ): void {
this.checkIfDestroyed();
this.cursorTracker.addRemoteCursorsUpdateListener(listener); this.cursorTracker.addRemoteCursorsUpdateListener(listener);
} }
private async startSyncing(): Promise<void> {
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<void> {
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<void> {
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 { private checkIfDestroyed(): void {
if (this.hasBeenDestroyed) { if (this.hasBeenDestroyed) {
throw new Error( throw new Error(

View file

@ -157,6 +157,13 @@ export class CursorTracker {
}); });
} }
public reset(): void {
this.knownRemoteCursors = [];
this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
this.updateLock.reset();
}
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] { private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
const result: MaybeOutdatedClientCursors[] = []; const result: MaybeOutdatedClientCursors[] = [];
const included = new Set<string>(); const included = new Set<string>();
@ -250,11 +257,4 @@ export class CursorTracker {
? DocumentUpToDateness.UpToDate ? DocumentUpToDateness.UpToDate
: DocumentUpToDateness.Prior; : DocumentUpToDateness.Prior;
} }
public reset(): void {
this.knownRemoteCursors = [];
this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
this.updateLock.reset();
}
} }

View file

@ -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 { private sendHandshakeMessage(): void {
const message: WebSocketClientMessage = { const message: WebSocketClientMessage = {
type: "handshake", type: "handshake",
@ -513,11 +520,4 @@ export class Syncer {
this.database.setHasInitialSyncCompleted(true); this.database.setHasInitialSyncCompleted(true);
} }
public reset(): void {
this._isFirstSyncComplete = false;
this.syncQueue.clear();
this.remoteDocumentsLock.reset();
this.runningScheduleSyncForOfflineChanges = undefined;
}
} }

View file

@ -9,6 +9,7 @@ type ResolvedTuple<T extends readonly unknown[]> = {
export const awaitAll = async <T extends readonly unknown[]>( export const awaitAll = async <T extends readonly unknown[]>(
promises: PromiseTuple<T> promises: PromiseTuple<T>
): Promise<ResolvedTuple<T>> => { ): Promise<ResolvedTuple<T>> => {
// eslint-disable-next-line no-restricted-properties
const result = await Promise.allSettled(promises); const result = await Promise.allSettled(promises);
for (const res of result) { for (const res of result) {
if (res.status === "rejected") { if (res.status === "rejected") {
@ -16,7 +17,9 @@ export const awaitAll = async <T extends readonly unknown[]>(
} }
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return result.map( return result.map(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(res) => (res as PromiseFulfilledResult<unknown>).value (res) => (res as PromiseFulfilledResult<unknown>).value
) as ResolvedTuple<T>; ) as ResolvedTuple<T>;
}; };

View file

@ -3,6 +3,8 @@ import assert from "node:assert";
import { Logger } from "../../tracing/logger"; import { Logger } from "../../tracing/logger";
import type { RelativePath } from "../../persistence/database"; import type { RelativePath } from "../../persistence/database";
import { Locks } from "./locks"; import { Locks } from "./locks";
import { awaitAll } from "../await-all";
import { sleep } from "../sleep";
describe("withLock", () => { describe("withLock", () => {
const testPath: RelativePath = "test/document/path"; const testPath: RelativePath = "test/document/path";
@ -31,7 +33,7 @@ describe("withLock", () => {
let executionCount = 0; let executionCount = 0;
const result = await locks.withLock(testPath, async () => { const result = await locks.withLock(testPath, async () => {
executionCount++; executionCount++;
await new Promise((resolve) => setTimeout(resolve, 10)); await sleep(10);
return "async-success"; return "async-success";
}); });
@ -56,19 +58,19 @@ describe("withLock", () => {
// Start two concurrent operations with keys in different orders // Start two concurrent operations with keys in different orders
const promise1 = locks.withLock([testPath2, testPath], async () => { const promise1 = locks.withLock([testPath2, testPath], async () => {
executionOrder.push("operation1-start"); executionOrder.push("operation1-start");
await new Promise((resolve) => setTimeout(resolve, 50)); await sleep(50);
executionOrder.push("operation1-end"); executionOrder.push("operation1-end");
return "result1"; return "result1";
}); });
const promise2 = locks.withLock([testPath, testPath2], async () => { const promise2 = locks.withLock([testPath, testPath2], async () => {
executionOrder.push("operation2-start"); executionOrder.push("operation2-start");
await new Promise((resolve) => setTimeout(resolve, 50)); await sleep(50);
executionOrder.push("operation2-end"); executionOrder.push("operation2-end");
return "result2"; return "result2";
}); });
const [result1, result2] = await Promise.all([promise1, promise2]); const [result1, result2] = await awaitAll([promise1, promise2]);
assert.strictEqual(result1, "result1"); assert.strictEqual(result1, "result1");
assert.strictEqual(result2, "result2"); assert.strictEqual(result2, "result2");
@ -86,19 +88,19 @@ describe("withLock", () => {
const promise1 = locks.withLock(testPath, async () => { const promise1 = locks.withLock(testPath, async () => {
executionOrder.push("operation1-start"); executionOrder.push("operation1-start");
await new Promise((resolve) => setTimeout(resolve, 50)); await sleep(50);
executionOrder.push("operation1-end"); executionOrder.push("operation1-end");
return "result1"; return "result1";
}); });
const promise2 = locks.withLock(testPath, async () => { const promise2 = locks.withLock(testPath, async () => {
executionOrder.push("operation2-start"); executionOrder.push("operation2-start");
await new Promise((resolve) => setTimeout(resolve, 30)); await sleep(30);
executionOrder.push("operation2-end"); executionOrder.push("operation2-end");
return "result2"; return "result2";
}); });
const [result1, result2] = await Promise.all([promise1, promise2]); const [result1, result2] = await awaitAll([promise1, promise2]);
assert.strictEqual(result1, "result1"); assert.strictEqual(result1, "result1");
assert.strictEqual(result2, "result2"); assert.strictEqual(result2, "result2");
@ -115,19 +117,20 @@ describe("withLock", () => {
const promise1 = locks.withLock(testPath, async () => { const promise1 = locks.withLock(testPath, async () => {
executionOrder.push("operation1-start"); executionOrder.push("operation1-start");
await new Promise((resolve) => setTimeout(resolve, 50)); await sleep(50);
executionOrder.push("operation1-end"); executionOrder.push("operation1-end");
return "result1"; return "result1";
}); });
const promise2 = locks.withLock(testPath2, async () => { const promise2 = locks.withLock(testPath2, async () => {
executionOrder.push("operation2-start"); executionOrder.push("operation2-start");
await new Promise((resolve) => setTimeout(resolve, 30)); await sleep(30);
executionOrder.push("operation2-end"); executionOrder.push("operation2-end");
return "result2"; return "result2";
}); });
const [result1, result2] = await Promise.all([promise1, promise2]); const [result1, result2] = await awaitAll([promise1, promise2]);
assert.strictEqual(result1, "result1"); assert.strictEqual(result1, "result1");
assert.strictEqual(result2, "result2"); assert.strictEqual(result2, "result2");
@ -159,7 +162,8 @@ describe("withLock", () => {
await assert.rejects( await assert.rejects(
locks.withLock(testPath, async () => { locks.withLock(testPath, async () => {
await new Promise((resolve) => setTimeout(resolve, 10)); await sleep(10);
throw error; throw error;
}), }),
{ message: "async test error" } { message: "async test error" }
@ -184,30 +188,30 @@ describe("withLock", () => {
// Start first operation that holds the lock // Start first operation that holds the lock
const firstPromise = locks.withLock(testPath, async () => { const firstPromise = locks.withLock(testPath, async () => {
executionOrder.push("first-start"); executionOrder.push("first-start");
await new Promise((resolve) => setTimeout(resolve, 100)); await sleep(100);
executionOrder.push("first-end"); executionOrder.push("first-end");
return "first"; return "first";
}); });
// Small delay to ensure first operation starts // Small delay to ensure first operation starts
await new Promise((resolve) => setTimeout(resolve, 10)); await sleep(10);
// Queue second and third operations // Queue second and third operations
const secondPromise = locks.withLock(testPath, async () => { const secondPromise = locks.withLock(testPath, async () => {
executionOrder.push("second-start"); executionOrder.push("second-start");
await new Promise((resolve) => setTimeout(resolve, 30)); await sleep(50);
executionOrder.push("second-end"); executionOrder.push("second-end");
return "second"; return "second";
}); });
const thirdPromise = locks.withLock(testPath, async () => { const thirdPromise = locks.withLock(testPath, async () => {
executionOrder.push("third-start"); executionOrder.push("third-start");
await new Promise((resolve) => setTimeout(resolve, 20)); await sleep(20);
executionOrder.push("third-end"); executionOrder.push("third-end");
return "third"; return "third";
}); });
const [first, second, third] = await Promise.all([ const [first, second, third] = await awaitAll([
firstPromise, firstPromise,
secondPromise, secondPromise,
thirdPromise thirdPromise

View file

@ -66,6 +66,11 @@ export class Locks<T> {
} }
} }
public reset(): void {
this.locked.clear();
this.waiters.clear();
}
/** /**
* Attempts to acquire a lock immediately without waiting. * Attempts to acquire a lock immediately without waiting.
* Must call `unlock()` if successful. * Must call `unlock()` if successful.
@ -131,11 +136,6 @@ export class Locks<T> {
this.locked.delete(key); this.locked.delete(key);
} }
} }
public reset(): void {
this.locked.clear();
this.waiters.clear();
}
} }
export class Lock { export class Lock {

View file

@ -6,6 +6,7 @@ export function slowWebSocketFactory(
jitterScaleInSeconds: number, jitterScaleInSeconds: number,
logger: Logger logger: Logger
): typeof WebSocket { ): typeof WebSocket {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return class FlakyWebSocket extends WebSocket { return class FlakyWebSocket extends WebSocket {
private static readonly RECEIVE_KEY = "websocket-receive"; private static readonly RECEIVE_KEY = "websocket-receive";
private static readonly SEND_KEY = "websocket-send"; private static readonly SEND_KEY = "websocket-send";

View file

@ -127,8 +127,9 @@ export class MockAgent extends MockClient {
public async finish(): Promise<void> { public async finish(): Promise<void> {
await this.client.setSetting("isSyncEnabled", true); await this.client.setSetting("isSyncEnabled", true);
await Promise.allSettled(this.pendingActions); // eslint-disable-next-line no-restricted-properties
await this.client.waitAndStop(); await Promise.all(this.pendingActions);
await this.client.destroy();
} }
public assertFileSystemsAreConsistent(otherAgent: MockAgent): void { public assertFileSystemsAreConsistent(otherAgent: MockAgent): void {

View file

@ -1,6 +1,4 @@
import type { StoredDatabase , import type { StoredDatabase, TextWithCursors } from "sync-client";
TextWithCursors
} from "sync-client";
import { assert } from "../utils/assert"; import { assert } from "../utils/assert";
import { import {
type RelativePath, type RelativePath,

View file

@ -53,10 +53,12 @@ async function runTest({
} }
try { try {
// eslint-disable-next-line no-restricted-properties
await Promise.all(clients.map(async (client) => client.init())); await Promise.all(clients.map(async (client) => client.init()));
for (let i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {
console.info(`Iteration ${i + 1}/${iterations}`); console.info(`Iteration ${i + 1}/${iterations}`);
// eslint-disable-next-line no-restricted-properties
await Promise.all(clients.map(async (client) => client.act())); await Promise.all(clients.map(async (client) => client.act()));
await sleep(100); await sleep(100);
} }