more ai changes

This commit is contained in:
Andras Schmelczer 2026-04-25 20:29:38 +01:00
parent bff3f5a5e9
commit 8ce33541a3
11 changed files with 208 additions and 185 deletions

View file

@ -45,7 +45,7 @@ Clients always start with syncing disabled.
cd sync-server && cargo build --release && cd - cd sync-server && cargo build --release && cd -
# Run all tests # Run all tests
cd frontend && npm run test -w deterministic-tests cd frontend && npm run build -w sync-client && npm run test -w deterministic-tests
# Filter by name # Filter by name
npm run test -w deterministic-tests -- --filter=rename npm run test -w deterministic-tests -- --filter=rename

View file

@ -8,6 +8,7 @@ import { isFileTypeMergable } from "../utils/is-file-type-mergable";
import { isBinary } from "../utils/is-binary"; import { isBinary } from "../utils/is-binary";
import { buildConflictFileName } from "../sync-operations/conflict-path"; import { buildConflictFileName } from "../sync-operations/conflict-path";
import type { ServerConfig } from "../services/server-config"; import type { ServerConfig } from "../services/server-config";
import { FileNotFoundError } from "../errors/file-not-found-error";
export enum MoveOnConflict { export enum MoveOnConflict {
EXISTING = "EXISTING", EXISTING = "EXISTING",
@ -95,6 +96,13 @@ export class FileOperations {
return; return;
} }
// The exists() check above is racy: between it returning true and
// any of the writes below running, the file can be deleted. The
// safe wrapper around `atomicUpdateText` raises FileNotFoundError
// in that window — treat it the same as the upfront-missing case
// (skip silently) so callers see one consistent outcome regardless
// of when the deletion happened to occur.
try {
if ( if (
!isFileTypeMergable( !isFileTypeMergable(
path, path,
@ -156,6 +164,15 @@ export class FileOperations {
}; };
} }
); );
} catch (e) {
if (e instanceof FileNotFoundError) {
this.logger.debug(
`File ${path} disappeared during write; not recreating`
);
return;
}
throw e;
}
} }
public async delete(path: RelativePath): Promise<void> { public async delete(path: RelativePath): Promise<void> {

View file

@ -342,7 +342,7 @@ export class SyncService {
const url = new URL(this.getUrl("/documents")); const url = new URL(this.getUrl("/documents"));
if (since !== undefined) { if (since !== undefined) {
url.searchParams.append("since", since.toString()); url.searchParams.append("since_update_id", since.toString());
} }
const response = await this.client(url.toString(), { const response = await this.client(url.toString(), {
headers: this.getDefaultHeaders() headers: this.getDefaultHeaders()

View file

@ -204,19 +204,22 @@ export class WebSocketManager {
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
this.webSocket = new this.webSocketFactoryImplementation(wsUri); const ws = new this.webSocketFactoryImplementation(wsUri);
this.webSocket = ws;
// Set connection timeout to handle cases where server is down and the WebSocket connection won't open // Set connection timeout to handle cases where server is down and the WebSocket connection won't open.
// The callback closes the *captured* `ws` rather than `this.webSocket` so a delayed timeout cannot
// accidentally close a freshly-constructed replacement socket. (Closing the already-closed `ws` is a no-op.)
this.connectionTimeoutId = setTimeout(() => { this.connectionTimeoutId = setTimeout(() => {
this.connectionTimeoutId = undefined; this.connectionTimeoutId = undefined;
this.logger.warn( this.logger.warn(
`WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds` `WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds`
); );
// Force close to trigger onclose handler which will schedule reconnection // Force close to trigger onclose handler which will schedule reconnection
this.webSocket?.close(1000, "Connection timeout"); ws.close(1000, "Connection timeout");
}, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000); }, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000);
this.webSocket.onopen = (): void => { ws.onopen = (): void => {
if (this.connectionTimeoutId !== undefined) { if (this.connectionTimeoutId !== undefined) {
clearTimeout(this.connectionTimeoutId); clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = undefined; this.connectionTimeoutId = undefined;
@ -224,7 +227,7 @@ export class WebSocketManager {
// Check if we've been stopped while connecting // Check if we've been stopped while connecting
if (this.isStopped) { if (this.isStopped) {
this.webSocket?.close( ws.close(
1000, 1000,
"WebSocketManager was stopped during connection" "WebSocketManager was stopped during connection"
); );
@ -234,7 +237,7 @@ export class WebSocketManager {
this.onWebSocketStatusChanged.trigger(true); this.onWebSocketStatusChanged.trigger(true);
}; };
this.webSocket.onmessage = (event): void => { ws.onmessage = (event): void => {
try { try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = JSON.parse( const message = JSON.parse(
@ -265,13 +268,13 @@ export class WebSocketManager {
} }
}; };
this.webSocket.onerror = (error): void => { ws.onerror = (error): void => {
this.logger.warn( this.logger.warn(
`WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}` `WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}`
); );
}; };
this.webSocket.onclose = (event): void => { ws.onclose = (event): void => {
if (this.connectionTimeoutId !== undefined) { if (this.connectionTimeoutId !== undefined) {
clearTimeout(this.connectionTimeoutId); clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = undefined; this.connectionTimeoutId = undefined;

View file

@ -399,7 +399,7 @@ export class SyncClient {
return DocumentSyncStatus.SYNCING_IS_DISABLED; return DocumentSyncStatus.SYNCING_IS_DISABLED;
} }
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) { if (!this.syncer.isFirstSyncStarted || !this.hasFinishedOfflineSync) {
return DocumentSyncStatus.SYNCING; return DocumentSyncStatus.SYNCING;
} }
@ -428,8 +428,11 @@ export class SyncClient {
* After calling this method, the SyncClient cannot be used again. * After calling this method, the SyncClient cannot be used again.
*/ */
public async destroy(): Promise<void> { public async destroy(): Promise<void> {
this.checkIfDestroyed("destroy"); if (this.hasBeenDestroyed) {
throw new Error(
"SyncClient has been destroyed and can no longer be used; called from destroy"
);
}
if (this.isDestroying) { if (this.isDestroying) {
this.logger.warn( this.logger.warn(
"destroy() called while already destroying, ignoring" "destroy() called while already destroying, ignoring"
@ -534,7 +537,11 @@ export class SyncClient {
} }
private checkIfDestroyed(origin: string): void { private checkIfDestroyed(origin: string): void {
if (this.hasBeenDestroyed) { // Reject new public-API entries the moment destroy() is called,
// not after `pause()` returns. Otherwise an external caller could
// pass the guard and start mutating state while destroy() is
// tearing down the websocket / clearing caches.
if (this.hasBeenDestroyed || this.isDestroying) {
throw new Error( throw new Error(
`SyncClient has been destroyed and can no longer be used; called from ${origin}` `SyncClient has been destroyed and can no longer be used; called from ${origin}`
); );

View file

@ -30,9 +30,13 @@ export class CursorTracker {
upToDateness: DocumentUpToDateness; upToDateness: DocumentUpToDateness;
})[] = []; })[] = [];
private lastLocalCursorState: DocumentWithCursors[] = []; // Cache the previously sent state as a JSON string rather than as the
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] = // array. We mutate `documentsWithCursors` in-place after the cache check
[]; // (setting `vaultUpdateId = null` for dirty docs); storing the array would
// alias and the next call's equality check would compare against
// post-mutation state.
private lastLocalCursorStateJson = "[]";
private lastLocalCursorStateWithoutDirtyDocumentsJson = "[]";
public constructor( public constructor(
logger: Logger, logger: Logger,
@ -99,6 +103,9 @@ export class CursorTracker {
public async sendLocalCursorsToServer( public async sendLocalCursorsToServer(
documentToCursors: Record<RelativePath, CursorSpan[]> documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> { ): Promise<void> {
// Serialise concurrent senders so they don't interleave on the
// disk reads + state mutations and emit out-of-order cursor messages.
await this.updateLock.withLock(async () => {
const documentsWithCursors: DocumentWithCursors[] = []; const documentsWithCursors: DocumentWithCursors[] = [];
for (const [relativePath, cursors] of Object.entries( for (const [relativePath, cursors] of Object.entries(
@ -121,14 +128,12 @@ export class CursorTracker {
}); });
} }
if ( const beforeJson = JSON.stringify(documentsWithCursors);
JSON.stringify(this.lastLocalCursorState) === if (this.lastLocalCursorStateJson === beforeJson) {
JSON.stringify(documentsWithCursors)
) {
// Caching step to avoid reading the edited files all the time // Caching step to avoid reading the edited files all the time
return; return;
} }
this.lastLocalCursorState = documentsWithCursors; this.lastLocalCursorStateJson = beforeJson;
for (const doc of documentsWithCursors) { for (const doc of documentsWithCursors) {
const readContent = await this.fileOperations.read( const readContent = await this.fileOperations.read(
@ -142,22 +147,21 @@ export class CursorTracker {
} }
} }
if ( const afterJson = JSON.stringify(documentsWithCursors);
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) === if (this.lastLocalCursorStateWithoutDirtyDocumentsJson === afterJson) {
JSON.stringify(documentsWithCursors)
) {
return; return;
} }
this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors; this.lastLocalCursorStateWithoutDirtyDocumentsJson = afterJson;
this.webSocketManager.updateLocalCursors({ documentsWithCursors }); this.webSocketManager.updateLocalCursors({ documentsWithCursors });
});
} }
public reset(): void { public reset(): void {
this.knownRemoteCursors = []; this.knownRemoteCursors = [];
this.lastLocalCursorState = []; this.lastLocalCursorStateJson = "[]";
this.lastLocalCursorStateWithoutDirtyDocuments = []; this.lastLocalCursorStateWithoutDirtyDocumentsJson = "[]";
this.updateLock.reset(); this.updateLock.reset();
} }

View file

@ -75,7 +75,7 @@ export class Syncer {
); );
} }
public get isFirstSyncComplete(): boolean { public get isFirstSyncStarted(): boolean {
return this._isFirstSyncStarted; return this._isFirstSyncStarted;
} }
@ -264,36 +264,34 @@ export class Syncer {
break; break;
} }
} catch (e) { } catch (e) {
if (e instanceof FileNotFoundError) { // The currently-processed event was already shifted off the queue
this.logger.info( // by drain() before processEvent ran. If it's a LocalCreate, any
`Skipping sync event '${event.type}' because the file no longer exists` // queued Delete/Update events whose `documentId` is this Create's
); // resolvers.promise would `await` it forever once we return — so
// settle the resolvers on every failure path before
// dispatching/re-throwing. clearPending()'s rejectAllPendingCreates
// walks the queue and so cannot reach this in-flight event.
// Re-rejecting an already-resolved promise is a no-op, so it's
// safe to call this unconditionally on the LocalCreate branch.
if (event.type === SyncEventType.LocalCreate) { if (event.type === SyncEventType.LocalCreate) {
event.resolvers.promise.catch(() => { event.resolvers.promise.catch(() => {
/* suppressed */ /* suppressed */
}); });
event.resolvers.reject(new Error("Create was cancelled")); event.resolvers.reject(
new Error(`Create was cancelled: ${e}`)
);
} }
if (e instanceof FileNotFoundError) {
this.logger.info(
`Skipping sync event '${event.type}' because the file no longer exists`
);
return; return;
} }
if (e instanceof HttpClientError) { if (e instanceof HttpClientError) {
this.logger.error( this.logger.error(
`Server rejected ${event.type} request: ${e.message}` `Server rejected ${event.type} request: ${e.message}`
); );
// The event was already shifted off the queue before
// `processEvent` ran; if it was a Create, its resolver
// promise would otherwise hang forever, blocking any
// queued Delete / SyncLocal that `await`s it.
if (event.type === SyncEventType.LocalCreate) {
event.resolvers.promise.catch(() => {
/* suppressed */
});
event.resolvers.reject(
new Error(
`Create was cancelled — server rejected the request (${e.message})`
)
);
}
return; return;
} }
throw e; throw e;
@ -513,6 +511,7 @@ export class Syncer {
if (createEvent === undefined) { if (createEvent === undefined) {
// a http response will always be more up-to-date than any queued remote update // a http response will always be more up-to-date than any queued remote update
// move will always move to the relative path when MoveOnConflict.EXISTING is given
await this.operations.move( await this.operations.move(
path, path,
response.relativePath, response.relativePath,

View file

@ -40,9 +40,12 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
* @param args The arguments to pass to each listener * @param args The arguments to pass to each listener
*/ */
public trigger(...args: Parameters<TListener>): void { public trigger(...args: Parameters<TListener>): void {
this.listeners.forEach((listener) => { const snapshot = this.listeners.slice();
for (const listener of snapshot) {
// allow removing listeners during the trigger loop
if (!this.listeners.includes(listener)) continue;
listener(...args); listener(...args);
}); }
} }
/** /**
@ -53,16 +56,17 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
* @param args The arguments to pass to each listener * @param args The arguments to pass to each listener
*/ */
public async triggerAsync(...args: Parameters<TListener>): Promise<void> { public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
await awaitAll( const snapshot = this.listeners.slice();
this.listeners const promises: Promise<unknown>[] = [];
.map((listener) => { for (const listener of snapshot) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return if (!this.listeners.includes(listener)) continue;
return listener(...args); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
}) const result = listener(...args);
.filter((result): result is Promise<unknown> => { if (result instanceof Promise) {
return result instanceof Promise; promises.push(result);
}) }
); }
await awaitAll(promises);
} }
public clear(): void { public clear(): void {

View file

@ -48,29 +48,25 @@ describe("MinCovered", () => {
assert.strictEqual(covered.min, 6); assert.strictEqual(covered.min, 6);
}); });
it("should auto-advance when setting min value", () => { it("should auto-advance when adding the value that fills the next gap", () => {
const covered = new MinCovered(5); const covered = new MinCovered(5);
covered.add(7); covered.add(7);
covered.add(8); covered.add(8);
covered.add(9); covered.add(9);
assert.strictEqual(covered.min, 5); assert.strictEqual(covered.min, 5);
// Setting min to 6 should auto-advance through 7, 8, 9 // Adding 6 fills the gap and auto-advances through 7, 8, 9
covered.min = 6; covered.add(6);
assert.strictEqual(covered.min, 9); assert.strictEqual(covered.min, 9);
covered.add(10); covered.add(10);
assert.strictEqual(covered.min, 10); assert.strictEqual(covered.min, 10);
}); });
it("should handle setting min value with no consecutive values", () => { it("should rewind when reset is called explicitly", () => {
const covered = new MinCovered(5); const covered = new MinCovered(5);
covered.add(10); covered.add(7);
covered.add(15); covered.reset(3);
assert.strictEqual(covered.min, 5); assert.strictEqual(covered.min, 3);
// Setting min to 8 should not auto-advance (no consecutive values) covered.add(4);
covered.min = 8; assert.strictEqual(covered.min, 4);
assert.strictEqual(covered.min, 8);
// Add 9 to trigger auto-advance to 10
covered.add(9);
assert.strictEqual(covered.min, 10);
}); });
}); });

View file

@ -16,18 +16,12 @@
export class MinCovered { export class MinCovered {
private seenValues: number[] = []; private seenValues: number[] = [];
public constructor(private minValue: number) {} public constructor(private minValue: number) { }
public get min(): number { public get min(): number {
return this.minValue; return this.minValue;
} }
public set min(value: number) {
this.minValue = Math.max(value, this.minValue);
this.seenValues = this.seenValues.filter((v) => v > this.minValue);
this.advanceMinWhilePossible();
}
public add(value: number | undefined): void { public add(value: number | undefined): void {
if (value === undefined || value < this.minValue) { if (value === undefined || value < this.minValue) {
return; return;

View file

@ -44,20 +44,19 @@ export function rateLimit<
newArgs = undefined; newArgs = undefined;
} }
const { promise, resolve } = Promise.withResolvers<undefined>(); // `running` must signal both "minimum interval has elapsed" *and*
running = promise; // "fn() has finished" — otherwise an `fn` that takes longer than
sleep( // the interval would let a queued waiter fire a concurrent `fn`
const interval =
typeof minIntervalMs === "function" typeof minIntervalMs === "function"
? minIntervalMs() ? minIntervalMs()
: minIntervalMs : minIntervalMs;
) const fnPromise = fn(...args);
.then(() => { running = Promise.all([
resolve(undefined); fnPromise.catch(() => undefined),
}) sleep(interval)
.catch(() => { ]);
// sleep cannot fail return fnPromise;
});
return fn(...args);
}; };
return decoratedFn; return decoratedFn;