From d8058d396c8e1d372d16cd88ab07fe15969a267d Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 23 Nov 2025 15:22:50 +0000 Subject: [PATCH] Add awaitAll --- frontend/eslint.config.mjs | 7 ++- .../sync-client/src/persistence/database.ts | 3 +- .../src/services/websocket-manager.ts | 7 ++- .../sync-client/src/sync-operations/syncer.ts | 11 ++-- .../sync-client/src/utils/await-all.test.ts | 56 +++++++++++++++++++ frontend/sync-client/src/utils/await-all.ts | 22 ++++++++ .../src/utils/data-structures/locks.ts | 5 +- frontend/test-client/src/cli.ts | 6 +- 8 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 frontend/sync-client/src/utils/await-all.test.ts create mode 100644 frontend/sync-client/src/utils/await-all.ts diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 4ed3f642..b2ed7a35 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -42,7 +42,12 @@ export default [ { object: "Promise", property: "all", - message: "Use Promise.allSettled instead of Promise.all to always await all promises." + message: "Use `awaitAll` instead of Promise.all to always await all promises." + }, + { + object: "Promise", + property: "allSettled", + message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors." }, { object: "String", diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts index 62962dba..1ad5af71 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -1,6 +1,7 @@ import type { Logger } from "../tracing/logger"; import { EMPTY_HASH } from "../utils/hash"; import { CoveredValues } from "../utils/data-structures/min-covered"; +import { awaitAll } from "../utils/await-all"; export type VaultUpdateId = number; export type DocumentId = string; @@ -183,7 +184,7 @@ export class Database { const currentPromises = entry.updates; entry.updates = [...currentPromises, promise]; - await Promise.allSettled(currentPromises); + await awaitAll(currentPromises); return entry; } diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index e399b0be..cf6e3928 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -6,6 +6,7 @@ import type { CursorPositionFromClient } from "./types/CursorPositionFromClient" import type { ClientCursors } from "./types/ClientCursors"; import { createPromise } from "../utils/create-promise"; import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; +import { awaitAll } from "../utils/await-all"; export class WebSocketManager { private readonly webSocketStatusChangeListeners: (( @@ -98,13 +99,13 @@ export class WebSocketManager { await promise; } - await Promise.allSettled(this.outstandingPromises).then(() => {}); + await awaitAll(this.outstandingPromises).then(() => {}); } public sendHandshakeMessage( message: WebSocketClientMessage & { type: "handshake" } ): void { - const {webSocket} = this; + const { webSocket } = this; if (!webSocket) { throw new Error( "WebSocket is not connected, cannot send handshake message" @@ -126,7 +127,7 @@ export class WebSocketManager { type: "cursorPositions", ...cursorPositions }; - const {webSocket} = this; + const { webSocket } = this; if (!webSocket) { this.logger.warn( "WebSocket is not connected, cannot send cursor positions" diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 053eaacd..cf35a909 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -20,6 +20,7 @@ import type { DocumentVersionWithoutContent } from "../services/types/DocumentVe import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; import type { WebSocketManager } from "../services/websocket-manager"; import type { WebSocketClientMessage } from "../services/types/WebSocketClientMessage"; +import { awaitAll } from "../utils/await-all"; export class Syncer { private readonly remoteDocumentsLock: Locks; @@ -277,7 +278,7 @@ export class Syncer { message: WebSocketVaultUpdate ): Promise { try { - const handlerPromise = Promise.allSettled( + const handlerPromise = awaitAll( message.documents.map(async (document) => this.internalSyncRemotelyUpdatedFile(document) ) @@ -405,7 +406,7 @@ export class Syncer { } } - const updates = Promise.allSettled( + const updates = awaitAll( allLocalFiles.map(async (relativePath) => { if ( this.database.getLatestDocumentByRelativePath(relativePath) @@ -463,7 +464,7 @@ export class Syncer { }) ); - const deletes = Promise.allSettled( + const deletes = awaitAll( locallyPossiblyDeletedFiles.map(async ({ relativePath }) => { this.logger.debug( `Document ${relativePath} has been deleted locally, scheduling sync to delete it` @@ -474,7 +475,7 @@ export class Syncer { }) ); - await Promise.allSettled([updates, deletes]); + await awaitAll([updates, deletes]); } /** @@ -487,7 +488,7 @@ export class Syncer { return; } - const [allLocalFiles, remote] = await Promise.allSettled([ + const [allLocalFiles, remote] = await awaitAll([ this.operations.listFilesRecursively(), this.syncQueue.add(async () => this.syncService.getAll()) ]); diff --git a/frontend/sync-client/src/utils/await-all.test.ts b/frontend/sync-client/src/utils/await-all.test.ts new file mode 100644 index 00000000..bbce9423 --- /dev/null +++ b/frontend/sync-client/src/utils/await-all.test.ts @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { awaitAll } from "./await-all"; + +void test("awaitAll resolves promises of the same type", async () => { + const promises = [ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3) + ]; + + const results = await awaitAll(promises); + assert.deepStrictEqual(results, [1, 2, 3]); +}); + +void test("awaitAll resolves promises of different types", async () => { + const promises = [ + Promise.resolve("hello"), + Promise.resolve(42), + Promise.resolve(true) + ] as const; + + const results = await awaitAll(promises); + + // Type assertions to verify type inference + const str: string = results[0]; + const num: number = results[1]; + const bool: boolean = results[2]; + + assert.strictEqual(str, "hello"); + assert.strictEqual(num, 42); + assert.strictEqual(bool, true); +}); + +void test("awaitAll throws on first rejection", async () => { + const error = new Error("Test error"); + const promises = [ + Promise.resolve(1), + Promise.reject(error), + Promise.resolve(3) + ]; + + await assert.rejects(async () => { + await awaitAll(promises); + }, error); +}); + +void test("awaitAll works with async functions", async () => { + const asyncString = async (): Promise => "async"; + const asyncNumber = async (): Promise => 123; + + const results = await awaitAll([asyncString(), asyncNumber()]); + + assert.strictEqual(results[0], "async"); + assert.strictEqual(results[1], 123); +}); diff --git a/frontend/sync-client/src/utils/await-all.ts b/frontend/sync-client/src/utils/await-all.ts new file mode 100644 index 00000000..07e3859f --- /dev/null +++ b/frontend/sync-client/src/utils/await-all.ts @@ -0,0 +1,22 @@ +type PromiseTuple = readonly [ + ...{ [K in keyof T]: Promise } +]; + +type ResolvedTuple = { + [K in keyof T]: T[K]; +}; + +export const awaitAll = async ( + promises: PromiseTuple +): Promise> => { + const result = await Promise.allSettled(promises); + for (const res of result) { + if (res.status === "rejected") { + throw res.reason; + } + } + + return result.map( + (res) => (res as PromiseFulfilledResult).value + ) as ResolvedTuple; +}; diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index 4e510943..eda89800 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -1,4 +1,5 @@ import type { Logger } from "../../tracing/logger"; +import { awaitAll } from "../await-all"; /** * Manages exclusive locks on items to prevent concurrent modifications. @@ -54,9 +55,7 @@ export class Locks { const uniqueKeys = Array.from(new Set(keys)); uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks - await Promise.allSettled( - uniqueKeys.map(async (key) => this.waitForLock(key)) - ); + await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key))); try { return await fn(); diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index 578dab0a..4a3aab4f 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -53,13 +53,11 @@ async function runTest({ } try { - await Promise.allSettled(clients.map(async (client) => client.init())); + await Promise.all(clients.map(async (client) => client.init())); for (let i = 0; i < iterations; i++) { console.info(`Iteration ${i + 1}/${iterations}`); - await Promise.allSettled( - clients.map(async (client) => client.act()) - ); + await Promise.all(clients.map(async (client) => client.act())); await sleep(100); }