From d9e19afcf0bbede1b5a2b805373d556fe3f82cd0 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Tue, 28 Apr 2026 21:07:13 +0200 Subject: [PATCH] refactor(web-client): read borrows Migrated from 0xMiden/miden-client#2080 (author: igamigo) as part of the web-sdk split. Original PR: https://github.com/0xMiden/miden-client/pull/2080 Read-only WebClient methods now take a shared borrow of the inner client (guard.as_ref()), and the JS proxy routes them through a readers-writer lock so reads run in parallel while writes run alone. The _withInnerWebClient re-entrancy fast path is preserved for both the read and write paths. --- CHANGELOG.md | 1 + crates/web-client/js/client.js | 36 +++-- crates/web-client/js/index.js | 165 ++++++++++++++++------ crates/web-client/js/types/api-types.d.ts | 10 +- crates/web-client/src/account.rs | 22 +-- crates/web-client/src/export.rs | 8 +- crates/web-client/src/lib.rs | 18 ++- crates/web-client/src/notes.rs | 20 +-- crates/web-client/src/platform.rs | 14 ++ crates/web-client/src/settings.rs | 12 +- crates/web-client/src/sync.rs | 4 +- crates/web-client/src/tags.rs | 4 +- crates/web-client/src/transactions.rs | 4 +- 13 files changed, 209 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63861914..79ceec91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Enhancements +* [FEATURE][web] Concurrent `MidenClient` reads now run in parallel. Read-only WASM bindings (`getAccount`, `getAccounts`, `getAccountStorage`, ...) take shared borrows of the inner client, and the JS proxy's write-serialization chain was replaced with a readers-writer lock, so reads coalesce while writes still take the slot exclusively. ([#29](https://github.com/0xMiden/web-sdk/pull/29), original: [0xMiden/miden-client#2080](https://github.com/0xMiden/miden-client/pull/2080)) * [FEATURE][web,react] The 0.14.x line's mobile/MT proving surface is available on the 0.15 series (forward-ported from `main`): `TransactionProver.newCallbackProver(jsFn)` (route prove to a native iOS/Android plugin; wire format matches `RemoteTransactionProver`), `ClientOptions.useWorker?: boolean` / `MidenConfig.useWorker` (opt out of the Web Worker shim — required for callback provers, whose closure cannot cross the worker boundary), `MidenClient._withInnerWebClient(fn)` with the depth-tracked re-entrancy fix, the multi-threaded WASM build at the `@miden-sdk/miden-sdk/mt` + `/mt/lazy` (and `@miden-sdk/react/mt` + `/mt/lazy`) subpaths, and the `miden-mobile-prover` C-ABI crate — now built against the 0.15 protocol, so its `TransactionInputs`/`ProvenTransaction` wire format requires a matching 0.15 SDK (native binaries built from 0.14 do not interoperate). ([#149](https://github.com/0xMiden/web-sdk/pull/149), [#152](https://github.com/0xMiden/web-sdk/pull/152), [#134](https://github.com/0xMiden/web-sdk/pull/134)) * [FEATURE][web] Added `BlockHeader.feeFaucetId()` — the account ID of the fungible faucet whose assets pay transaction verification fees, read from the block's on-chain fee parameters. This is the 0.15 spelling of the 0.14 line's `BlockHeader.nativeAssetId()` (the underlying protocol field was renamed `native_asset_id` → `fee_faucet_id`); consumers discovering the fee/native asset per network should migrate to the new name. diff --git a/crates/web-client/js/client.js b/crates/web-client/js/client.js index 71fb5a46..75f4e138 100644 --- a/crates/web-client/js/client.js +++ b/crates/web-client/js/client.js @@ -58,10 +58,10 @@ export class MidenClient { * * The callback runs inside `_serializeWasmCall`, so the WASM RefCell is * held for the duration of `fn`. Concurrent SDK calls (sync, other - * transactions, etc.) queue on the same chain and run after `fn` - * settles. Without this serialization, raw inner-client access would - * race the proxy's chain and trip wasm-bindgen's "recursive use of an - * object detected" panic. + * transactions, etc.) queue on the same readers-writer lock and run + * after `fn` settles. Without this serialization, raw inner-client + * access would race the lock and trip wasm-bindgen's "recursive use of + * an object detected" panic. * * Re-entrancy: while `fn` is running, the underlying client's * `_withInnerLockDepth` counter is bumped so that `_serializeWasmCall` @@ -308,28 +308,26 @@ export class MidenClient { } /** - * Resolves once every serialized WASM call that was already on the - * internal `_serializeWasmCall` chain when `waitForIdle()` was called - * (execute, submit, prove, apply, sync, or account creation) has - * settled. Use this from callers that need to perform a non-WASM-side - * action — e.g. clearing an in-memory auth key on wallet lock — after - * the kernel finishes, so its auth callback doesn't race with the key - * being cleared. + * Resolves once every WASM call queued on this client before + * `waitForIdle()` was called (execute, submit, prove, apply, sync, + * account creation, proxy-fallback reads) has settled. Use this from + * callers that need to perform a non-WASM-side action — e.g. clearing + * an in-memory auth key on wallet lock — after the kernel finishes, so + * its auth callback doesn't race with the key being cleared. * * Does NOT wait for calls enqueued after `waitForIdle()` returns — * intentional, so a caller can drain and proceed without being blocked * indefinitely by concurrent workload. * * Caveat for `syncState`: `syncStateWithTimeout` awaits the sync lock - * (`acquireSyncLock`, which uses Web Locks) BEFORE putting its WASM - * call onto the chain, so a `syncState` that is queued on the sync - * lock — but has not yet begun its WASM phase — is not visible to - * `waitForIdle` and will not be awaited. Other methods (`newWallet`, - * `executeTransaction`, etc.) route through the chain synchronously - * on call and are always observed. + * (`acquireSyncLock`, which uses Web Locks) BEFORE enqueuing its WASM + * call, so a `syncState` that is queued on the sync lock — but has + * not yet begun its WASM phase — is not visible to `waitForIdle` and + * will not be awaited. Other methods (`newWallet`, `executeTransaction`, + * etc.) route through the queue synchronously on call and are always + * observed. * - * Safe to call at any time; returns immediately if nothing was in - * flight. + * Safe to call at any time; resolves as soon as the client is idle. * * @returns {Promise} */ diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index f9626e94..9231d7b9 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -141,15 +141,17 @@ const READ_METHODS = new Set([ "getTransactions", "listSettingKeys", "listTags", - "executeProgram", ]); const MOCK_STORE_NAME = "mock_client_db"; -// Suppress unused-variable warnings — these sets exist solely for the CI lint check. -void SYNC_METHODS; +// `SYNC_METHODS` and `READ_METHODS` are read by `createClientProxy` to +// route proxy-fallback calls: sync methods bypass the lock entirely and +// reads go through the shared-borrow path so they can run in parallel. +// `WRITE_METHODS` is consulted only by the CI lint (see +// scripts/check-method-classification.js); suppress unused-variable +// warnings for it. void WRITE_METHODS; -void READ_METHODS; const buildTypedArraysExport = (exportObject) => { return Object.entries(exportObject).reduce( @@ -252,6 +254,18 @@ export const getWasmOrThrow = async () => { /** * Create a Proxy that forwards missing properties to the underlying WASM * WebClient. + * + * Async proxy-fallback methods are routed through the readers-writer lock + * on the enclosing `WebClient`: + * - `READ_METHODS` take `&self` on the Rust side, so wasm-bindgen uses + * shared borrows for them and they can run concurrently with other + * reads; they go through `_serializeWasmRead`. + * - Everything else takes `&mut self` and must run alone; those go + * through `_serializeWasmCall`. + * + * `SYNC_METHODS` opts out: they are synchronous in JS and wrapping them + * would change their return type to `Promise`, which is a breaking + * change for consumers that use them as plain getters or builders. */ function createClientProxy(instance) { return new Proxy(instance, { @@ -262,7 +276,19 @@ function createClientProxy(instance) { if (target.wasmWebClient && prop in target.wasmWebClient) { const value = target.wasmWebClient[prop]; if (typeof value === "function") { - return value.bind(target.wasmWebClient); + if (typeof prop === "string" && SYNC_METHODS.has(prop)) { + return value.bind(target.wasmWebClient); + } + if (typeof prop === "string" && READ_METHODS.has(prop)) { + return (...args) => + target._serializeWasmRead(() => + value.apply(target.wasmWebClient, args) + ); + } + return (...args) => + target._serializeWasmCall(() => + value.apply(target.wasmWebClient, args) + ); } return value; } @@ -499,37 +525,76 @@ class WebClient { this.wasmWebClient = null; this.wasmWebClientPromise = null; - // Promise chain to serialize direct WASM calls that require exclusive - // (&mut self) access. Without this, concurrent calls on the same client - // would panic with "recursive use of an object detected" due to - // wasm-bindgen's internal RefCell. - this._wasmCallChain = Promise.resolve(); + // Readers-writer lock state for direct WASM calls. Concurrent calls on + // the same client would otherwise panic with "recursive use of an object + // detected" (wasm-bindgen's internal RefCell): any two `&mut self` calls, + // or an `&mut self` call racing an `&self` call, collide on the borrow. + // Multiple `&self` calls (our reads) can coexist, so they run in parallel + // while writes still take the slot alone. + this._rwQueue = []; // FIFO of { type: 'read' | 'write', fn, resolve, reject } + this._rwRunning = 0; + this._rwRunningType = null; // 'read' | 'write' | null // Depth counter for `_withInnerWebClient` re-entrancy. While > 0, - // `_serializeWasmCall` runs its callback inline instead of queueing - // it on the chain — see the comment on `_serializeWasmCall` for the - // safety contract. + // `_serializeWasmCall` and `_serializeWasmRead` run their callback + // inline instead of enqueueing it on the readers-writer queue — see + // the comment on `_serializeWasmCall` for the safety contract. this._withInnerLockDepth = 0; } /** - * Serialize a WASM call that requires exclusive (&mut self) access. - * Concurrent calls are queued and executed one at a time. + * Enqueue a WASM call under the readers-writer lock. + * @param {'read' | 'write'} type + * @param {() => Promise} fn + * @returns {Promise} + */ + _enqueueWasmCall(type, fn) { + return new Promise((resolve, reject) => { + this._rwQueue.push({ type, fn, resolve, reject }); + this._pumpWasmCalls(); + }); + } + + _pumpWasmCalls() { + while (this._rwQueue.length > 0) { + const head = this._rwQueue[0]; + const canStart = + this._rwRunningType === null || + (this._rwRunningType === "read" && head.type === "read"); + if (!canStart) break; + this._rwQueue.shift(); + this._rwRunning++; + this._rwRunningType = head.type; + (async () => { + try { + head.resolve(await head.fn()); + } catch (err) { + head.reject(err); + } finally { + this._rwRunning--; + if (this._rwRunning === 0) this._rwRunningType = null; + this._pumpWasmCalls(); + } + })(); + } + } + + /** + * Serialize a WASM call that requires exclusive (`&mut self`) access. + * The call waits for every prior queued call (reads and writes) to finish + * and runs alone. New calls queued after it will wait for it to complete. * * Wraps both the direct (in-thread) path and the worker-dispatched path. * On the worker path this is redundant with the worker's own message queue, - * but harmless (the chain resolves immediately on the main thread once the - * worker's postMessage returns). On the direct path it is load-bearing — - * without it, concurrent main-thread callers would panic with - * "recursive use of an object detected" (wasm-bindgen's internal RefCell). + * but harmless. On the direct path it is load-bearing. * * Re-entrancy: when invoked from inside a `_withInnerWebClient(fn)` * callback — detected via `_withInnerLockDepth > 0` — `fn` runs inline - * (no chain enqueue). The outer `_withInnerWebClient` invocation - * already holds the chain via its own wrapping `_serializeWasmCall`, + * (no queue enqueue). The outer `_withInnerWebClient` invocation + * already holds the write slot via its own wrapping `_serializeWasmCall`, * so enqueueing the inner call would deadlock (the inner queues * behind the outer; the outer awaits the inner). The inline run is - * safe because the chain still serializes against external callers - * — they queue behind the outer call's chain slot, which only resolves + * safe because the queue still serializes against external callers + * — they queue behind the outer call's write slot, which only resolves * after `fn` (including all inline re-entries) settles. Callers of * `_withInnerWebClient` MUST hold an external mutex preventing * concurrent access via other code paths on this same instance during @@ -543,38 +608,48 @@ class WebClient { if (this._withInnerLockDepth > 0) { return Promise.resolve().then(fn); } - const result = this._wasmCallChain.catch(() => {}).then(fn); - this._wasmCallChain = result.catch(() => {}); - return result; + return this._enqueueWasmCall("write", fn); + } + + /** + * Serialize a WASM call that only requires shared (`&self`) access. + * Concurrent reads run in parallel; a read waits for any in-flight or + * queued write to complete first. + * + * @param {() => Promise} fn - The async function to execute. + * @returns {Promise} The result of fn. + */ + _serializeWasmRead(fn) { + if (this._withInnerLockDepth > 0) { + return Promise.resolve().then(fn); + } + return this._enqueueWasmCall("read", fn); } /** - * Returns a promise that resolves once every serialized WASM call that - * was already on `_wasmCallChain` when `waitForIdle()` was called has - * settled. Use this from callers that need to perform a non-WASM-side - * action (e.g. clear an in-memory auth key) AFTER any in-flight - * execute / submit / sync has completed, so the WASM kernel's auth - * callback doesn't race with the key being cleared. + * Resolves once every WASM call queued on this client before `waitForIdle()` + * was called has settled. Use this from callers that need to perform a + * non-WASM-side action (e.g. clear an in-memory auth key) AFTER any + * in-flight execute / submit / sync has completed, so the WASM kernel's + * auth callback doesn't race with the key being cleared. * - * Does NOT wait for calls enqueued after `waitForIdle()` returns — - * this is intentional, so a caller can drain and then proceed without - * being blocked indefinitely by a concurrent workload. + * Implemented by enqueuing a no-op write: it cannot run until every + * preceding read and write has finished, so when it runs the client is + * idle. Calls queued after `waitForIdle()` returns are not awaited. * * Caveat for `syncState`: `syncStateWithTimeout` awaits - * `acquireSyncLock` (Web Locks) BEFORE wrapping its WASM call in - * `_serializeWasmCall`, so a sync that is queued on the sync lock but - * has not yet reached its WASM phase is not on the chain and will not - * be awaited. Every other serialized method (`executeTransaction`, - * `newWallet`, `submitNewTransaction`, `proveTransaction`, - * `applyTransaction`, and the proxy-fallback reads) routes through - * the chain synchronously on call and is always observed. + * `acquireSyncLock` (Web Locks) BEFORE enqueueing its WASM call, so a + * sync that is queued on the sync lock but has not yet reached its WASM + * phase is not on the queue and will not be awaited. Every other + * serialized method (`executeTransaction`, `newWallet`, + * `submitNewTransaction`, `proveTransaction`, `applyTransaction`, and + * the proxy-fallback reads) routes through the queue synchronously on + * call and is always observed. * * @returns {Promise} */ async waitForIdle() { - // Chain on `_wasmCallChain`; by the time this resolves, any in-flight - // serialized call has settled. Catch so the chain state doesn't leak. - await this._wasmCallChain.catch(() => {}); + await this._enqueueWasmCall("write", async () => {}).catch(() => {}); } // TODO: This will soon conflict with some changes in main. diff --git a/crates/web-client/js/types/api-types.d.ts b/crates/web-client/js/types/api-types.d.ts index 7ff194b7..8416f889 100644 --- a/crates/web-client/js/types/api-types.d.ts +++ b/crates/web-client/js/types/api-types.d.ts @@ -1059,19 +1059,19 @@ export declare class MidenClient { /** Returns the current sync height. */ getSyncHeight(): Promise; /** - * Resolves once every serialized WASM call that was already on the - * internal call chain when `waitForIdle()` was called (execute, submit, - * prove, apply, sync, or account creation) has settled. Use this from + * Resolves once every WASM call queued on this client before + * `waitForIdle()` was called (execute, submit, prove, apply, sync, + * account creation, proxy-fallback reads) has settled. Use this from * callers that need to perform a non-WASM-side action — e.g. clearing * an in-memory auth key on wallet lock — after the kernel finishes, so * its auth callback doesn't race with the key being cleared. Does NOT * wait for calls enqueued after `waitForIdle()` returns. * * Caveat for `sync`: a `syncState` blocked on its sync lock (Web - * Locks) has not yet reached the internal chain, so `waitForIdle` + * Locks) has not yet reached the internal queue, so `waitForIdle` * does not await it. Other serialized methods are always observed. * - * Returns immediately if nothing was in flight. + * Resolves as soon as the client is idle. */ waitForIdle(): Promise; /** diff --git a/crates/web-client/src/account.rs b/crates/web-client/src/account.rs index 21a7a8ef..edb74b54 100644 --- a/crates/web-client/src/account.rs +++ b/crates/web-client/src/account.rs @@ -20,8 +20,8 @@ use crate::{WebClient, js_error_with_context}; impl WebClient { #[js_export(js_name = "getAccounts")] pub async fn get_accounts(&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"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let result = client .get_account_headers() .await @@ -35,8 +35,8 @@ impl WebClient { /// This method loads the complete account state including vault, storage, and code. #[js_export(js_name = "getAccount")] pub async fn get_account(&self, account_id: &AccountId) -> Result, JsErr> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; client .get_account(account_id.into()) .await @@ -49,8 +49,8 @@ impl WebClient { /// To check the balance for a single asset, use `accountReader` instead. #[js_export(js_name = "getAccountVault")] pub async fn get_account_vault(&self, account_id: &AccountId) -> Result { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; client .get_account_vault(account_id.into()) .await @@ -66,8 +66,8 @@ impl WebClient { &self, account_id: &AccountId, ) -> Result { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; client .get_account_storage(account_id.into()) .await @@ -83,8 +83,8 @@ impl WebClient { &self, account_id: &AccountId, ) -> Result, JsErr> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; client .get_account_code(account_id.into()) .await @@ -108,7 +108,7 @@ impl WebClient { /// ``` #[js_export(js_name = "accountReader")] pub async fn account_reader(&self, account_id: &AccountId) -> Result { - let guard = self.inner.lock().await; + let guard = self.get_inner().await; let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; Ok(AccountReader::from(client.account_reader(account_id.into()))) } diff --git a/crates/web-client/src/export.rs b/crates/web-client/src/export.rs index 5817fdd9..876c0160 100644 --- a/crates/web-client/src/export.rs +++ b/crates/web-client/src/export.rs @@ -21,8 +21,8 @@ impl WebClient { note_id: String, export_format: NoteExportFormat, ) -> Result { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let note_id = NoteId::from_raw(Word::try_from(note_id).map_err(|err| { js_error_with_context(err, "error exporting note file: failed to parse input note id") })?); @@ -47,8 +47,8 @@ impl WebClient { #[js_export(js_name = "exportAccountFile")] pub async fn export_account_file(&self, account_id: AccountId) -> Result { let keystore = self.get_keystore().await?; - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let account = client .get_account(account_id.into()) .await diff --git a/crates/web-client/src/lib.rs b/crates/web-client/src/lib.rs index ae188ec8..385ace26 100644 --- a/crates/web-client/src/lib.rs +++ b/crates/web-client/src/lib.rs @@ -239,14 +239,14 @@ impl WebClient { /// Returns the identifier of the underlying store (e.g. `IndexedDB` database name, file path). #[js_export(js_name = "storeIdentifier")] pub async fn store_identifier(&self) -> Result { - let guard = self.inner.lock().await; + let guard = self.inner.lock_shared().await; let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; Ok(client.store_identifier().to_string()) } #[js_export(js_name = "createCodeBuilder")] pub async fn create_code_builder(&self) -> Result { - let guard = self.inner.lock().await; + let guard = self.inner.lock_shared().await; let client = guard.as_ref().ok_or_else(|| { from_str_err("client was not initialized before instancing CodeBuilder") })?; @@ -262,8 +262,20 @@ impl WebClient { self.inner.lock().await } + /// Shared-borrow counterpart of [`Self::get_mut_inner`] for read-only + /// (`&self`-on-the-client) methods, so concurrent reads can hold the + /// guard across awaits without colliding. + pub(crate) async fn get_inner( + &self, + ) -> impl core::ops::Deref>> + '_ { + self.inner.lock_shared().await + } + pub(crate) async fn get_keystore(&self) -> Result, JsErr> { - let guard = self.inner.lock().await; + // Shared borrow: this is called from read-classified methods that may + // run in parallel with other reads holding a shared guard across an + // await — an exclusive borrow here would panic on the browser build. + let guard = self.inner.lock_shared().await; guard .as_ref() .and_then(|c| c.authenticator()) diff --git a/crates/web-client/src/notes.rs b/crates/web-client/src/notes.rs index 8da093f7..18d215ed 100644 --- a/crates/web-client/src/notes.rs +++ b/crates/web-client/src/notes.rs @@ -14,8 +14,8 @@ use crate::{WebClient, js_error_with_context}; impl WebClient { #[js_export(js_name = "getInputNotes")] pub async fn get_input_notes(&self, filter: NoteFilter) -> Result, JsErr> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let result = client .get_input_notes(filter.into()) .await @@ -25,8 +25,8 @@ impl WebClient { #[js_export(js_name = "getInputNote")] pub async fn get_input_note(&self, note_id: String) -> Result, JsErr> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let note_id: NoteId = NoteId::from_raw( Word::try_from(note_id) .map_err(|err| js_error_with_context(err, "failed to parse input note id"))?, @@ -44,8 +44,8 @@ impl WebClient { &self, filter: NoteFilter, ) -> Result, JsErr> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let notes = client .get_output_notes(filter.into()) .await @@ -55,8 +55,8 @@ impl WebClient { #[js_export(js_name = "getOutputNote")] pub async fn get_output_note(&self, note_id: String) -> Result { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let note_id: NoteId = NoteId::from_raw( Word::try_from(note_id) .map_err(|err| js_error_with_context(err, "failed to parse output note id"))?, @@ -75,8 +75,8 @@ impl WebClient { &self, account_id: Option, ) -> Result, JsErr> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let native_account_id = account_id.map(Into::into); let result = Box::pin(client.get_consumable_notes(native_account_id)) .await diff --git a/crates/web-client/src/platform.rs b/crates/web-client/src/platform.rs index 75e2545d..cdb406e4 100644 --- a/crates/web-client/src/platform.rs +++ b/crates/web-client/src/platform.rs @@ -81,6 +81,14 @@ impl AsyncCell { self.0.borrow_mut() } + /// Async shared borrow. Multiple shared borrows can coexist, so reads + /// holding one across an await can run in parallel; the JS-layer + /// readers-writer lock ensures no exclusive borrow overlaps them. + #[allow(unknown_lints, clippy::unused_async, clippy::unused_async_trait_impl)] + pub async fn lock_shared(&self) -> std::cell::Ref<'_, T> { + self.0.borrow() + } + /// Synchronous shared borrow (browser-only, single-threaded). /// /// Used by `#[wasm_bindgen(getter)]` methods that cannot be async. @@ -98,6 +106,12 @@ impl AsyncCell { pub async fn lock(&self) -> impl core::ops::DerefMut + '_ { self.0.lock().await } + + /// Async shared borrow. The tokio `Mutex` has no shared mode, so reads + /// serialize on Node.js; the borrow semantics still match the browser. + pub async fn lock_shared(&self) -> impl core::ops::Deref + '_ { + self.0.lock().await + } } // NUMERIC TYPES diff --git a/crates/web-client/src/settings.rs b/crates/web-client/src/settings.rs index eae8a99f..870eb607 100644 --- a/crates/web-client/src/settings.rs +++ b/crates/web-client/src/settings.rs @@ -12,8 +12,8 @@ impl WebClient { /// Retrieves the setting value for `key`, or `None` if it hasn't been set. #[wasm_bindgen(js_name = "getSetting")] pub async fn get_setting(&self, key: String) -> Result, JsValue> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| JsValue::from_str("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| JsValue::from_str("Client not initialized"))?; let result: Option> = client.get_setting(key).await.map_err(|err| { js_error_with_context(err, "failed to get setting value from the store") })?; @@ -49,8 +49,8 @@ impl WebClient { /// Retrieves the setting value for `key`, or `None` if it hasn't been set. #[napi(js_name = "getSetting")] pub async fn get_setting(&self, key: String) -> Result>, JsErr> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; client .get_setting(key) .await @@ -86,8 +86,8 @@ impl WebClient { /// Returns all the existing setting keys from the store. #[js_export(js_name = "listSettingKeys")] pub async fn list_setting_keys(&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"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; client .list_setting_keys() .await diff --git a/crates/web-client/src/sync.rs b/crates/web-client/src/sync.rs index a6dcb99a..9a7ed142 100644 --- a/crates/web-client/src/sync.rs +++ b/crates/web-client/src/sync.rs @@ -64,8 +64,8 @@ impl WebClient { #[js_export(js_name = "getSyncHeight")] pub async fn get_sync_height(&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 guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let sync_height = client .get_sync_height() .await diff --git a/crates/web-client/src/tags.rs b/crates/web-client/src/tags.rs index f06ae117..997d7f1c 100644 --- a/crates/web-client/src/tags.rs +++ b/crates/web-client/src/tags.rs @@ -42,8 +42,8 @@ impl WebClient { #[js_export(js_name = "listTags")] pub async fn list_tags(&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"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let tags: Vec = client .get_note_tags() .await diff --git a/crates/web-client/src/transactions.rs b/crates/web-client/src/transactions.rs index 89a1dbb7..7cebf341 100644 --- a/crates/web-client/src/transactions.rs +++ b/crates/web-client/src/transactions.rs @@ -13,8 +13,8 @@ impl WebClient { &self, transaction_filter: TransactionFilter, ) -> Result, JsErr> { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; + let guard = self.get_inner().await; + let client = guard.as_ref().ok_or_else(|| from_str_err("Client not initialized"))?; let transaction_records: Vec = client .get_transactions(transaction_filter.into()) .await