From ddbf39b4a4964be293949b7964fae7687e2e4911 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Tue, 28 Apr 2026 21:07:51 +0200 Subject: [PATCH 1/9] refactor(sync): split sync_state into sync_chain and sync_note_transport (3-way merged with CONFLICTS) Migrated from 0xMiden/miden-client#2091 (author: JereSalo) as part of the web-sdk split. Original PR: https://github.com/0xMiden/miden-client/pull/2091 The patch contains 3-way merge conflicts; resolution needed before merge. --- crates/web-client/js/client.js | 29 +- crates/web-client/js/constants.js | 2 + crates/web-client/js/index.js | 164 +++++----- crates/web-client/js/node/napi-compat.js | 10 +- .../web-client/js/resources/transactions.js | 2 +- crates/web-client/js/syncLock.js | 145 ++++----- crates/web-client/js/types/api-types.d.ts | 8 +- crates/web-client/js/types/index.d.ts | 3 +- .../js/workers/web-client-methods-worker.js | 8 + .../scripts/check-method-classification.js | 4 +- crates/web-client/src/sync.rs | 28 +- crates/web-client/test/node-adapter.ts | 4 - crates/web-client/test/sync_lock.test.ts | 292 ------------------ crates/web-client/test/test-helpers.ts | 1 - 14 files changed, 215 insertions(+), 485 deletions(-) diff --git a/crates/web-client/js/client.js b/crates/web-client/js/client.js index e0cae112..60be295e 100644 --- a/crates/web-client/js/client.js +++ b/crates/web-client/js/client.js @@ -177,15 +177,34 @@ export class MidenClient { } /** - * Syncs the client state with the Miden node. + * Syncs the client: fetches private notes from the Note Transport Layer, then syncs on-chain + * state with the Miden node. Fails fast on either. * - * @param {object} [opts] - Sync options. - * @param {number} [opts.timeout] - Timeout in milliseconds (0 = no timeout). * @returns {Promise} The sync summary. */ - async sync(opts) { + async sync() { this.assertNotTerminated(); - return await this.#inner.syncStateWithTimeout(opts?.timeout ?? 0); + return await this.#inner.syncState(); + } + + /** + * Syncs on-chain state only (no NTL fetch). + * + * @returns {Promise} + */ + async syncChain() { + this.assertNotTerminated(); + return await this.#inner.syncChain(); + } + + /** + * Fetches private notes from the Note Transport Layer. + * + * @returns {Promise} + */ + async syncNoteTransport() { + this.assertNotTerminated(); + return await this.#inner.syncNoteTransport(); } /** diff --git a/crates/web-client/js/constants.js b/crates/web-client/js/constants.js index 43154954..63680461 100644 --- a/crates/web-client/js/constants.js +++ b/crates/web-client/js/constants.js @@ -22,4 +22,6 @@ export const MethodName = Object.freeze({ SUBMIT_NEW_TRANSACTION_WITH_PROVER_MOCK: "submitNewTransactionWithProverMock", SYNC_STATE: "syncState", SYNC_STATE_MOCK: "syncStateMock", + SYNC_CHAIN: "syncChain", + SYNC_NOTE_TRANSPORT: "syncNoteTransport", }); diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index 87743331..e3bdf1d2 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -1,10 +1,6 @@ import loadWasm from "./wasm.js"; import { CallbackType, MethodName, WorkerAction } from "./constants.js"; -import { - acquireSyncLock, - releaseSyncLock, - releaseSyncLockWithError, -} from "./syncLock.js"; +import { withSyncLock } from "./syncLock.js"; import { MidenClient } from "./client.js"; import { CompilerResource } from "./resources/compiler.js"; import { @@ -841,7 +837,7 @@ class WebClient { } /** - * Syncs the client state with the node. + * Syncs the client (NTL followed by chain sync, failing fast on either). * * This method coordinates concurrent sync calls using the Web Locks API when available, * with an in-process mutex fallback for older browsers. If a sync is already in progress, @@ -850,58 +846,76 @@ class WebClient { * @returns {Promise} The sync summary */ async syncState() { - return this.syncStateWithTimeout(0); + const dbId = this.storeName || "default"; + const methodId = MethodName.SYNC_STATE; + + try { + return await withSyncLock(dbId, methodId, async () => { + if (!this.worker) { + const wasmWebClient = await this.getWasmWebClient(); + return await wasmWebClient.syncStateImpl(); + } + const wasm = await getWasmOrThrow(); + const serializedSyncSummaryBytes = + await this.callMethodWithWorker(methodId); + return wasm.SyncSummary.deserialize( + new Uint8Array(serializedSyncSummaryBytes) + ); + }); + } catch (error) { + console.error("INDEX.JS: Error in syncState:", error); + throw error; + } } /** - * Syncs the client state with the node with an optional timeout. + * Fetches private notes from the Note Transport Layer. * - * This method coordinates concurrent sync calls using the Web Locks API when available, - * with an in-process mutex fallback for older browsers. If a sync is already in progress, - * subsequent callers will wait and receive the same result (coalescing behavior). - * - * @param {number} timeoutMs - Timeout in milliseconds (0 = no timeout) - * @returns {Promise} The sync summary + * @returns {Promise} */ - async syncStateWithTimeout(timeoutMs = 0) { - // Use storeName as the database ID for lock coordination + async syncNoteTransport() { const dbId = this.storeName || "default"; + const methodId = MethodName.SYNC_NOTE_TRANSPORT; try { - // Acquire the sync lock (coordinates concurrent calls) - const lockHandle = await acquireSyncLock(dbId, timeoutMs); - - if (!lockHandle.acquired) { - // We're coalescing - return the result from the in-progress sync - return lockHandle.coalescedResult; - } - - // We acquired the lock - perform the sync - try { - let result; + await withSyncLock(dbId, methodId, async () => { if (!this.worker) { const wasmWebClient = await this.getWasmWebClient(); - result = await wasmWebClient.syncStateImpl(); + await wasmWebClient.syncNoteTransportImpl(); } else { - const wasm = await getWasmOrThrow(); - const serializedSyncSummaryBytes = await this.callMethodWithWorker( - MethodName.SYNC_STATE - ); - result = wasm.SyncSummary.deserialize( - new Uint8Array(serializedSyncSummaryBytes) - ); + await this.callMethodWithWorker(methodId); } + }); + } catch (error) { + console.error("INDEX.JS: Error in syncNoteTransport:", error); + throw error; + } + } - // Release the lock with the result - releaseSyncLock(dbId, result); - return result; - } catch (error) { - // Release the lock with the error - releaseSyncLockWithError(dbId, error); - throw error; - } + /** + * Syncs on-chain state only (no NTL fetch). + * + * @returns {Promise} + */ + async syncChain() { + const dbId = this.storeName || "default"; + const methodId = MethodName.SYNC_CHAIN; + + try { + return await withSyncLock(dbId, methodId, async () => { + if (!this.worker) { + const wasmWebClient = await this.getWasmWebClient(); + return await wasmWebClient.syncChainImpl(); + } + const wasm = await getWasmOrThrow(); + const serializedSyncSummaryBytes = + await this.callMethodWithWorker(methodId); + return wasm.SyncSummary.deserialize( + new Uint8Array(serializedSyncSummaryBytes) + ); + }); } catch (error) { - console.error("INDEX.JS: Error in syncState:", error); + console.error("INDEX.JS: Error in syncChain:", error); throw error; } } @@ -990,57 +1004,33 @@ class MockWebClient extends WebClient { * @returns {Promise} The sync summary */ async syncState() { - return this.syncStateWithTimeout(0); - } - - /** - * Syncs the mock client state with an optional timeout. - * - * @param {number} timeoutMs - Timeout in milliseconds (0 = no timeout) - * @returns {Promise} The sync summary - */ - async syncStateWithTimeout(timeoutMs = 0) { const dbId = this.storeName || "mock"; + const methodId = MethodName.SYNC_STATE; try { - const lockHandle = await acquireSyncLock(dbId, timeoutMs); - - if (!lockHandle.acquired) { - return lockHandle.coalescedResult; - } - - try { - let result; + return await withSyncLock(dbId, methodId, async () => { const wasmWebClient = await this.getWasmWebClient(); if (!this.worker) { - result = await wasmWebClient.syncStateImpl(); - } else { - let serializedMockChain = (await wasmWebClient.serializeMockChain()) - .buffer; - let serializedMockNoteTransportNode = ( - await wasmWebClient.serializeMockNoteTransportNode() - ).buffer; - - const wasm = await getWasmOrThrow(); - - const serializedSyncSummaryBytes = await this.callMethodWithWorker( - MethodName.SYNC_STATE_MOCK, - serializedMockChain, - serializedMockNoteTransportNode - ); - - result = wasm.SyncSummary.deserialize( - new Uint8Array(serializedSyncSummaryBytes) - ); + return await wasmWebClient.syncStateImpl(); } - releaseSyncLock(dbId, result); - return result; - } catch (error) { - releaseSyncLockWithError(dbId, error); - throw error; - } + const serializedMockChain = (await wasmWebClient.serializeMockChain()) + .buffer; + const serializedMockNoteTransportNode = ( + await wasmWebClient.serializeMockNoteTransportNode() + ).buffer; + + const wasm = await getWasmOrThrow(); + const serializedSyncSummaryBytes = await this.callMethodWithWorker( + MethodName.SYNC_STATE_MOCK, + serializedMockChain, + serializedMockNoteTransportNode + ); + return wasm.SyncSummary.deserialize( + new Uint8Array(serializedSyncSummaryBytes) + ); + }); } catch (error) { console.error("INDEX.JS: Error in syncState:", error); throw error; diff --git a/crates/web-client/js/node/napi-compat.js b/crates/web-client/js/node/napi-compat.js index 8c746619..35780a71 100644 --- a/crates/web-client/js/node/napi-compat.js +++ b/crates/web-client/js/node/napi-compat.js @@ -61,7 +61,8 @@ export function wrapClass(Cls) { * Wraps a raw napi WebClient to normalize API differences with the browser SDK. * * - syncState() -> syncStateImpl() (no browser lock coordination needed) - * - syncStateWithTimeout() -> syncStateImpl() (timeout not applicable) + * - syncChain() -> syncChainImpl() + * - syncNoteTransport() -> syncNoteTransportImpl() * - null -> undefined for Option returns * - BigInt/Uint8Array args normalized */ @@ -71,8 +72,11 @@ export function wrapClient(rawClient, storeName) { if (prop === "syncState") { return (...args) => target.syncStateImpl(...args); } - if (prop === "syncStateWithTimeout") { - return (_timeoutMs) => target.syncStateImpl(); + if (prop === "syncChain") { + return () => target.syncChainImpl(); + } + if (prop === "syncNoteTransport") { + return () => target.syncNoteTransportImpl(); } if (prop === "storeName") { return storeName || "default"; diff --git a/crates/web-client/js/resources/transactions.js b/crates/web-client/js/resources/transactions.js index 64009c58..f7bb9c33 100644 --- a/crates/web-client/js/resources/transactions.js +++ b/crates/web-client/js/resources/transactions.js @@ -353,7 +353,7 @@ export class TransactionsResource { } try { - await this.#inner.syncStateWithTimeout(0); + await this.#inner.syncState(); } catch { // Sync may fail transiently; continue polling } diff --git a/crates/web-client/js/syncLock.js b/crates/web-client/js/syncLock.js index de1a290f..391d8e5f 100644 --- a/crates/web-client/js/syncLock.js +++ b/crates/web-client/js/syncLock.js @@ -1,15 +1,15 @@ /** * Sync Lock Module * - * Provides coordination for concurrent syncState() calls using the Web Locks API - * with an in-process mutex fallback for older browsers. + * Coordinates concurrent sync calls using the Web Locks API. * * Behavior: - * - Uses "coalescing": if a sync is in progress, subsequent callers wait and receive - * the same result - * - Web Locks for cross-tab coordination (Chrome 69+, Safari 15.4+) - * - In-process mutex fallback when Web Locks unavailable - * - Optional timeout support + * - Same-method coalescing: if a sync of the same method is in progress, + * subsequent callers share its result promise + * - Different-method serialization: different methods (e.g. syncState vs + * syncNoteTransport) wait for each other via the Web Lock (or the + * WASM-level mutex when Web Locks are unavailable) + * - Web Locks also serialize across tabs (Chrome 69+, Safari 15.4+) */ /** @@ -23,44 +23,17 @@ export function hasWebLocks() { ); } -/** - * Internal state for tracking in-progress syncs and waiters per database. - */ -const syncStates = new Map(); - -/** - * Get or create sync state for a database. - */ -function getSyncState(dbId) { - let state = syncStates.get(dbId); - if (!state) { - state = { - inProgress: false, - result: null, - error: null, - waiters: [], - releaseLock: null, - syncGeneration: 0, - }; - syncStates.set(dbId, state); - } - return state; -} +// Coalesce map keyed by `${dbId}:${methodId}` -> in-flight promise. +const inFlight = new Map(); /** - * Acquire a sync lock for the given database. - * - * If a sync is already in progress: - * - Returns { acquired: false, coalescedResult } after waiting for the result - * - * If no sync is in progress: - * - Returns { acquired: true } and the caller should perform the sync, - * then call releaseSyncLock() or releaseSyncLockWithError() + * Build the coalesce-map key for an in-flight sync of `(dbId, methodId)`. * - * @param {string} dbId - The database ID to lock - * @param {number} timeoutMs - Optional timeout in milliseconds (0 = no timeout) - * @returns {Promise<{acquired: boolean, coalescedResult?: any}>} + * @param {string} dbId + * @param {string} methodId + * @returns {string} */ +<<<<<<< ours export async function acquireSyncLock(dbId, timeoutMs = 0) { const state = getSyncState(dbId); @@ -152,64 +125,62 @@ export async function acquireSyncLock(dbId, timeoutMs = 0) { // Fallback: no Web Locks, just use in-process state return { acquired: true }; } +======= +function coalesceKey(dbId, methodId) { + return `${dbId}:${methodId}`; +>>>>>>> theirs } /** - * Release the sync lock with a successful result. + * Run `fn` while holding the per-db Web Lock. When Web Locks are unavailable, + * runs `fn` directly and relies on the WASM-level mutex (`get_mut_inner`) to + * serialize across methods within the tab. * - * This notifies all waiting callers with the result and releases the lock. - * - * @param {string} dbId - The database ID - * @param {any} result - The sync result to pass to waiters + * @param {string} dbId + * @param {() => Promise} fn + * @returns {Promise} + * @template T */ -export function releaseSyncLock(dbId, result) { - const state = getSyncState(dbId); - - if (!state.inProgress) { - console.warn("releaseSyncLock called but no sync was in progress"); - return; - } - - state.result = result; - state.inProgress = false; - - for (const waiter of state.waiters) { - waiter.resolve(result); - } - state.waiters = []; - - if (state.releaseLock) { - state.releaseLock(); - state.releaseLock = null; +function runUnderLock(dbId, fn) { + if (!hasWebLocks()) { + // No Web Locks: rely on the WASM-level mutex (get_mut_inner) to serialize + // across methods within the tab. + return Promise.resolve().then(fn); } + return navigator.locks.request( + `miden-sync-${dbId}`, + { mode: "exclusive" }, + fn + ); } /** - * Release the sync lock due to an error. + * Run `fn` under the sync lock for (dbId, methodId). * - * This notifies all waiting callers that the sync failed. + * Concurrent calls with the same (dbId, methodId) share the same promise + * (coalescing). Concurrent calls on the same dbId with different methodIds + * serialize via the Web Lock. * - * @param {string} dbId - The database ID - * @param {Error} error - The error to pass to waiters + * @param {string} dbId - Database ID + * @param {string} methodId - Method identifier (see MethodName constants) + * @param {() => Promise} fn - Work to run under the lock + * @returns {Promise} */ -export function releaseSyncLockWithError(dbId, error) { - const state = getSyncState(dbId); - - if (!state.inProgress) { - console.warn("releaseSyncLockWithError called but no sync was in progress"); - return; - } - - state.error = error; - state.inProgress = false; - - for (const waiter of state.waiters) { - waiter.reject(error); +export function withSyncLock(dbId, methodId, fn) { + const key = coalesceKey(dbId, methodId); + + let work = inFlight.get(key); + if (!work) { + work = runUnderLock(dbId, fn); + inFlight.set(key, work); + // Swallow on the derived promise so a rejection here doesn't surface as + // an unhandled rejection; the caller still sees the error through `work`. + work + .finally(() => { + if (inFlight.get(key) === work) inFlight.delete(key); + }) + .catch(() => {}); } - state.waiters = []; - if (state.releaseLock) { - state.releaseLock(); - state.releaseLock = null; - } + return work; } diff --git a/crates/web-client/js/types/api-types.d.ts b/crates/web-client/js/types/api-types.d.ts index b9384e54..d43974c5 100644 --- a/crates/web-client/js/types/api-types.d.ts +++ b/crates/web-client/js/types/api-types.d.ts @@ -933,8 +933,12 @@ export declare class MidenClient { readonly compile: CompilerResource; readonly keystore: KeystoreResource; - /** Syncs the client state with the Miden node. */ - sync(options?: { timeout?: number }): Promise; + /** Syncs the client: fetches private notes from the Note Transport Layer, then syncs on-chain state. Fails fast on either. */ + sync(): Promise; + /** Syncs on-chain state only (no NTL fetch). */ + syncChain(): Promise; + /** Fetches private notes from the Note Transport Layer. */ + syncNoteTransport(): Promise; /** Returns the current sync height. */ getSyncHeight(): Promise; /** Returns the client-level default prover. */ diff --git a/crates/web-client/js/types/index.d.ts b/crates/web-client/js/types/index.d.ts index 20e971c6..9c4bd8b6 100644 --- a/crates/web-client/js/types/index.d.ts +++ b/crates/web-client/js/types/index.d.ts @@ -156,7 +156,8 @@ export declare class WasmWebClient extends WasmWebClientBase { ): Promise; syncState(): Promise; - syncStateWithTimeout(timeoutMs: number): Promise; + syncChain(): Promise; + syncNoteTransport(): Promise; setSignCb(signCb: SignCallback | null | undefined): void; onStateChanged(callback: (event: any) => void): (() => void) | undefined; terminate(): void; diff --git a/crates/web-client/js/workers/web-client-methods-worker.js b/crates/web-client/js/workers/web-client-methods-worker.js index 95a46672..ecb3e6a7 100644 --- a/crates/web-client/js/workers/web-client-methods-worker.js +++ b/crates/web-client/js/workers/web-client-methods-worker.js @@ -164,6 +164,14 @@ const methodHandlers = { const serializedSyncSummary = syncSummary.serialize(); return serializedSyncSummary.buffer; }, + [MethodName.SYNC_NOTE_TRANSPORT]: async () => { + await wasmWebClient.syncNoteTransportImpl(); + }, + [MethodName.SYNC_CHAIN]: async () => { + const syncSummary = await wasmWebClient.syncChainImpl(); + const serializedSyncSummary = syncSummary.serialize(); + return serializedSyncSummary.buffer; + }, [MethodName.APPLY_TRANSACTION]: async (args) => { const wasm = await getWasmOrThrow(); const [serializedTransactionResult, submissionHeight] = args; diff --git a/crates/web-client/scripts/check-method-classification.js b/crates/web-client/scripts/check-method-classification.js index ccc2fa7a..9f151f4b 100644 --- a/crates/web-client/scripts/check-method-classification.js +++ b/crates/web-client/scripts/check-method-classification.js @@ -183,8 +183,10 @@ const allowedUnclassified = new Set([ "createClient", "createClientWithExternalKeystore", "createMockClient", - // Internal impl method called directly by syncState wrappers + // Internal impl methods called directly by sync wrappers "syncStateImpl", + "syncChainImpl", + "syncNoteTransportImpl", ]); const unclassified = [...wasmMethods].filter( diff --git a/crates/web-client/src/sync.rs b/crates/web-client/src/sync.rs index a6dcb99a..3e7f5f44 100644 --- a/crates/web-client/src/sync.rs +++ b/crates/web-client/src/sync.rs @@ -43,7 +43,7 @@ impl WebClient { Ok(native_note_tag.into()) } - /// Internal implementation of `sync_state`. + /// Internal implementation of `sync_state` (combined NTL + chain sync). /// /// This method performs the actual sync operation. Concurrent call coordination /// is handled at the JavaScript layer using the Web Locks API. @@ -62,6 +62,32 @@ impl WebClient { Ok(sync_summary.into()) } + /// Internal implementation of `sync_chain` (on-chain-only sync). Use `syncChain()` from JS. + #[js_export(js_name = "syncChainImpl")] + pub async fn sync_chain_impl(&self) -> Result { + let mut guard = self.get_mut_inner().await; + let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + + let sync_summary = maybe_wrap_send(client.sync_chain()) + .await + .map_err(|err| js_error_with_context(err, "failed to sync chain"))?; + + Ok(sync_summary.into()) + } + + /// Internal implementation of `sync_note_transport`. Use `syncNoteTransport()` from JS. + #[js_export(js_name = "syncNoteTransportImpl")] + pub async fn sync_note_transport_impl(&self) -> Result<(), JsErr> { + let mut guard = self.get_mut_inner().await; + let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + + maybe_wrap_send(client.sync_note_transport()) + .await + .map_err(|err| js_error_with_context(err, "failed to sync note transport"))?; + + Ok(()) + } + #[js_export(js_name = "getSyncHeight")] pub async fn get_sync_height(&self) -> Result { let mut guard = self.get_mut_inner().await; diff --git a/crates/web-client/test/node-adapter.ts b/crates/web-client/test/node-adapter.ts index 64e9c9f4..ef60a839 100644 --- a/crates/web-client/test/node-adapter.ts +++ b/crates/web-client/test/node-adapter.ts @@ -194,10 +194,6 @@ function wrapClient(client: any, storeName?: string): any { if (prop === "syncState") { return (...args: any[]) => target.syncStateImpl(...args); } - // syncStateWithTimeout — just calls syncState (no browser lock coordination needed) - if (prop === "syncStateWithTimeout") { - return (_timeoutMs?: number) => target.syncStateImpl(); - } // storeName — used by MidenClient for lock coordination if (prop === "storeName") { return storeName || "default"; diff --git a/crates/web-client/test/sync_lock.test.ts b/crates/web-client/test/sync_lock.test.ts index 7a067b45..0007a1f2 100644 --- a/crates/web-client/test/sync_lock.test.ts +++ b/crates/web-client/test/sync_lock.test.ts @@ -88,70 +88,6 @@ test.describe("Sync Lock Tests", () => { }); }); - test.describe("Timeout Behavior", () => { - test("syncStateWithTimeout with 0 timeout works like syncState", async ({ - page, - }) => { - const result = await page.evaluate(async () => { - const client = window.client; - - const result1 = await client.syncState(); - const result2 = await client.syncStateWithTimeout(0); - - return { - blockNum1: result1.blockNum(), - blockNum2: result2.blockNum(), - }; - }); - - expect(typeof result.blockNum1).toBe("number"); - expect(typeof result.blockNum2).toBe("number"); - }); - - test("syncStateWithTimeout with positive timeout succeeds", async ({ - page, - }) => { - const result = await page.evaluate(async () => { - const client = window.client; - - // Use a generous timeout - const result = await client.syncStateWithTimeout(30000); - - return { - blockNum: result.blockNum(), - committedNotes: result.committedNotes().length, - consumedNotes: result.consumedNotes().length, - }; - }); - - expect(typeof result.blockNum).toBe("number"); - expect(result.blockNum).toBeGreaterThanOrEqual(0); - }); - - test("concurrent syncs with timeout all complete", async ({ page }) => { - const result = await page.evaluate(async () => { - const client = window.client; - - const syncPromises = [ - client.syncStateWithTimeout(30000), - client.syncStateWithTimeout(30000), - client.syncStateWithTimeout(30000), - ]; - - const results = await Promise.all(syncPromises); - const blockNums = results.map((r) => r.blockNum()); - - return { - blockNums, - allSame: blockNums.every((n) => n === blockNums[0]), - }; - }); - - expect(result.blockNums.length).toBe(3); - expect(result.allSame).toBe(true); - }); - }); - test.describe("Error Handling", () => { test("sync after failed sync works correctly", async ({ page }) => { // This test ensures that the lock is properly released after an error @@ -557,234 +493,6 @@ test.describe("Cross-Tab Sync Lock Tests", () => { }); }); -test.describe("Sync Lock Timeout Race Condition", () => { - test("new sync succeeds after previous sync times out", async ({ page }) => { - // This test verifies the fix for the race condition where: - // 1. Sync A starts and tries to acquire Web Lock - // 2. Sync A times out while waiting - // 3. Sync B starts (sees inProgress = false) - // 4. Sync A's Web Lock callback eventually runs but should not corrupt Sync B's state - const result = await page.evaluate(async () => { - const client = window.client; - - // First, do a successful sync to ensure everything is working - const initialResult = await client.syncState(); - const initialBlockNum = initialResult.blockNum(); - - // Now do multiple sequential syncs with timeouts to verify - // the lock state is properly cleaned up after each timeout/success - const results: number[] = []; - - for (let i = 0; i < 3; i++) { - try { - const result = await client.syncStateWithTimeout(30000); - results.push(result.blockNum()); - } catch (e) { - results.push(-1); // Mark failures - } - } - - return { - initialBlockNum, - results, - allSucceeded: results.every((n) => n >= 0), - }; - }); - - expect(result.initialBlockNum).toBeGreaterThanOrEqual(0); - expect(result.allSucceeded).toBe(true); - expect(result.results.length).toBe(3); - }); - - test("waiters are rejected when sync times out", async ({ page }) => { - // This test verifies that waiters (coalesced callers) are properly - // rejected when the sync they're waiting on times out - const result = await page.evaluate(async () => { - // Access the sync lock functions directly from the idxdb-store module - const { acquireSyncLock, releaseSyncLock, releaseSyncLockWithError } = - await import("@aspect-build/aspect-rsdoctor/index.js").catch(() => { - // Fallback: the functions may not be directly exported - // In this case, we test via the client API - return { - acquireSyncLock: null, - releaseSyncLock: null, - releaseSyncLockWithError: null, - }; - }); - - // If we can't access the low-level functions, test via client API - const client = window.client; - - // Start a sync that will hold the lock - const syncPromise1 = client.syncState(); - - // Immediately start more syncs that will be coalesced - const syncPromise2 = client.syncState(); - const syncPromise3 = client.syncState(); - - // Wait for all to complete - they should all succeed via coalescing - const [result1, result2, result3] = await Promise.all([ - syncPromise1, - syncPromise2, - syncPromise3, - ]); - - return { - allCompleted: true, - blockNum1: result1.blockNum(), - blockNum2: result2.blockNum(), - blockNum3: result3.blockNum(), - allSameBlock: - result1.blockNum() === result2.blockNum() && - result2.blockNum() === result3.blockNum(), - }; - }); - - expect(result.allCompleted).toBe(true); - expect(result.allSameBlock).toBe(true); - }); - - test("sync generation prevents stale callback interference", async ({ - page, - }) => { - // This test verifies that the syncGeneration counter properly - // prevents stale lock callbacks from interfering with newer syncs - const result = await page.evaluate(async () => { - const client = window.client; - - // Do many rapid sequential syncs - each should complete cleanly - // without interference from any stale state - const blockNums: number[] = []; - - for (let i = 0; i < 5; i++) { - const result = await client.syncState(); - blockNums.push(result.blockNum()); - } - - // Then do concurrent syncs - const concurrentResults = await Promise.all([ - client.syncState(), - client.syncState(), - client.syncState(), - ]); - - const concurrentBlockNums = concurrentResults.map((r) => r.blockNum()); - - return { - sequentialBlockNums: blockNums, - concurrentBlockNums, - allValid: - blockNums.every((n) => typeof n === "number" && n >= 0) && - concurrentBlockNums.every((n) => typeof n === "number" && n >= 0), - concurrentCoalesced: concurrentBlockNums.every( - (n) => n === concurrentBlockNums[0] - ), - }; - }); - - expect(result.allValid).toBe(true); - expect(result.concurrentCoalesced).toBe(true); - expect(result.sequentialBlockNums.length).toBe(5); - expect(result.concurrentBlockNums.length).toBe(3); - }); - - test("concurrent syncs with short timeout handle race correctly", async ({ - page, - }) => { - // Test that even with short timeouts, the sync lock handles - // concurrent access correctly without state corruption - const result = await page.evaluate(async () => { - const client = window.client; - const errors: string[] = []; - const successes: number[] = []; - - // Fire many concurrent syncs with various timeouts - const promises = [ - client.syncStateWithTimeout(50000), - client.syncStateWithTimeout(50000), - client.syncState(), - client.syncStateWithTimeout(50000), - client.syncState(), - ]; - - const results = await Promise.allSettled(promises); - - for (const result of results) { - if (result.status === "fulfilled") { - successes.push(result.value.blockNum()); - } else { - errors.push(result.reason?.message || "unknown error"); - } - } - - // After all the concurrent activity, verify we can still sync - const finalResult = await client.syncState(); - - return { - totalAttempts: promises.length, - successCount: successes.length, - errorCount: errors.length, - errors, - finalSyncBlockNum: finalResult.blockNum(), - finalSyncSucceeded: typeof finalResult.blockNum() === "number", - }; - }); - - // All syncs should succeed (they should coalesce) - expect(result.successCount).toBe(5); - expect(result.errorCount).toBe(0); - expect(result.finalSyncSucceeded).toBe(true); - expect(result.finalSyncBlockNum).toBeGreaterThanOrEqual(0); - }); - - test("state is clean after timeout followed by successful sync", async ({ - page, - }) => { - // Verify that after a sequence of operations including potential - // timeouts, the sync lock state remains consistent - const result = await page.evaluate(async () => { - const client = window.client; - - // Create an account to track state consistency - const wallet = await client.newWallet( - window.AccountStorageMode.private(), - true, - window.AuthScheme.AuthRpoFalcon512 - ); - const walletId = wallet.id().toString(); - - // Do several syncs with timeouts - for (let i = 0; i < 3; i++) { - await client.syncStateWithTimeout(30000); - } - - // Do concurrent syncs - await Promise.all([ - client.syncState(), - client.syncState(), - client.syncStateWithTimeout(30000), - ]); - - // Verify account state is still consistent - const accounts = await client.getAccounts(); - const accountIds = accounts.map((a) => a.id().toString()); - const syncHeight = await client.getSyncHeight(); - - return { - walletId, - walletFound: accountIds.includes(walletId), - accountCount: accounts.length, - syncHeight, - stateConsistent: syncHeight >= 0 && accountIds.includes(walletId), - }; - }); - - expect(result.walletFound).toBe(true); - expect(result.stateConsistent).toBe(true); - expect(result.syncHeight).toBeGreaterThanOrEqual(0); - }); -}); - test.describe("Sync Lock Performance", () => { test("coalesced syncs complete faster than sequential", async ({ page }) => { const result = await page.evaluate(async () => { diff --git a/crates/web-client/test/test-helpers.ts b/crates/web-client/test/test-helpers.ts index 6d13db61..7216715e 100644 --- a/crates/web-client/test/test-helpers.ts +++ b/crates/web-client/test/test-helpers.ts @@ -420,7 +420,6 @@ function wrapClientForMidenClient( get(target, prop) { if (prop === "syncState") return (...args: any[]) => target.syncStateImpl(...args); - if (prop === "syncStateWithTimeout") return () => target.syncStateImpl(); if (prop === "storeName") return storeName || "mock"; if (prop === "wasmWebClient") return target; if (prop === "proveBlock") return async () => target.proveBlock(); From 357d5fa75591dbe2f72c827587b672d16e1372db Mon Sep 17 00:00:00 2001 From: JereSalo Date: Wed, 29 Apr 2026 18:49:31 -0300 Subject: [PATCH 2/9] fix(sync): resolve syncLock.js merge conflict from migration The 3-way merge in the prior commit left <<<<<<< ours / ======= / >>>>>>> theirs markers around the legacy acquireSyncLock block. Drop the legacy implementation (the simpler withSyncLock callback design already supersedes it) and remove the markers so the file parses. --- crates/web-client/js/syncLock.js | 94 -------------------------------- 1 file changed, 94 deletions(-) diff --git a/crates/web-client/js/syncLock.js b/crates/web-client/js/syncLock.js index 391d8e5f..a248801d 100644 --- a/crates/web-client/js/syncLock.js +++ b/crates/web-client/js/syncLock.js @@ -33,102 +33,8 @@ const inFlight = new Map(); * @param {string} methodId * @returns {string} */ -<<<<<<< ours -export async function acquireSyncLock(dbId, timeoutMs = 0) { - const state = getSyncState(dbId); - - // If a sync is already in progress, wait for it to complete (coalescing) - if (state.inProgress) { - return new Promise((resolve, reject) => { - let timeoutId; - if (timeoutMs > 0) { - timeoutId = setTimeout(() => { - const idx = state.waiters.findIndex((w) => w.resolve === onResult); - if (idx !== -1) { - state.waiters.splice(idx, 1); - } - reject(new Error("Sync lock acquisition timed out")); - }, timeoutMs); - } - - const onResult = (result) => { - /* v8 ignore next 1 -- timeoutId only set when timeoutMs>0 AND another sync is in progress; combo rare in tests */ - if (timeoutId) clearTimeout(timeoutId); - resolve({ acquired: false, coalescedResult: result }); - }; - - const onError = (error) => { - if (timeoutId) clearTimeout(timeoutId); - reject(error); - }; - - state.waiters.push({ resolve: onResult, reject: onError }); - }); - } - - // Mark sync as in progress and increment generation - state.inProgress = true; - state.result = null; - state.error = null; - state.syncGeneration++; - const currentGeneration = state.syncGeneration; - - // Try to acquire Web Lock if available - if (hasWebLocks()) { - const lockName = `miden-sync-${dbId}`; - - return new Promise((resolve, reject) => { - let timeoutId; - let timedOut = false; - - if (timeoutMs > 0) { - timeoutId = setTimeout(() => { - timedOut = true; - if (state.syncGeneration === currentGeneration) { - state.inProgress = false; - const error = new Error("Sync lock acquisition timed out"); - for (const waiter of state.waiters) { - waiter.reject(error); - } - state.waiters = []; - } - reject(new Error("Sync lock acquisition timed out")); - }, timeoutMs); - } - - navigator.locks - .request(lockName, { mode: "exclusive" }, async () => { - /* v8 ignore next 3 -- race: lock granted after timeout or newer generation */ - if (timedOut || state.syncGeneration !== currentGeneration) { - return; - } - - if (timeoutId) clearTimeout(timeoutId); - - return new Promise((releaseLock) => { - state.releaseLock = releaseLock; - resolve({ acquired: true }); - }); - }) - .catch((err) => { - /* v8 ignore next 5 -- catch path requires Web Locks rejection combined with - optional timeout; tested via "rejects when Web Locks request rejects" but - the timeoutId-set branch needs Web Locks + timeout simultaneously */ - if (timeoutId) clearTimeout(timeoutId); - if (state.syncGeneration === currentGeneration) { - state.inProgress = false; - } - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); - } else { - // Fallback: no Web Locks, just use in-process state - return { acquired: true }; - } -======= function coalesceKey(dbId, methodId) { return `${dbId}:${methodId}`; ->>>>>>> theirs } /** From 313c870a1f43b29729b53b83eb801bbbb7fb06ba Mon Sep 17 00:00:00 2001 From: JereSalo Date: Wed, 29 Apr 2026 18:49:41 -0300 Subject: [PATCH 3/9] test(sync): verify withSyncLock clears in-flight after fn rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous "sync after failed sync works correctly" test, which only ran two successful syncs back-to-back and never actually exercised post-failure recovery. The new test calls withSyncLock directly with a rejecting fn, then a second time with the same key, and asserts the second fn actually executes — proving the in-flight entry was cleared rather than coalesced onto the rejected promise. Exposes withSyncLock from the SDK index alongside the existing internal exports used by integration tests. --- crates/web-client/js/index.js | 1 + crates/web-client/test/sync_lock.test.ts | 56 +++++++++++++++++------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index e3bdf1d2..2e008334 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -62,6 +62,7 @@ export { WebClient as WasmWebClient, MockWebClient as MockWasmWebClient, MockWebClient, + withSyncLock, }; // Method classification sets — used by scripts/check-method-classification.js to ensure diff --git a/crates/web-client/test/sync_lock.test.ts b/crates/web-client/test/sync_lock.test.ts index 0007a1f2..4c34cc68 100644 --- a/crates/web-client/test/sync_lock.test.ts +++ b/crates/web-client/test/sync_lock.test.ts @@ -89,27 +89,53 @@ test.describe("Sync Lock Tests", () => { }); test.describe("Error Handling", () => { - test("sync after failed sync works correctly", async ({ page }) => { - // This test ensures that the lock is properly released after an error + test("withSyncLock cleans up inFlight after fn rejects", async ({ + page, + }) => { + // After fn rejects, the in-flight entry must be cleared so that a later + // call with the same (dbId, methodId) starts a fresh execution. Without + // this, a single failure would permanently coalesce all subsequent + // callers onto the rejected promise and no further sync could run. const result = await page.evaluate(async () => { - const client = window.client; - - // First successful sync - const result1 = await client.syncState(); - - // Another successful sync (verifies lock was released) - const result2 = await client.syncState(); + const dbId = "withSyncLock-cleanup-test"; + const methodId = "syncTest"; + + let firstRan = 0; + let secondRan = 0; + + let firstError: Error | null = null; + try { + await window.withSyncLock(dbId, methodId, async () => { + firstRan++; + throw new Error("forced failure"); + }); + } catch (err) { + firstError = err as Error; + } + + const secondResult = await window.withSyncLock( + dbId, + methodId, + async () => { + secondRan++; + return "second-success"; + } + ); return { - blockNum1: result1.blockNum(), - blockNum2: result2.blockNum(), + firstErrorMessage: firstError?.message, + firstRan, + secondRan, + secondResult, }; }); - expect(typeof result.blockNum1).toBe("number"); - expect(typeof result.blockNum2).toBe("number"); - // Block numbers should be monotonically non-decreasing - expect(result.blockNum2).toBeGreaterThanOrEqual(result.blockNum1); + expect(result.firstErrorMessage).toBe("forced failure"); + expect(result.firstRan).toBe(1); + // The second fn must actually execute — proves the in-flight entry was + // cleared after the first rejection rather than coalescing onto it. + expect(result.secondRan).toBe(1); + expect(result.secondResult).toBe("second-success"); }); }); From 5162e158193749260d1c8bd0429488524251fb51 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Thu, 30 Apr 2026 15:48:03 +0200 Subject: [PATCH 4/9] ci: retarget miden-client dep at the syncstate-pre-2100-rebase snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct retarget at miden-client#2091's tip (jere/syncstate-ntl-decouple-1930) fails with 9 build errors in idxdb-store: jere's branch was rebased onto a point of next that includes #2100 (peaks-table removal), and web-sdk's idxdb-store still implements the pre-#2100 Store API (get_partial_blockchain_peaks_by_block_num, StateSyncUpdate.block_updates, etc.). Created a sibling branch (wiktor/syncstate-pre-2100-rebase, 48a79f208 on miden-client) by taking jere's pre-merge tip (e212878cb — the parent of its merge with current next) and merging miden-client@dab6cf7b into it. That snapshot: - Carries jere's sync_chain / sync_note_transport additions on Client (the API this PR's web-sdk-side code calls). - Has the dab6cf7b-era Store trait that web-sdk's idxdb-store implements verbatim (untrack_and_prune_irrelevant_blocks, peaks intact, StateSyncUpdate fields unchanged). cargo check --workspace --target wasm32-unknown-unknown is clean against this dep snapshot. Before merging this PR: 1. Land + release miden-client#2091 (and either rebase the migration onto current next or land it alongside a web-sdk idxdb-store migration to the post-#2100 Store API), 2. Revert these two lines back to `branch = "next"`. --- Cargo.lock | 77 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 22 +++++++++++----- 2 files changed, 54 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97effaef..627e2ddf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1655,9 +1655,9 @@ dependencies = [ [[package]] name = "miden-air" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15646ebc95906b2a7cb66711d1e184f53fd6edc2605730bbcf0c2a129f792cf" +checksum = "b45551e1417cb2be47064c36fe6e1e69ab10ad7b4b55f0731d8cac109b7738b9" dependencies = [ "miden-core", "miden-crypto", @@ -1668,9 +1668,9 @@ dependencies = [ [[package]] name = "miden-assembly" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6013b3a390e0dcb29242f4480a7727965887bbf0903466c88f362b4cb20c0e" +checksum = "1d2094e2b943f7bf955a2bc3b44b0ad7c4f45a286f170eaa7e5060871c44847a" dependencies = [ "env_logger", "log", @@ -1685,9 +1685,9 @@ dependencies = [ [[package]] name = "miden-assembly-syntax" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "996156b8f7c5fe6be17dea71089c6d7985c2dec1e3a4fec068b1dfc690e25df5" +checksum = "b5a3212614ad28399612f39024c1e321dc8cebc8998def06058e60462ddc3856" dependencies = [ "aho-corasick", "env_logger", @@ -1721,7 +1721,7 @@ dependencies = [ [[package]] name = "miden-client" version = "0.15.0" -source = "git+https://github.com/0xMiden/miden-client.git?branch=jere%2Fsyncstate-ntl-decouple-1930#f0d1b94c796dff7b75c55d0647f895aa25dd28ea" +source = "git+https://github.com/0xMiden/miden-client.git?branch=wiktor%2Fsyncstate-pre-2100-rebase#48a79f208a5de1ef3323d80ca1c7fb3844ccf5af" dependencies = [ "anyhow", "async-trait", @@ -1753,12 +1753,13 @@ dependencies = [ "tonic-web-wasm-client", "tracing", "uuid", + "web-sys", ] [[package]] name = "miden-client-sqlite-store" version = "0.15.0" -source = "git+https://github.com/0xMiden/miden-client.git?branch=jere%2Fsyncstate-ntl-decouple-1930#f0d1b94c796dff7b75c55d0647f895aa25dd28ea" +source = "git+https://github.com/0xMiden/miden-client.git?branch=wiktor%2Fsyncstate-pre-2100-rebase#48a79f208a5de1ef3323d80ca1c7fb3844ccf5af" dependencies = [ "anyhow", "async-trait", @@ -1805,9 +1806,9 @@ dependencies = [ [[package]] name = "miden-core" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdec54a321cdf3d23e9ef615e91cb858038c6b4d4202507bdec048fc6d7763e4" +checksum = "39a4a2e2de49213ec899e88fe399d4ec568c8eb9e8c747d6ed58938c40031daa" dependencies = [ "derive_more", "itertools", @@ -1827,9 +1828,9 @@ dependencies = [ [[package]] name = "miden-core-lib" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "621e8fa911a790bcf3cd3aedce80bc10922a19d6181f08ff3ca078f955cff70b" +checksum = "2d2ea7e17c4382255c6e0cb1e4b90693449dcf5a286a844e2918af66b371c0ab" dependencies = [ "env_logger", "fs-err", @@ -1897,9 +1898,9 @@ dependencies = [ [[package]] name = "miden-debug-types" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6e50274d11c80b901cf6c90362de8c98c8c8ad6030c80624d683b63d899a0fb" +checksum = "16570786d938b7f795921b3a84890708a7d72708442c622eb58c2fb5480821e9" dependencies = [ "memchr", "miden-crypto", @@ -1958,9 +1959,9 @@ dependencies = [ [[package]] name = "miden-mast-package" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8b2e3447fcde1f0e6b76e5219f129517639772cb02ca543177f0584e315288" +checksum = "0953396dc5e575b79bccb8b7da6e0d18ce71bcde899901bb4293a433f9003b94" dependencies = [ "derive_more", "miden-assembly-syntax", @@ -2009,7 +2010,7 @@ dependencies = [ [[package]] name = "miden-node-proto-build" version = "0.15.0" -source = "git+https://github.com/0xMiden/node.git?branch=next#28fbaa108549b696a238db339bb5e33c7c71fb7a" +source = "git+https://github.com/0xMiden/node.git?branch=next#f5b0c2c9b7162ed0184252430dad129e4d7fa30c" dependencies = [ "build-rs", "codegen", @@ -2033,9 +2034,9 @@ dependencies = [ [[package]] name = "miden-package-registry" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969ba3942052e52b3968e34dbd1c52c707e75777ee42ebdae2c8f57af56cf6cf" +checksum = "e07af92dc184a71132a34d89ad15e69633435bfd36fb5af4ce18b200bd1952e5" dependencies = [ "miden-assembly-syntax", "miden-core", @@ -2048,9 +2049,9 @@ dependencies = [ [[package]] name = "miden-processor" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ec6cecbf22bd92b73a931ee80b424e46b8b7cdf4f2f3c364c25c5c15d2840da" +checksum = "340c424f9f62b56a808c9a479cef016f25478e227555ce39cb2684e8baf26542" dependencies = [ "itertools", "miden-air", @@ -2067,9 +2068,9 @@ dependencies = [ [[package]] name = "miden-project" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3840520c01881534fbbceb6b3687ec1c407fbaf310a35ce415fd3510abc52fdb" +checksum = "541619ccdf566c2fac0d24bfc3806bc36e1d57a698a937621f1874ceb36a55d4" dependencies = [ "miden-assembly-syntax", "miden-core", @@ -2124,9 +2125,9 @@ dependencies = [ [[package]] name = "miden-prover" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb2c94e36f57684d7fa0cd382adeedc1728d502dbbe69ad1c12f4a931f45511" +checksum = "fadcff2d171f81f2737a35a007c756753a8298d067555c7a556bd72f570b32f9" dependencies = [ "bincode", "miden-air", @@ -2143,7 +2144,7 @@ dependencies = [ [[package]] name = "miden-remote-prover-client" version = "0.15.0" -source = "git+https://github.com/0xMiden/node.git?branch=next#28fbaa108549b696a238db339bb5e33c7c71fb7a" +source = "git+https://github.com/0xMiden/node.git?branch=next#f5b0c2c9b7162ed0184252430dad129e4d7fa30c" dependencies = [ "build-rs", "fs-err", @@ -2238,9 +2239,9 @@ dependencies = [ [[package]] name = "miden-utils-core-derive" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3846c8674ccec0c37005f99c1a599a24790ba2a5e5f4e1c7aec5f456821df835" +checksum = "cdd5103e9b6527ad396dce12c135cea1984dfd77ebbffa76f260f4e139906cc4" dependencies = [ "proc-macro2", "quote", @@ -2249,9 +2250,9 @@ dependencies = [ [[package]] name = "miden-utils-diagnostics" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397f5d1e8679cf17cf7713ffd9654840791a6ed5818b025bbc2fbfdce846579a" +checksum = "72226906c968c2e7c37435d67be9e29aeba05336db30c4e57d290cc6efb1da9d" dependencies = [ "miden-crypto", "miden-debug-types", @@ -2262,9 +2263,9 @@ dependencies = [ [[package]] name = "miden-utils-indexing" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8834e76299686bcce3de1685158aa4cff49b7fa5e0e00a6cc811e8f2cf5775f" +checksum = "5cc2e62161113179a370ae0bf1fd33eb8d20b6131e8559d2dc0bead5cffae586" dependencies = [ "miden-crypto", "serde", @@ -2273,9 +2274,9 @@ dependencies = [ [[package]] name = "miden-utils-sync" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9e9747e9664c1a0997bb040ae291306ea0a1c74a572141ec66cec855c1b0e8" +checksum = "4e9210b3592b577843710daf68293087c68b53d8482c82f6875ad83d578cb51e" dependencies = [ "lock_api", "loom", @@ -2285,9 +2286,9 @@ dependencies = [ [[package]] name = "miden-verifier" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4580df640d889c9f3c349cd2268968e44a99a8cf0df6c36ae5b1fb273712b00" +checksum = "83f47bf33268ffb31c2fc452debf8e4ba76fbb3175566efbfe850c4886fb5b37" dependencies = [ "bincode", "miden-air", @@ -3547,9 +3548,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 225a70ec..8d85c89d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,14 +40,22 @@ idxdb-store = { default-features = false, package = "miden-idxdb-store", path = # The 0.15.x API isn't on crates.io yet, so we track miden-client via # git+branch=next here; once miden-client publishes 0.15.0 we can switch # back to versioned crates.io deps. -# TEMPORARY: pinned to upstream miden-client PR branch -# (https://github.com/0xMiden/miden-client/pull/2091) which adds the -# `sync_chain()` / `sync_note_transport()` methods on `Client`. Before -# merging this PR: -# 1. Land + release miden-client#2091, +# TEMPORARY: pinned to a custom branch that snapshots +# miden-client#2091 BEFORE its merge with the post-#2100 next (which +# removes peaks from the Store trait). Web-sdk's idxdb-store still +# implements the pre-#2100 Store API, so pointing directly at jere's +# branch tip fails to compile (8 errors in idxdb-store: missing +# get_current_blockchain_peaks, partial_blockchain_updates field, etc.). +# This snapshot branch (`wiktor/syncstate-pre-2100-rebase` at e212878cb, +# the parent of jere's merge with next) carries sync_chain/ +# sync_note_transport on top of the OLD Store trait, so web-sdk +# compiles. Before merging this PR: +# 1. Land + release miden-client#2091 (and migrate web-sdk's idxdb-store +# to the post-#2100 Store API, OR have miden-client#2091 land +# ALONGSIDE that migration as one rollup), # 2. Revert these two lines back to `branch = "next"`. -miden-client = { branch = "jere/syncstate-ntl-decouple-1930", default-features = false, git = "https://github.com/0xMiden/miden-client.git" } -miden-client-sqlite-store = { branch = "jere/syncstate-ntl-decouple-1930", default-features = false, git = "https://github.com/0xMiden/miden-client.git" } +miden-client = { branch = "wiktor/syncstate-pre-2100-rebase", default-features = false, git = "https://github.com/0xMiden/miden-client.git" } +miden-client-sqlite-store = { branch = "wiktor/syncstate-pre-2100-rebase", default-features = false, git = "https://github.com/0xMiden/miden-client.git" } # Miden protocol dependencies miden-protocol = { default-features = false, version = "0.14" } From 40d95a611ed0d932e9a2a2732ba3e9cba73276de Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Thu, 30 Apr 2026 15:54:03 +0200 Subject: [PATCH 5/9] test: align syncLock + transactions unit tests with the migrated APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration of miden-client#2091 rewrote js/syncLock.js to expose a single `withSyncLock(dbId, methodId, fn)` instead of the prior acquireSyncLock / releaseSyncLock / releaseSyncLockWithError shape, and removed the timeout-aware `syncStateWithTimeout` WASM method in favor of plain `syncState` (the timeout was rejecting the caller's promise without cancelling the underlying sync — see PR description). The js/__tests__/ unit tests were not updated alongside, so vitest dropped to 298 → 276 passes (15 syncLock.test.js + 7 transactions.test.js failed) and the run reported coverage gate red. - js/__tests__/syncLock.test.js: replaced 15 acquire/release-shaped tests with 12 tests against withSyncLock that cover the same behaviors (in-process fallback resolution, fn rejection propagation, same-key coalescing, error coalescing, in-flight slot cleanup on resolve and on reject, distinct-key non-coalescing, multi-waiter shared result) plus the existing 4 hasWebLocks tests. Adds a separate describe for the Web-Locks branch with a mocked navigator.locks.request, asserting the lock name shape (`miden-sync-`) and that fn rejection propagates through the lock. Web Locks semantics across tabs are still covered E2E by crates/web-client/test/sync_lock.test.ts under Playwright. - js/__tests__/resources/transactions.test.js: rename every `inner.syncStateWithTimeout` reference (mock setup + spy assertions) to `inner.syncState`. The new TransactionsResource waitFor() uses syncState() in its polling loop; the assertions that "waitForConfirmation triggers a sync" still hold, just on the renamed method. vitest now runs 298/298 with 99.71% coverage. --- .../__tests__/resources/transactions.test.js | 22 +- .../web-client/js/__tests__/syncLock.test.js | 360 +++++++----------- 2 files changed, 156 insertions(+), 226 deletions(-) diff --git a/crates/web-client/js/__tests__/resources/transactions.test.js b/crates/web-client/js/__tests__/resources/transactions.test.js index 125dfa3e..940b033a 100644 --- a/crates/web-client/js/__tests__/resources/transactions.test.js +++ b/crates/web-client/js/__tests__/resources/transactions.test.js @@ -80,7 +80,7 @@ function makeInner(overrides = {}) { getConsumableNotes: vi.fn().mockResolvedValue([]), executeForSummary: vi.fn().mockResolvedValue("summary"), executeProgram: vi.fn().mockResolvedValue("programResult"), - syncStateWithTimeout: vi.fn().mockResolvedValue(undefined), + syncState: vi.fn().mockResolvedValue(undefined), _txResult: txResult, ...overrides, }; @@ -148,7 +148,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 30000, }); - expect(inner.syncStateWithTimeout).toHaveBeenCalled(); + expect(inner.syncState).toHaveBeenCalled(); expect(result.txId).toBeDefined(); }); @@ -837,7 +837,7 @@ describe("TransactionsResource", () => { it("throws timeout when transaction takes too long", async () => { const { resource } = makeResource({ getTransactions: vi.fn().mockResolvedValue([]), - syncStateWithTimeout: vi.fn().mockResolvedValue(undefined), + syncState: vi.fn().mockResolvedValue(undefined), }); await expect( resource.waitFor("0xtxHex", { timeout: 1, interval: 0 }) @@ -879,14 +879,14 @@ describe("TransactionsResource", () => { expect(txIdObj.toHex).toHaveBeenCalled(); }); - it("continues polling when syncStateWithTimeout throws", async () => { + it("continues polling when syncState throws", async () => { const committedStatus = { isCommitted: () => true, isDiscarded: () => false, }; let syncCount = 0; const { resource } = makeResource({ - syncStateWithTimeout: vi.fn().mockImplementation(() => { + syncState: vi.fn().mockImplementation(() => { if (syncCount++ === 0) throw new Error("sync fail"); return Promise.resolve(); }), @@ -942,7 +942,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncStateWithTimeout).toHaveBeenCalled(); + expect(inner.syncState).toHaveBeenCalled(); expect(result.note).toBe("p2idNote"); }); }); @@ -965,7 +965,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncStateWithTimeout).toHaveBeenCalled(); + expect(inner.syncState).toHaveBeenCalled(); expect(result.txId).toBeDefined(); }); }); @@ -986,7 +986,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncStateWithTimeout).toHaveBeenCalled(); + expect(inner.syncState).toHaveBeenCalled(); expect(result.txId).toBeDefined(); }); }); @@ -1012,7 +1012,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncStateWithTimeout).toHaveBeenCalled(); + expect(inner.syncState).toHaveBeenCalled(); expect(result.consumed).toBe(1); }); }); @@ -1035,7 +1035,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncStateWithTimeout).toHaveBeenCalled(); + expect(inner.syncState).toHaveBeenCalled(); }); }); @@ -1055,7 +1055,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncStateWithTimeout).toHaveBeenCalled(); + expect(inner.syncState).toHaveBeenCalled(); expect(result.txId).toBeDefined(); }); }); diff --git a/crates/web-client/js/__tests__/syncLock.test.js b/crates/web-client/js/__tests__/syncLock.test.js index 65796812..39c203ac 100644 --- a/crates/web-client/js/__tests__/syncLock.test.js +++ b/crates/web-client/js/__tests__/syncLock.test.js @@ -1,10 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - hasWebLocks, - acquireSyncLock, - releaseSyncLock, - releaseSyncLockWithError, -} from "../syncLock.js"; +import { hasWebLocks, withSyncLock } from "../syncLock.js"; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -18,7 +13,6 @@ function uniqueDb() { describe("hasWebLocks", () => { it("returns false when navigator is undefined", () => { const orig = globalThis.navigator; - // Can't delete navigator in strict mode; use defineProperty Object.defineProperty(globalThis, "navigator", { configurable: true, value: undefined, @@ -69,7 +63,7 @@ describe("hasWebLocks", () => { const orig = globalThis.navigator; Object.defineProperty(globalThis, "navigator", { configurable: true, - value: { locks: { request: vi.fn() } }, + value: { locks: { request: () => {} } }, }); try { expect(hasWebLocks()).toBe(true); @@ -82,114 +76,148 @@ describe("hasWebLocks", () => { }); }); -// ── acquireSyncLock / releaseSyncLock (no Web Locks) ───────────────────────── +// ── withSyncLock — Web-Locks-unavailable path ───────────────────────────────── +// +// In the node-test env, `navigator` is undefined, so `hasWebLocks()` returns +// false and `withSyncLock` runs `fn` directly (relying on the WASM-level +// mutex to serialize across methods within the tab). These tests cover that +// branch; the Web-Locks branch is exercised by the Playwright integration +// suite under `crates/web-client/test/sync_lock.test.ts`. -// In a node environment, navigator.locks is unavailable, so we test the -// in-process fallback path throughout this suite. +describe("withSyncLock — in-process fallback (no Web Locks)", () => { + beforeEach(() => { + // Sanity check: vitest runs in node, navigator is absent or stripped of + // navigator.locks. If a future config change breaks this assumption, + // these tests need to mock navigator.locks to remain in-process. + expect(hasWebLocks()).toBe(false); + }); -describe("acquireSyncLock — in-process fallback (no Web Locks)", () => { - it("acquires immediately when no sync in progress", async () => { + it("runs fn and resolves with its result", async () => { const dbId = uniqueDb(); - const result = await acquireSyncLock(dbId); - expect(result.acquired).toBe(true); - releaseSyncLock(dbId, "done"); // cleanup + const result = await withSyncLock(dbId, "syncState", async () => "ok"); + expect(result).toBe("ok"); }); - it("coalesces: waiter receives the same result as the releaser", async () => { + it("propagates fn rejections to the caller", async () => { const dbId = uniqueDb(); - // Acquire first - const { acquired } = await acquireSyncLock(dbId); - expect(acquired).toBe(true); + const err = new Error("boom"); + await expect( + withSyncLock(dbId, "syncState", async () => { + throw err; + }) + ).rejects.toBe(err); + }); - // Second call while in-progress — should wait - const waiterPromise = acquireSyncLock(dbId); + it("coalesces concurrent calls on the same (dbId, methodId): all share one fn invocation", async () => { + const dbId = uniqueDb(); + const fn = vi.fn(async () => "shared"); - // Release with a result - releaseSyncLock(dbId, "syncResult"); + const [a, b, c] = await Promise.all([ + withSyncLock(dbId, "syncState", fn), + withSyncLock(dbId, "syncState", fn), + withSyncLock(dbId, "syncState", fn), + ]); - const waiterResult = await waiterPromise; - expect(waiterResult.acquired).toBe(false); - expect(waiterResult.coalescedResult).toBe("syncResult"); + expect(fn).toHaveBeenCalledTimes(1); + expect([a, b, c]).toEqual(["shared", "shared", "shared"]); }); - it("coalesces error: waiter rejects with the same error", async () => { + it("coalesces error: concurrent waiters all reject with the same error", async () => { const dbId = uniqueDb(); - await acquireSyncLock(dbId); + const err = new Error("shared-fail"); + const fn = vi.fn(async () => { + throw err; + }); - const waiterPromise = acquireSyncLock(dbId); - const err = new Error("sync failed"); - releaseSyncLockWithError(dbId, err); + const results = await Promise.allSettled([ + withSyncLock(dbId, "syncState", fn), + withSyncLock(dbId, "syncState", fn), + ]); - await expect(waiterPromise).rejects.toThrow("sync failed"); + expect(fn).toHaveBeenCalledTimes(1); + for (const r of results) { + expect(r.status).toBe("rejected"); + expect(r.reason).toBe(err); + } }); - it("allows re-acquire after releaseSyncLock", async () => { + it("clears the in-flight slot after fn resolves: a subsequent call invokes fn fresh", async () => { const dbId = uniqueDb(); - await acquireSyncLock(dbId); - releaseSyncLock(dbId, "first"); + const fn = vi.fn(async () => "ok"); + + await withSyncLock(dbId, "syncState", fn); + await withSyncLock(dbId, "syncState", fn); - const second = await acquireSyncLock(dbId); - expect(second.acquired).toBe(true); - releaseSyncLock(dbId, "second"); + expect(fn).toHaveBeenCalledTimes(2); }); - it("multiple waiters all receive the same result", async () => { + it("clears the in-flight slot after fn rejects: a subsequent call invokes fn fresh", async () => { const dbId = uniqueDb(); - await acquireSyncLock(dbId); + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("first")) + .mockResolvedValueOnce("second"); - const w1 = acquireSyncLock(dbId); - const w2 = acquireSyncLock(dbId); + await expect(withSyncLock(dbId, "syncState", fn)).rejects.toThrow("first"); + await expect(withSyncLock(dbId, "syncState", fn)).resolves.toBe("second"); - releaseSyncLock(dbId, "sharedResult"); - - const [r1, r2] = await Promise.all([w1, w2]); - expect(r1.coalescedResult).toBe("sharedResult"); - expect(r2.coalescedResult).toBe("sharedResult"); + expect(fn).toHaveBeenCalledTimes(2); }); -}); -describe("releaseSyncLock — edge cases", () => { - it("warns when called without an active sync (no-op)", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + it("does not coalesce calls with different methodIds on the same dbId", async () => { const dbId = uniqueDb(); - releaseSyncLock(dbId, "orphan"); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("no sync was in progress") - ); - warnSpy.mockRestore(); + const fnA = vi.fn(async () => "A"); + const fnB = vi.fn(async () => "B"); + + const [a, b] = await Promise.all([ + withSyncLock(dbId, "syncState", fnA), + withSyncLock(dbId, "syncNoteTransport", fnB), + ]); + + expect(fnA).toHaveBeenCalledTimes(1); + expect(fnB).toHaveBeenCalledTimes(1); + expect(a).toBe("A"); + expect(b).toBe("B"); }); -}); -describe("releaseSyncLockWithError — edge cases", () => { - it("warns when called without an active sync (no-op)", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const dbId = uniqueDb(); - releaseSyncLockWithError(dbId, new Error("orphan error")); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("no sync was in progress") - ); - warnSpy.mockRestore(); + it("does not coalesce calls with the same methodId on different dbIds", async () => { + const dbA = uniqueDb(); + const dbB = uniqueDb(); + const fn = vi.fn(async () => "ok"); + + await Promise.all([ + withSyncLock(dbA, "syncState", fn), + withSyncLock(dbB, "syncState", fn), + ]); + + expect(fn).toHaveBeenCalledTimes(2); }); -}); -describe("acquireSyncLock — timeout (no Web Locks fallback)", () => { - it("waiter times out when sync takes too long", async () => { + it("supports multiple waiters: all receive the same resolved value", async () => { const dbId = uniqueDb(); - // Acquire the lock (first caller) - await acquireSyncLock(dbId); - // Second caller with a very short timeout - const waiterPromise = acquireSyncLock(dbId, 10); - await expect(waiterPromise).rejects.toThrow("timed out"); + let resolveFn; + const gate = new Promise((r) => (resolveFn = r)); + const fn = vi.fn(() => gate); - // Cleanup - releaseSyncLock(dbId, "late result"); - }, 3000); + const p1 = withSyncLock(dbId, "syncState", fn); + const p2 = withSyncLock(dbId, "syncState", fn); + const p3 = withSyncLock(dbId, "syncState", fn); + + resolveFn("settled"); + + const [a, b, c] = await Promise.all([p1, p2, p3]); + expect([a, b, c]).toEqual(["settled", "settled", "settled"]); + expect(fn).toHaveBeenCalledTimes(1); + }); }); -// ── Web Locks path (mocked) ─────────────────────────────────────────────────── +// ── withSyncLock — Web-Locks path ───────────────────────────────────────────── +// +// Mock navigator.locks.request to verify withSyncLock requests an exclusive +// lock on the right name and runs fn under that lock. -describe("acquireSyncLock — Web Locks path", () => { +describe("withSyncLock — Web-Locks path", () => { let origNavigator; beforeEach(() => { @@ -203,154 +231,56 @@ describe("acquireSyncLock — Web Locks path", () => { }); }); - it("acquires via Web Locks when available", async () => { - const dbId = uniqueDb(); - let lockCallback; - - // Mock navigator.locks to capture the callback - const mockLocks = { - request: vi.fn().mockImplementation((_name, _opts, callback) => { - return new Promise((resolve) => { - lockCallback = () => { - const result = callback(); - result.then(resolve); - }; - }); - }), - }; - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { locks: mockLocks }, - }); - - const lockPromise = acquireSyncLock(dbId); - // Simulate lock grant by calling the callback - lockCallback(); - const result = await lockPromise; - expect(result.acquired).toBe(true); - releaseSyncLock(dbId, "done"); - }); - - it("times out when lock is not granted within timeoutMs (Web Locks path)", async () => { - const dbId = uniqueDb(); - - // Lock request never calls its callback (lock is never granted) - const mockLocks = { - request: vi.fn().mockImplementation(() => new Promise(() => {})), // never resolves - }; - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { locks: mockLocks }, - }); - - await expect(acquireSyncLock(dbId, 10)).rejects.toThrow("timed out"); - }, 3000); - - it("notifies waiters when Web Locks timeout fires with a coalesced waiter", async () => { - const dbId = uniqueDb(); - - // Lock never granted — so the timeout fires for both acquirer and waiters - const mockLocks = { - request: vi.fn().mockImplementation(() => new Promise(() => {})), - }; + function installLocksMock(impl) { Object.defineProperty(globalThis, "navigator", { configurable: true, - value: { locks: mockLocks }, + value: { locks: { request: impl } }, }); + } - // First acquire with a short timeout - const p1 = acquireSyncLock(dbId, 15); - // While p1 is in-progress (before timeout fires), add a waiter - const p2 = acquireSyncLock(dbId, 1000); - - // Both should reject — p1 from timeout, p2 from waiter rejection - await expect(p1).rejects.toThrow("timed out"); - await expect(p2).rejects.toThrow("timed out"); - }, 3000); - - it("clears timeout when Web Locks grant the lock before timeout fires", async () => { + it("requests an exclusive lock named 'miden-sync-' and runs fn under it", async () => { const dbId = uniqueDb(); - let lockCallback; - - const mockLocks = { - request: vi.fn().mockImplementation((_name, _opts, callback) => { - return new Promise((resolve) => { - lockCallback = () => { - const result = callback(); - result.then(resolve); - }; - }); - }), - }; - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { locks: mockLocks }, + const calls = []; + installLocksMock(async (name, opts, fn) => { + calls.push({ name, opts }); + return fn(); }); - // Pass a timeout so timeoutId is set, then grant lock before it fires - const lockPromise = acquireSyncLock(dbId, 5000); - // Grant the lock immediately (before 5000ms timeout) - lockCallback(); - const result = await lockPromise; - expect(result.acquired).toBe(true); - releaseSyncLock(dbId, "done"); - }); + const result = await withSyncLock(dbId, "syncState", async () => "ok"); - it("rejects when Web Locks request rejects with Error object", async () => { - const dbId = uniqueDb(); - - const mockLocks = { - request: vi.fn().mockRejectedValue(new Error("locks unavailable")), - }; - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { locks: mockLocks }, - }); - - await expect(acquireSyncLock(dbId)).rejects.toThrow("locks unavailable"); + expect(result).toBe("ok"); + expect(calls).toEqual([ + { name: `miden-sync-${dbId}`, opts: { mode: "exclusive" } }, + ]); }); - it("wraps non-Error rejection in a new Error", async () => { + it("propagates fn rejections through the lock", async () => { const dbId = uniqueDb(); - - const mockLocks = { - request: vi.fn().mockRejectedValue("string rejection"), // not an Error object - }; - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { locks: mockLocks }, - }); - - await expect(acquireSyncLock(dbId)).rejects.toThrow("string rejection"); + const err = new Error("inside-lock"); + installLocksMock(async (_name, _opts, fn) => fn()); + + await expect( + withSyncLock(dbId, "syncState", async () => { + throw err; + }) + ).rejects.toBe(err); }); - it("releaseSyncLockWithError calls state.releaseLock if set (Web Locks path)", async () => { + it("coalesces concurrent same-(dbId, methodId) calls: one lock acquisition", async () => { const dbId = uniqueDb(); - let lockCallback; - - const mockLocks = { - request: vi.fn().mockImplementation((_name, _opts, callback) => { - return new Promise((resolve) => { - lockCallback = () => { - const result = callback(); - result.then(resolve); - }; - }); - }), - }; - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { locks: mockLocks }, - }); - - const lockPromise = acquireSyncLock(dbId); - lockCallback(); - const acquired = await lockPromise; - expect(acquired.acquired).toBe(true); - - // Now release with error — should invoke state.releaseLock - const err = new Error("sync error"); - releaseSyncLockWithError(dbId, err); - // The lock was held via a releaseLock promise; calling releaseLock() resolves it + const requested = vi.fn(async (_name, _opts, fn) => fn()); + installLocksMock(requested); + + const fn = vi.fn(async () => "ok"); + const [a, b] = await Promise.all([ + withSyncLock(dbId, "syncState", fn), + withSyncLock(dbId, "syncState", fn), + ]); + + expect(a).toBe("ok"); + expect(b).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(1); + // Coalesced: only one underlying lock request. + expect(requested).toHaveBeenCalledTimes(1); }); }); From a60f9fddb80a114320cbc196344c244d8254edec Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Thu, 30 Apr 2026 16:13:26 +0200 Subject: [PATCH 6/9] test(sync_lock): replace removed syncStateWithTimeout(N) with syncState() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same migration pattern as the unit tests fixed in 40d95a61, but in the Playwright integration suite (crates/web-client/test/sync_lock.test.ts). miden-client#2091's web-sdk-side migration removed the timeout-bearing syncStateWithTimeout method (per PR description: 'Removed Timeout Logic — the timeout only rejected the caller's promise, but it never actually cancelled the underlying sync'), so client.syncStateWithTimeout is now undefined at runtime — playwright reports 'TypeError: client.syncStateWithTimeout is not a function' across ci-shard-2-sync-and-state. Drop the timeout argument (no equivalent in the new API; coalescing handles concurrency) and call client.syncState() — the same method the new client.sync() wrapper delegates to internally. --- crates/web-client/test/sync_lock.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/web-client/test/sync_lock.test.ts b/crates/web-client/test/sync_lock.test.ts index fa2293f6..cc6dee48 100644 --- a/crates/web-client/test/sync_lock.test.ts +++ b/crates/web-client/test/sync_lock.test.ts @@ -539,7 +539,7 @@ test.describe("Sync Lock Timeout Race Condition", () => { for (let i = 0; i < 3; i++) { try { - const result = await client.syncStateWithTimeout(30000); + const result = await client.syncState(); results.push(result.blockNum()); } catch (e) { results.push(-1); // Mark failures @@ -653,10 +653,10 @@ test.describe("Sync Lock Timeout Race Condition", () => { // Fire many concurrent syncs with various timeouts const promises = [ - client.syncStateWithTimeout(50000), - client.syncStateWithTimeout(50000), client.syncState(), - client.syncStateWithTimeout(50000), + client.syncState(), + client.syncState(), + client.syncState(), client.syncState(), ]; @@ -708,14 +708,14 @@ test.describe("Sync Lock Timeout Race Condition", () => { // Do several syncs with timeouts for (let i = 0; i < 3; i++) { - await client.syncStateWithTimeout(30000); + await client.syncState(); } // Do concurrent syncs await Promise.all([ client.syncState(), client.syncState(), - client.syncStateWithTimeout(30000), + client.syncState(), ]); // Verify account state is still consistent From 3f805c6ef0d47f247eaf6eade1e965d915685852 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Mon, 4 May 2026 14:51:16 -0300 Subject: [PATCH 7/9] address concurrency findings on the new split sync API - waitFor() polling uses syncChain instead of syncState so transaction confirmation stays alive when the note transport endpoint is unavailable; chain-only sync is sufficient for confirmation. - MockWebClient gains syncChain and syncNoteTransport overrides that serialize the main-thread mock chain + note transport node to the worker before delegating, mirroring the existing syncState override. Adds matching SYNC_CHAIN_MOCK and SYNC_NOTE_TRANSPORT_MOCK worker actions and MethodName entries. - syncLock no-Web-Locks fallback now serializes cross-method calls per-dbId via an in-process promise tail. The previous fallback relied on a non-existent WASM-side mutex; in the browser, AsyncCell is a plain RefCell, so concurrent cross-method borrows would hit the recursive-use aliasing error. Same-method coalescing is unchanged. Adds two unit tests pinning the new fallback contract: cross-method ordering on the same dbId and recovery after a prior call rejects. Updates the transactions resource unit tests for the syncChain switch. --- .../__tests__/resources/transactions.test.js | 21 ++--- .../web-client/js/__tests__/syncLock.test.js | 54 ++++++++++++ crates/web-client/js/constants.js | 2 + crates/web-client/js/index.js | 84 +++++++++++++++++++ .../web-client/js/resources/transactions.js | 5 +- crates/web-client/js/syncLock.js | 28 +++++-- .../js/workers/web-client-methods-worker.js | 30 +++++++ 7 files changed, 206 insertions(+), 18 deletions(-) diff --git a/crates/web-client/js/__tests__/resources/transactions.test.js b/crates/web-client/js/__tests__/resources/transactions.test.js index 940b033a..b556662f 100644 --- a/crates/web-client/js/__tests__/resources/transactions.test.js +++ b/crates/web-client/js/__tests__/resources/transactions.test.js @@ -81,6 +81,7 @@ function makeInner(overrides = {}) { executeForSummary: vi.fn().mockResolvedValue("summary"), executeProgram: vi.fn().mockResolvedValue("programResult"), syncState: vi.fn().mockResolvedValue(undefined), + syncChain: vi.fn().mockResolvedValue(undefined), _txResult: txResult, ...overrides, }; @@ -148,7 +149,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 30000, }); - expect(inner.syncState).toHaveBeenCalled(); + expect(inner.syncChain).toHaveBeenCalled(); expect(result.txId).toBeDefined(); }); @@ -837,7 +838,7 @@ describe("TransactionsResource", () => { it("throws timeout when transaction takes too long", async () => { const { resource } = makeResource({ getTransactions: vi.fn().mockResolvedValue([]), - syncState: vi.fn().mockResolvedValue(undefined), + syncChain: vi.fn().mockResolvedValue(undefined), }); await expect( resource.waitFor("0xtxHex", { timeout: 1, interval: 0 }) @@ -879,14 +880,14 @@ describe("TransactionsResource", () => { expect(txIdObj.toHex).toHaveBeenCalled(); }); - it("continues polling when syncState throws", async () => { + it("continues polling when syncChain throws", async () => { const committedStatus = { isCommitted: () => true, isDiscarded: () => false, }; let syncCount = 0; const { resource } = makeResource({ - syncState: vi.fn().mockImplementation(() => { + syncChain: vi.fn().mockImplementation(() => { if (syncCount++ === 0) throw new Error("sync fail"); return Promise.resolve(); }), @@ -942,7 +943,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncState).toHaveBeenCalled(); + expect(inner.syncChain).toHaveBeenCalled(); expect(result.note).toBe("p2idNote"); }); }); @@ -965,7 +966,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncState).toHaveBeenCalled(); + expect(inner.syncChain).toHaveBeenCalled(); expect(result.txId).toBeDefined(); }); }); @@ -986,7 +987,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncState).toHaveBeenCalled(); + expect(inner.syncChain).toHaveBeenCalled(); expect(result.txId).toBeDefined(); }); }); @@ -1012,7 +1013,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncState).toHaveBeenCalled(); + expect(inner.syncChain).toHaveBeenCalled(); expect(result.consumed).toBe(1); }); }); @@ -1035,7 +1036,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncState).toHaveBeenCalled(); + expect(inner.syncChain).toHaveBeenCalled(); }); }); @@ -1055,7 +1056,7 @@ describe("TransactionsResource", () => { waitForConfirmation: true, timeout: 5000, }); - expect(inner.syncState).toHaveBeenCalled(); + expect(inner.syncChain).toHaveBeenCalled(); expect(result.txId).toBeDefined(); }); }); diff --git a/crates/web-client/js/__tests__/syncLock.test.js b/crates/web-client/js/__tests__/syncLock.test.js index 39c203ac..c2f11c1a 100644 --- a/crates/web-client/js/__tests__/syncLock.test.js +++ b/crates/web-client/js/__tests__/syncLock.test.js @@ -180,6 +180,60 @@ describe("withSyncLock — in-process fallback (no Web Locks)", () => { expect(b).toBe("B"); }); + it("serializes calls with different methodIds on the same dbId (no overlap)", async () => { + // Browser WebClient uses a synchronous RefCell, so overlapping + // cross-method borrows would throw the "recursive use" aliasing error. + // Without Web Locks we serialize per-dbId via the in-process chain. + const dbId = uniqueDb(); + const events = []; + let releaseA; + const gateA = new Promise((r) => (releaseA = r)); + + const fnA = vi.fn(async () => { + events.push("start-A"); + await gateA; + events.push("finish-A"); + return "A"; + }); + const fnB = vi.fn(async () => { + events.push("start-B"); + events.push("finish-B"); + return "B"; + }); + + const pA = withSyncLock(dbId, "syncState", fnA); + const pB = withSyncLock(dbId, "syncNoteTransport", fnB); + + // Yield enough microtasks for fnA to enter; fnB must still be queued. + for (let i = 0; i < 5; i++) await Promise.resolve(); + expect(events).toEqual(["start-A"]); + + releaseA(); + await Promise.all([pA, pB]); + + expect(events).toEqual(["start-A", "finish-A", "start-B", "finish-B"]); + }); + + it("runs the next queued call after a prior cross-method call rejects", async () => { + const dbId = uniqueDb(); + const events = []; + const fnA = vi.fn(async () => { + events.push("A"); + throw new Error("A-fail"); + }); + const fnB = vi.fn(async () => { + events.push("B"); + return "B"; + }); + + const pA = withSyncLock(dbId, "syncState", fnA); + const pB = withSyncLock(dbId, "syncNoteTransport", fnB); + + await expect(pA).rejects.toThrow("A-fail"); + await expect(pB).resolves.toBe("B"); + expect(events).toEqual(["A", "B"]); + }); + it("does not coalesce calls with the same methodId on different dbIds", async () => { const dbA = uniqueDb(); const dbB = uniqueDb(); diff --git a/crates/web-client/js/constants.js b/crates/web-client/js/constants.js index 63680461..ab650508 100644 --- a/crates/web-client/js/constants.js +++ b/crates/web-client/js/constants.js @@ -23,5 +23,7 @@ export const MethodName = Object.freeze({ SYNC_STATE: "syncState", SYNC_STATE_MOCK: "syncStateMock", SYNC_CHAIN: "syncChain", + SYNC_CHAIN_MOCK: "syncChainMock", SYNC_NOTE_TRANSPORT: "syncNoteTransport", + SYNC_NOTE_TRANSPORT_MOCK: "syncNoteTransportMock", }); diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index 2e008334..33fa2067 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -1038,6 +1038,90 @@ class MockWebClient extends WebClient { } } + /** + * Syncs only the on-chain mock state (no note transport fetch). + * + * In worker mode, the main-thread mock chain + note-transport-node state + * is serialized and shipped to the worker before the sync, so a prior + * `proveBlock()` on the main thread is reflected in the worker's WASM + * client. The no-worker path uses the main-thread WASM client directly. + * + * @returns {Promise} + */ + async syncChain() { + const dbId = this.storeName || "mock"; + const methodId = MethodName.SYNC_CHAIN; + + try { + return await withSyncLock(dbId, methodId, async () => { + const wasmWebClient = await this.getWasmWebClient(); + + if (!this.worker) { + return await wasmWebClient.syncChainImpl(); + } + + const serializedMockChain = (await wasmWebClient.serializeMockChain()) + .buffer; + const serializedMockNoteTransportNode = ( + await wasmWebClient.serializeMockNoteTransportNode() + ).buffer; + + const wasm = await getWasmOrThrow(); + const serializedSyncSummaryBytes = await this.callMethodWithWorker( + MethodName.SYNC_CHAIN_MOCK, + serializedMockChain, + serializedMockNoteTransportNode + ); + return wasm.SyncSummary.deserialize( + new Uint8Array(serializedSyncSummaryBytes) + ); + }); + } catch (error) { + console.error("INDEX.JS: Error in syncChain:", error); + throw error; + } + } + + /** + * Syncs only the mock note-transport state (no chain fetch). + * + * Mirrors {@link MockWebClient#syncChain}: in worker mode, the + * main-thread mock chain + note-transport-node state is serialized + * and shipped to the worker first. + * + * @returns {Promise} + */ + async syncNoteTransport() { + const dbId = this.storeName || "mock"; + const methodId = MethodName.SYNC_NOTE_TRANSPORT; + + try { + await withSyncLock(dbId, methodId, async () => { + const wasmWebClient = await this.getWasmWebClient(); + + if (!this.worker) { + await wasmWebClient.syncNoteTransportImpl(); + return; + } + + const serializedMockChain = (await wasmWebClient.serializeMockChain()) + .buffer; + const serializedMockNoteTransportNode = ( + await wasmWebClient.serializeMockNoteTransportNode() + ).buffer; + + await this.callMethodWithWorker( + MethodName.SYNC_NOTE_TRANSPORT_MOCK, + serializedMockChain, + serializedMockNoteTransportNode + ); + }); + } catch (error) { + console.error("INDEX.JS: Error in syncNoteTransport:", error); + throw error; + } + } + async submitNewTransaction(accountId, transactionRequest) { try { if (!this.worker) { diff --git a/crates/web-client/js/resources/transactions.js b/crates/web-client/js/resources/transactions.js index f7bb9c33..78fe045a 100644 --- a/crates/web-client/js/resources/transactions.js +++ b/crates/web-client/js/resources/transactions.js @@ -353,7 +353,10 @@ export class TransactionsResource { } try { - await this.#inner.syncState(); + // Chain-only sync is sufficient: confirmation only needs on-chain + // state, and skipping NTL keeps polling alive when the note + // transport endpoint is unavailable. + await this.#inner.syncChain(); } catch { // Sync may fail transiently; continue polling } diff --git a/crates/web-client/js/syncLock.js b/crates/web-client/js/syncLock.js index a248801d..37d5585a 100644 --- a/crates/web-client/js/syncLock.js +++ b/crates/web-client/js/syncLock.js @@ -7,8 +7,8 @@ * - Same-method coalescing: if a sync of the same method is in progress, * subsequent callers share its result promise * - Different-method serialization: different methods (e.g. syncState vs - * syncNoteTransport) wait for each other via the Web Lock (or the - * WASM-level mutex when Web Locks are unavailable) + * syncNoteTransport) wait for each other via the Web Lock, or via an + * in-process per-dbId promise chain when Web Locks are unavailable * - Web Locks also serialize across tabs (Chrome 69+, Safari 15.4+) */ @@ -26,6 +26,11 @@ export function hasWebLocks() { // Coalesce map keyed by `${dbId}:${methodId}` -> in-flight promise. const inFlight = new Map(); +// Per-dbId promise tail used to serialize cross-method calls when Web Locks +// are unavailable. Each new task chains onto the current tail so different +// methods on the same dbId run sequentially within the tab. +const fallbackTails = new Map(); + /** * Build the coalesce-map key for an in-flight sync of `(dbId, methodId)`. * @@ -39,8 +44,11 @@ function coalesceKey(dbId, methodId) { /** * Run `fn` while holding the per-db Web Lock. When Web Locks are unavailable, - * runs `fn` directly and relies on the WASM-level mutex (`get_mut_inner`) to - * serialize across methods within the tab. + * serializes `fn` against any other in-flight call on the same `dbId` via an + * in-process promise chain — the wasm-bindgen `WebClient` uses a synchronous + * `RefCell` for interior mutability in the browser, so overlapping + * cross-method borrows would throw "recursive use of an object detected + * which would lead to unsafe aliasing in rust". * * @param {string} dbId * @param {() => Promise} fn @@ -49,9 +57,15 @@ function coalesceKey(dbId, methodId) { */ function runUnderLock(dbId, fn) { if (!hasWebLocks()) { - // No Web Locks: rely on the WASM-level mutex (get_mut_inner) to serialize - // across methods within the tab. - return Promise.resolve().then(fn); + const prev = fallbackTails.get(dbId) ?? Promise.resolve(); + const next = prev.catch(() => {}).then(fn); + const guarded = next.catch(() => {}); + fallbackTails.set(dbId, guarded); + guarded.then(() => { + // Drop the slot only if no successor chained onto this tail. + if (fallbackTails.get(dbId) === guarded) fallbackTails.delete(dbId); + }); + return next; } return navigator.locks.request( `miden-sync-${dbId}`, diff --git a/crates/web-client/js/workers/web-client-methods-worker.js b/crates/web-client/js/workers/web-client-methods-worker.js index ecb3e6a7..78c75579 100644 --- a/crates/web-client/js/workers/web-client-methods-worker.js +++ b/crates/web-client/js/workers/web-client-methods-worker.js @@ -315,6 +315,36 @@ methodHandlers[MethodName.SYNC_STATE_MOCK] = async (args) => { return await methodHandlers[MethodName.SYNC_STATE](); }; +methodHandlers[MethodName.SYNC_CHAIN_MOCK] = async (args) => { + let [serializedMockChain, serializedMockNoteTransportNode] = args; + serializedMockChain = new Uint8Array(serializedMockChain); + serializedMockNoteTransportNode = serializedMockNoteTransportNode + ? new Uint8Array(serializedMockNoteTransportNode) + : null; + await wasmWebClient.createMockClient( + wasmSeed, + serializedMockChain, + serializedMockNoteTransportNode + ); + + return await methodHandlers[MethodName.SYNC_CHAIN](); +}; + +methodHandlers[MethodName.SYNC_NOTE_TRANSPORT_MOCK] = async (args) => { + let [serializedMockChain, serializedMockNoteTransportNode] = args; + serializedMockChain = new Uint8Array(serializedMockChain); + serializedMockNoteTransportNode = serializedMockNoteTransportNode + ? new Uint8Array(serializedMockNoteTransportNode) + : null; + await wasmWebClient.createMockClient( + wasmSeed, + serializedMockChain, + serializedMockNoteTransportNode + ); + + return await methodHandlers[MethodName.SYNC_NOTE_TRANSPORT](); +}; + methodHandlers[MethodName.SUBMIT_NEW_TRANSACTION_MOCK] = async (args) => { const wasm = await getWasmOrThrow(); let serializedMockNoteTransportNode = args.pop(); From 49a9d800c44d325fe8ea8799880c00c8311221fa Mon Sep 17 00:00:00 2001 From: JereSalo Date: Mon, 4 May 2026 15:25:46 -0300 Subject: [PATCH 8/9] alias syncChain and syncNoteTransport in Node test adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser-side WebClient proxy in index.js wraps the raw napi-exported syncChainImpl / syncNoteTransportImpl with same-named JS methods that go through the sync lock. The napi-built client exposes only the *Impl forms, so test adapters and napi consumers need to alias the public names to the Impl methods — same pattern as the existing syncState → syncStateImpl alias. Without these, MidenClient.syncChain() / .syncNoteTransport() and the new TransactionsResource.waitFor() polling path throw TypeError on the napi binding (the underlying property is undefined). Updates node-adapter.ts, test-helpers.ts, and test-setup.ts. --- crates/web-client/test/node-adapter.ts | 10 ++++++++++ crates/web-client/test/test-helpers.ts | 7 ++++++- crates/web-client/test/test-setup.ts | 8 ++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/web-client/test/node-adapter.ts b/crates/web-client/test/node-adapter.ts index b70ffbe6..723d4dea 100644 --- a/crates/web-client/test/node-adapter.ts +++ b/crates/web-client/test/node-adapter.ts @@ -7,6 +7,8 @@ * Key adaptations: * - BigInt → Number for JsU64 params (napi uses f64, browser uses BigInt) * - syncState() → syncStateImpl() + * - syncChain() → syncChainImpl() + * - syncNoteTransport() → syncNoteTransportImpl() * - createMockClient() with no args → createMockClient(dbPath, keystorePath, ...) * - Fake page.evaluate() that runs callbacks directly */ @@ -194,6 +196,14 @@ function wrapClient(client: any, storeName?: string): any { if (prop === "syncState") { return (...args: any[]) => target.syncStateImpl(...args); } + // syncChain → syncChainImpl + if (prop === "syncChain") { + return (...args: any[]) => target.syncChainImpl(...args); + } + // syncNoteTransport → syncNoteTransportImpl + if (prop === "syncNoteTransport") { + return (...args: any[]) => target.syncNoteTransportImpl(...args); + } // storeName — used by MidenClient for lock coordination if (prop === "storeName") { return storeName || "default"; diff --git a/crates/web-client/test/test-helpers.ts b/crates/web-client/test/test-helpers.ts index 8435a460..1ab84b73 100644 --- a/crates/web-client/test/test-helpers.ts +++ b/crates/web-client/test/test-helpers.ts @@ -409,7 +409,8 @@ function wrapClass(Cls: any): any { /** * Wraps a raw napi WebClient for MidenClient compatibility. - * Handles syncState → syncStateImpl, BigInt → Number, null → undefined. + * Handles syncState → syncStateImpl (and the new split-sync siblings), + * BigInt → Number, null → undefined. */ function wrapClientForMidenClient( rawClient: any, @@ -420,6 +421,10 @@ function wrapClientForMidenClient( get(target, prop) { if (prop === "syncState") return (...args: any[]) => target.syncStateImpl(...args); + if (prop === "syncChain") + return (...args: any[]) => target.syncChainImpl(...args); + if (prop === "syncNoteTransport") + return (...args: any[]) => target.syncNoteTransportImpl(...args); if (prop === "storeName") return storeName || "mock"; if (prop === "wasmWebClient") return target; if (prop === "proveBlock") return async () => target.proveBlock(); diff --git a/crates/web-client/test/test-setup.ts b/crates/web-client/test/test-setup.ts index f61713d9..66b041ec 100644 --- a/crates/web-client/test/test-setup.ts +++ b/crates/web-client/test/test-setup.ts @@ -138,6 +138,8 @@ export async function createNodeIntegrationClient( /** * Wraps a napi WebClient to normalize differences with the browser SDK: * - syncState() → syncStateImpl() + * - syncChain() → syncChainImpl() + * - syncNoteTransport() → syncNoteTransportImpl() * - null → undefined for Option returns */ export function wrapNodeClient(rawClient: any, rawSdk: any): any { @@ -172,6 +174,12 @@ export function wrapNodeClient(rawClient: any, rawSdk: any): any { if (prop === "syncState") { return (...args: any[]) => target.syncStateImpl(...args); } + if (prop === "syncChain") { + return (...args: any[]) => target.syncChainImpl(...args); + } + if (prop === "syncNoteTransport") { + return (...args: any[]) => target.syncNoteTransportImpl(...args); + } if (prop === "proveBlock") { return async () => { const guard = await target.proveBlock(); From 2e0fc74aa8c16ba32fdf357230fd0168e1cf7358 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Mon, 4 May 2026 15:33:27 -0300 Subject: [PATCH 9/9] drop redundant reference in export.rs format! arg A recent clippy version flags useless_borrows_in_formatting on &account_id.to_string() inside format!(); the reference is unnecessary because format! takes its arguments by value (via Display). Pre-existing line, surfaced now by tightened lint rules. --- crates/web-client/src/export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/web-client/src/export.rs b/crates/web-client/src/export.rs index 5f17eab1..5817fdd9 100644 --- a/crates/web-client/src/export.rs +++ b/crates/web-client/src/export.rs @@ -66,7 +66,7 @@ impl WebClient { keystore.get_keys_for_account(account_id.as_native()).await.map_err(|err| { js_error_with_context( err, - &format!("failed to get keys for account: {}", &account_id.to_string()), + &format!("failed to get keys for account: {}", account_id.to_string()), ) })?;