From 6c1f1a6eb83be363058e15845f7fcfd21965b563 Mon Sep 17 00:00:00 2001 From: jamaljsr <1356600+jamaljsr@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:52:21 -0500 Subject: [PATCH 1/4] core: add the wallet_locked error code A start attempt while another tab of the same origin already runs the wallet is an expected condition, not a runtime fault. Give it a stable machine-readable code so transports can tag it and consumers can branch to actionable copy without string-matching SQLite lock messages. --- packages/core/src/base-client.test.ts | 69 ++++++++++++++ packages/core/src/base-client.ts | 91 ++++++++++++++++++- packages/core/src/client.ts | 19 +++- packages/core/src/engine/engine.test.ts | 109 +++++++++++++++++++++++ packages/core/src/engine/engine.ts | 46 +++++++++- packages/core/src/engine/machine.test.ts | 20 ++++- packages/core/src/engine/machine.ts | 37 +++++++- packages/core/src/errors.ts | 16 +++- 8 files changed, 390 insertions(+), 17 deletions(-) diff --git a/packages/core/src/base-client.test.ts b/packages/core/src/base-client.test.ts index b93392ed..6aacb1bd 100644 --- a/packages/core/src/base-client.test.ts +++ b/packages/core/src/base-client.test.ts @@ -39,6 +39,11 @@ describe('BaseWavelengthClient', () => { it('callFacade accepts every portable method and rejects worker/raw verbs', async () => { const client = new FakeClient(); for (const method of FACADE_METHODS) { + // start/stop are guarded (asserted separately below); they run only + // through the typed start()/stop(). + if (method === 'start' || method === 'stop') { + continue; + } await client.callFacade(method); } await assert.rejects( @@ -51,6 +56,32 @@ describe('BaseWavelengthClient', () => { ); }); + it('callFacade rejects the lifecycle verbs so the runtime lock is not bypassed', async () => { + const client = new FakeClient(); + for (const verb of ['start', 'stop'] as const) { + await assert.rejects( + () => client.callFacade(verb), + (err: unknown) => { + assert.ok(err instanceof WavelengthError); + assert.match(err.message, new RegExp(`Call ${verb}\\(\\)`)); + + return true; + }, + ); + // The guard rejects before anything reaches the transport. + assert.equal(client.calls.some((call) => call.method === verb), false); + } + + // The typed methods still dispatch the same verbs through the internal path. + client.responses.set('getInfo', { walletState: 2 }); + await client.start({ network: 'regtest', arkServerAddress: 'h:7070' }); + await client.stop(); + assert.deepEqual( + client.calls.map((call) => call.method), + ['start', 'getInfo', 'stop'], + ); + }); + it('callFacade normalizes raw facade values once in core', async () => { const client = new FakeClient(); client.responses.set('list', { @@ -140,6 +171,44 @@ describe('BaseWavelengthClient', () => { assert.deepEqual(events, [{ type: 'runtimeStopped' }]); }); + it('runs the afterDaemonStopped hook before announcing the stop', async () => { + // A transport releases exclusive resources in this hook, so it has to run + // once the daemon is confirmed down but before a subscriber can react to + // the stop by starting the runtime again. + const order: string[] = []; + class HookedClient extends FakeClient { + protected afterDaemonStopped(): void { + order.push('hook'); + } + } + + const client = new HookedClient(); + client.subscribe(() => order.push('runtimeStopped')); + await client.stop(); + + assert.deepEqual(order, ['hook', 'runtimeStopped']); + }); + + it('does not run afterDaemonStopped when the stop call fails', async () => { + let hookRuns = 0; + class FailingStopClient extends FakeClient { + protected invokeFacade(method: FacadeMethod): Promise { + if (method === 'stop') { + return Promise.reject(new Error('daemon did not acknowledge stop')); + } + + return Promise.resolve({} as T); + } + protected afterDaemonStopped(): void { + hookRuns += 1; + } + } + + const client = new FailingStopClient(); + await assert.rejects(client.stop()); + assert.equal(hookRuns, 0, 'an unacknowledged stop is not a confirmed stop'); + }); + it('dispose clears subscribers', async () => { const client = new FakeClient(); const events: WavelengthEvent[] = []; diff --git a/packages/core/src/base-client.ts b/packages/core/src/base-client.ts index a577be91..12f3ec90 100644 --- a/packages/core/src/base-client.ts +++ b/packages/core/src/base-client.ts @@ -39,7 +39,7 @@ import { toGoUnlockWalletReq, toMobileConfig, } from './facade.ts'; -import { errorMessage } from './errors.ts'; +import { WavelengthError, errorMessage } from './errors.ts'; import type { Entry } from './generated.ts'; import { normalizeEntry, @@ -62,6 +62,12 @@ import { export abstract class BaseWavelengthClient implements WavelengthClient { protected readonly listeners = new Set(); + // Serializes runtime lifecycle operations for transports that route start and + // stop through enqueueLifecycle, so a host's overlapping calls (a double + // click, a stop issued mid-start) run one at a time in invocation order + // instead of interleaving at the transport's exclusive resources. + #lifecycleTail: Promise = Promise.resolve(); + // Transport hooks the concrete clients implement. abstract ready(): Promise; protected abstract invokeFacade( @@ -75,9 +81,32 @@ export abstract class BaseWavelengthClient implements WavelengthClient { /** How this transport's daemon dials the Ark and swap servers. */ protected abstract readonly serverTransport: ServerTransport; + // The raw facade escape hatch. It rejects the lifecycle verbs 'start' and + // 'stop' so they can only run through the typed start()/stop(): those are + // where the web transports take and release the cross-tab runtime lock, and a + // raw call would bypass it. The typed methods dispatch through + // callFacadeInternal, which carries no such guard. async callFacade( method: FacadeMethod, params: unknown = {}, + ): Promise { + if (method === 'start' || method === 'stop') { + throw new WavelengthError( + `Call ${method}() instead of callFacade('${method}'): the ${method} ` + + 'lifecycle verb runs through the typed method, which manages the ' + + 'cross-tab runtime lock.', + ); + } + + return this.callFacadeInternal(method, params); + } + + // The unguarded facade dispatch behind callFacade. The typed lifecycle verbs + // (start/stop) call this directly so callFacade's guard does not reject the + // very verbs they exist to issue. + protected async callFacadeInternal( + method: FacadeMethod, + params: unknown = {}, ): Promise { assertFacadeMethod(method); const raw = await this.invokeFacade(method, params); @@ -94,22 +123,78 @@ export abstract class BaseWavelengthClient implements WavelengthClient { await this.openActivityStream(opts); } + /** + * Runs a runtime lifecycle operation serialized against every other one on + * this client, in invocation order. A transport that owns an exclusive + * resource for the daemon's lifetime (the web transport's cross-tab runtime + * lock) routes its start()/stop() through this so overlapping host calls + * cannot interleave: two starts cannot share one lock lease, and a stop cannot + * release the lock while a start is still opening the databases. A failed + * operation does not poison the queue for the next caller. + */ + protected enqueueLifecycle(op: () => Promise): Promise { + const run = this.#lifecycleTail.then(op, op); + // The tail tracks only completion, never the value or a rejection. + this.#lifecycleTail = run.then( + () => undefined, + () => undefined, + ); + + return run; + } + // start boots the embedded daemon and returns the post-boot WalletInfo. The // facade's start verb resolves nothing useful on its own, so the client // fetches getInfo afterwards; the React provider derives the runtime phase // from it. async start(config: RuntimeConfig): Promise { validateRuntimeConfig(config, this.serverTransport); - await this.callFacade('start', toMobileConfig(config, this.serverTransport)); + await this.callFacadeInternal( + 'start', + toMobileConfig(config, this.serverTransport), + ); return this.getInfo(); } async stop(): Promise { - await this.callFacade('stop'); + // Snapshot any per-session teardown token before the stop is issued, so + // afterDaemonStopped acts on the session this stop belongs to even if a new + // start takes over while the stop RPC is in flight. + const token = this.beforeDaemonStop(); + await this.callFacadeInternal('stop'); + await this.afterDaemonStopped(token); this.emit({ type: 'runtimeStopped' }); } + /** + * Called at the very start of a stop, before the daemon RPC, so a transport + * can capture whatever identifies the session being stopped (the web + * transport's runtime-lock lease). The value is handed back to + * {@link afterDaemonStopped}. Default: nothing to capture. + */ + protected beforeDaemonStop(): unknown { + return undefined; + } + + /** + * Called once the daemon has acknowledged a stop and before subscribers are + * told about it. A transport holding an exclusive resource for the daemon's + * lifetime (the web transport's cross-tab runtime lock, say) releases it + * here: the acknowledgement is the proof the daemon let its storage go, and + * running before the event means a subscriber that restarts the runtime + * cannot race the release. Not called when the stop call fails, because an + * unacknowledged stop is no evidence the daemon is down. + * + * Receives the token {@link beforeDaemonStop} captured, so the release can be + * scoped to the session this stop belongs to and a stop whose start has + * already been superseded frees nothing. Awaited, so a transport whose + * release only completes asynchronously (the Web Locks API frees a lock when + * the holder's promise settles, not when it is asked to) can resolve once the + * resource is genuinely free. + */ + protected afterDaemonStopped(_token?: unknown): void | Promise {} + getInfo(): Promise { return this.callFacade('getInfo'); } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index bfc5b9ff..fecbabe7 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -42,7 +42,12 @@ import type { export interface WavelengthClient { /** Resolves once the runtime assets are loaded and the client is usable. */ ready(): Promise; - /** Starts the embedded daemon with the given config and resolves with its initial info. */ + /** + * Starts the embedded daemon with the given config and resolves with its + * initial info. Calling start() while a session is already running coalesces + * onto it and resolves with the current info; the passed config is ignored, + * so stop() first to start under a different one. + */ start(config: RuntimeConfig): Promise; /** Stops the embedded daemon. */ stop(): Promise; @@ -96,7 +101,17 @@ export interface WavelengthClient { * `broadcast: false` first to preview; `broadcast: true` moves funds. */ sweepWallet(req: SweepWalletRequest): Promise; - /** Invokes a portable daemon facade method with a normalized response. */ + /** + * Invokes a portable daemon facade method with a normalized response. A + * low-level escape hatch for facade verbs the typed methods do not cover; + * prefer the typed methods wherever they exist. + * + * Rejects the lifecycle verbs `'start'` and `'stop'`: use the typed + * {@link start} and {@link stop} instead. On the web transports those verbs + * take and release the cross-tab runtime lock, so allowing a raw + * `callFacade('start')` or `callFacade('stop')` would bypass it, defeat the + * multi-tab guard, and could strand the lock until the page reloads. + */ callFacade(method: FacadeMethod, params?: unknown): Promise; /** Reports whether the embedded daemon is running. */ isRunning(): Promise; diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 7f987a31..e97b60f0 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -26,6 +26,115 @@ describe('engine lifecycle', () => { engine.dispose(); }); + // A runtime that dies mid-start (the wallet database held by another tab, + // say) rejects the start and announces its stop, and which lands first + // depends on transport timing the engine cannot control: a synchronous emit + // beats the rejection, an emit deferred behind an async lock release loses + // to it. The failure has to win either way: it is what the host renders, + // and a stopped phase would hide it behind a generic screen with no cause. + for (const stopTiming of ['before the rejection', 'after the rejection'] as const) { + it(`keeps a start failure visible when the runtime stop lands ${stopTiming}`, async () => { + const client = new FakeWavelengthClient(); + const engine = createWalletEngine({ client }); + client.resolveReady(); + await flush(); + + const locked = Object.assign(new Error('database is locked'), { + code: 'wallet_locked', + }); + client.impl('start', () => { + if (stopTiming === 'before the rejection') { + client.emit({ type: 'runtimeStopped' }); + } else { + setTimeout(() => client.emit({ type: 'runtimeStopped' }), 1); + } + throw locked; + }); + + await engine + .start({ network: 'regtest', arkServerAddress: 'h:7070' }) + .catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 5)); + + const snap = engine.getSnapshot(); + assert.equal(snap.phase, 'error'); + assert.equal((snap.error as { code?: string } | null)?.code, 'wallet_locked'); + engine.dispose(); + }); + } + + it('lets a deliberate stop outlive the start it interrupted', async () => { + // Stopping while a start is still in flight is allowed, and the stop owns + // the outcome: the abandoned start's rejection must not reopen it as an + // error and put the host back on a failure screen it already left. + const client = new FakeWavelengthClient(); + const engine = createWalletEngine({ client }); + client.resolveReady(); + await flush(); + + let rejectStart!: (reason?: unknown) => void; + client.impl( + 'start', + () => + new Promise((_resolve, reject) => { + rejectStart = reject; + }), + ); + + const starting = engine + .start({ network: 'regtest', arkServerAddress: 'h:7070' }) + .catch(() => undefined); + await flush(); + await engine.stop(); + await flush(); + assert.equal(engine.getSnapshot().phase, 'stopped'); + + rejectStart(new Error('stale start gave up')); + await starting; + await flush(); + + assert.equal(engine.getSnapshot().phase, 'stopped'); + engine.dispose(); + }); + + it('does not repopulate info when a stop lands mid-start and the start then succeeds', async () => { + // The success-path sibling of the deliberate-stop test above. A stop the + // host issued while start() was in flight owns the outcome, so a start that + // then resolves must not dispatch its info back into a snapshot the host + // already asked to tear down. + const client = new FakeWavelengthClient(); + client.info = readyInfo; + const engine = createWalletEngine({ client }); + client.resolveReady(); + await flush(); + + let resolveStart!: (info: WalletInfo) => void; + client.impl( + 'start', + () => + new Promise((resolve) => { + resolveStart = resolve; + }), + ); + + const starting = engine + .start({ network: 'regtest', arkServerAddress: 'h:7070' }) + .catch(() => undefined); + await flush(); + await engine.stop(); + await flush(); + assert.equal(engine.getSnapshot().phase, 'stopped'); + + resolveStart(readyInfo); + await starting; + await flush(); + + const snap = engine.getSnapshot(); + assert.equal(snap.phase, 'stopped'); + assert.equal(snap.info, null); + engine.dispose(); + }); + it('surfaces a rejected ready() as the error phase with an Error', async () => { const client = new FakeWavelengthClient(); const engine = createWalletEngine({ client }); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 8438e901..d4d8422b 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -109,8 +109,13 @@ export interface WalletEngine { subscribe(listener: () => void): () => void; /** * Starts the runtime. Falls back to the engine's configured config when - * called without an argument; throws if neither exists. A failure moves the - * phase to 'error' and rejects. + * called without an argument; throws if neither exists. On success it + * dispatches the info and best-effort refreshes; a failure moves the phase to + * 'error' and rejects. Either way, a stop() the host issued while the start + * was in flight takes precedence: that stop governs the phase, and the start + * leaves the snapshot to it (no info dispatch and no refresh on success, no + * error surfaced on failure). The promise still settles the ordinary way, + * resolving with the info or rejecting. */ start(config?: RuntimeConfig): Promise; /** Stops the runtime and clears the in-memory snapshot (info, balance, activity, error); persisted wallet data is untouched. A failure moves the phase to 'error'. */ @@ -184,6 +189,9 @@ class WavelengthEngine implements WalletEngine { readonly #store = new SnapshotStore(); readonly #config: RuntimeConfig | undefined; #disposed = false; + // Counts stops the host asked for, so a start that rejects afterwards can + // tell an intentional shutdown from a runtime that died underneath it. + #deliberateStops = 0; #unsubscribe: (() => void) | undefined; // Background refreshes are serialized: two concurrent reads would race on @@ -297,9 +305,20 @@ class WavelengthEngine implements WalletEngine { throw new Error('cannot start while the runtime is stopping'); } this.#refreshFailures = 0; + const stopsBefore = this.#deliberateStops; this.#dispatch({ type: 'startRequested' }, { error: null }); try { const info = await this.client.start(cfg); + // A stop the host issued while this start was in flight owns the outcome, + // the same precedence the catch below applies to a rejected start. The + // phase has already moved to stopping or stopped; dispatching infoReceived + // would ignore-transition through the machine but still apply the { info } + // patch, repopulating a snapshot the host asked to tear down (the hazard + // #fetchAll guards on its sibling path). Hand the caller the info the RPC + // returned without touching the snapshot, and skip the refresh with it. + if (this.#deliberateStops !== stopsBefore) { + return info; + } this.#dispatch({ type: 'infoReceived', info }, { info }); try { await this.refresh(); @@ -310,6 +329,28 @@ class WavelengthEngine implements WalletEngine { return info; } catch (err) { const error = toError(err); + // A stop the host asked for while this start was in flight owns the + // outcome. Reporting the abandoned start's failure would drag the + // phase back off 'stopped' and show an error the host already moved + // past. A runtime that died on its own is different: it reaches + // 'stopped' without a stop request, and the failure still has to + // surface (see the machine's startFailed transition). + if (this.#deliberateStops !== stopsBefore) { + // The phase intentionally stays 'stopped', so the cause is dropped + // from the snapshot; log it so it is still recoverable. The promise + // rejection below carries it too. + const logs = [ + ...this.getSnapshot().logs, + { + level: 'debug' as const, + message: + 'start failed but a deliberate stop took precedence; error ' + + `kept off the phase: ${error.message}`, + }, + ].slice(-MAX_LOGS); + this.#store.update({ logs }); + throw error; + } this.#dispatch({ type: 'startFailed' }, { error }); throw error; } @@ -317,6 +358,7 @@ class WavelengthEngine implements WalletEngine { async stop(): Promise { this.#assertNotDisposed(); + this.#deliberateStops += 1; this.#dispatch({ type: 'stopRequested' }); try { await this.client.stop(); diff --git a/packages/core/src/engine/machine.test.ts b/packages/core/src/engine/machine.test.ts index fdca0ff2..564da762 100644 --- a/packages/core/src/engine/machine.test.ts +++ b/packages/core/src/engine/machine.test.ts @@ -17,6 +17,16 @@ const TABLE: Array<[WalletEngineEvent, RuntimePhase, RuntimePhase]> = [ [{ type: 'runtimeReady' }, 'loading', 'runtimeReady'], [{ type: 'runtimeFailed' }, 'loading', 'error'], [{ type: 'startFailed' }, 'starting', 'error'], + // A runtime that dies mid-start announces its stop before the start + // rejection finishes propagating, so a start failure has to be able to + // claim the phase back from 'stopped'; the failure is what the host needs + // to show, and 'stopped' would hide it behind a generic screen. + [{ type: 'startFailed' }, 'stopped', 'error'], + // The loading-phase dual: a runtime that dies before ready() settles + // announces its stop (loading -> stopped) before the ready() rejection + // dispatches runtimeFailed, so runtimeFailed must reclaim 'stopped' too or the + // boot error is hidden behind a stopped screen. + [{ type: 'runtimeFailed' }, 'stopped', 'error'], [{ type: 'infoReceived', info: readyInfo }, 'starting', 'ready'], [{ type: 'infoReceived', info: lockedInfo }, 'starting', 'locked'], [{ type: 'infoReceived', info: readyInfo }, 'syncing', 'ready'], @@ -51,9 +61,15 @@ describe('transition table', () => { }); } - it('runtimeStopped wins from every phase', () => { + it('runtimeStopped wins from every phase except error', () => { for (const phase of ALL_PHASES) { - assert.equal(transition(phase, { type: 'runtimeStopped' }), 'stopped'); + // 'error' outranks the stop that caused it: a runtime dying because a + // start failed announces both, in an order that depends on transport + // timing, and the host must end on the failure either way. Making the + // machine preserve 'error' is what frees the transports from ordering + // their rejection against their stop event. + const expected = phase === 'error' ? 'error' : 'stopped'; + assert.equal(transition(phase, { type: 'runtimeStopped' }), expected); } }); diff --git a/packages/core/src/engine/machine.ts b/packages/core/src/engine/machine.ts index fcdafc51..2891d5bc 100644 --- a/packages/core/src/engine/machine.ts +++ b/packages/core/src/engine/machine.ts @@ -39,18 +39,47 @@ export function transition( return phase === 'loading' ? 'runtimeReady' : phase; case 'runtimeFailed': - return phase === 'loading' ? 'error' : phase; + // 'stopped' is accepted alongside 'loading' for the same reason startFailed + // accepts it: a runtime that dies before ready() settles announces its stop + // (moving loading -> stopped) before the ready() rejection dispatches + // runtimeFailed. Without this the boot failure would be swallowed by the + // stop it caused, leaving the host on a generic stopped screen. Unlike a + // start failure, a runtime that failed to load is a genuine error worth + // surfacing even if a stop coincided, so this needs no deliberate-stop + // guard: a clean stop during loading never produces a runtimeFailed. + return phase === 'loading' || phase === 'stopped' ? 'error' : phase; case 'runtimeStopped': // A clean stop or a runtime crash; either way the engine is gone, so - // this always wins. - return 'stopped'; + // this wins over every phase except 'error'. An error outranks the stop + // that caused it: a runtime dying because a start failed announces both + // the failure and the stop, in an order that depends on transport timing + // (a synchronous emit lands before the rejection, one deferred behind an + // async lock release lands after), and the host must end on the failure + // either way. Preserving 'error' here is what frees transports from + // ordering their rejection against their stop event. A clean stop never + // passes through 'error', so it is unaffected, and startRequested still + // exits 'error', so retry flows are too. + // + // This preserves 'error' for every error, not only a start failure: a + // live-runtime error (a lost activity stream, refresh-budget exhaustion) + // followed by a runtime death also stays on 'error' rather than moving to + // 'stopped'. That is intentional: an error screen is a safe terminal state + // the host recovers from with a retry (startRequested exits it), and + // scoping the rule to start failures would mean tracking which error is in + // flight for a difference the user does not feel. + return phase === 'error' ? phase : 'stopped'; case 'startRequested': return phase === 'stopping' ? phase : 'starting'; case 'startFailed': - return phase === 'starting' ? 'error' : phase; + // 'stopped' is accepted alongside 'starting' because a runtime that dies + // mid-start announces the stop before the start rejection has finished + // propagating. Without this the failure would be swallowed by the stop + // that it caused, leaving the host on a generic stopped screen with no + // way to show (for instance) that the wallet is open in another tab. + return phase === 'starting' || phase === 'stopped' ? 'error' : phase; case 'infoReceived': switch (phase) { diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index d4a4a48f..1ea8f99e 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,16 +1,24 @@ /** * A stable, machine-readable classification on {@link WavelengthError} so consumers * can branch without string-matching the message. The SDK's own failures use the - * named codes; daemon-originated errors currently fall back to `'wavelength_error'` - * (a richer daemon-code mapping is planned). The `(string & {})` arm keeps the - * union open for forward compatibility while still offering autocomplete on the - * known codes. + * named codes; most daemon-originated errors fall back to `'wavelength_error'` + * (a richer daemon-code mapping is planned), except storage-contention failures + * on `start()`, which the web transports map to `'wallet_locked'`. `'wallet_locked'` means the wallet + * runtime is already open in another tab or window of the same origin: the + * daemon's OPFS databases are exclusive, so a start attempt fails fast with this + * code until the other tab stops the runtime or closes. `'runtime_lock_unavailable'` + * means the browser refused or dropped the lock request itself (for example + * while the document is shutting down), which says nothing about another tab. + * The `(string & {})` arm keeps the union open for forward + * compatibility while still offering autocomplete on the known codes. */ export type WavelengthErrorCode = | 'wavelength_error' | 'runtime_not_ready' | 'asset_load_failed' | 'worker_error' + | 'wallet_locked' + | 'runtime_lock_unavailable' | 'unsupported_facade_method' | 'invalid_cursor' | 'invalid_config' From fb19544cb793ee8b7a3ffa24f2d14779084b955c Mon Sep 17 00:00:00 2001 From: jamaljsr <1356600+jamaljsr@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:52:34 -0500 Subject: [PATCH 2/4] web: fail fast with wallet_locked when another tab runs the wallet The daemon's OPFS SQLite handles are exclusive per origin, so a second tab that starts the runtime used to boot the full wasm stack only to die deep inside migrations with a raw SQLITE_CANTOPEN trace (issue #48). Guard the start verb with a held Web Lock instead: the first tab to start the runtime takes the lock, and a second tab's start() now rejects with the typed wallet_locked error before the daemon opens any OPFS database. Config validation runs before the acquisition, and start() rechecks disposal after it, so neither a config that can never reach the daemon nor a client disposed mid-acquire takes the lock or boots a runtime. One rule governs the release: the lock is handed back only once the daemon is provably down. A clean stop is the graceful proof and keeps the worker for a fast restart, releasing through the core client's afterDaemonStopped hook. Every other end proves it by force. The worker transport terminates the worker, which takes its nested SQLite worker with it and frees the OPFS handles, then releases; that single teardown path (killWorker) covers a failed start, a fatal runtime exit, a worker error, and disposal, and a retry gets a fresh worker rather than a dead one. The main-thread transport cannot terminate a Go runtime running on the page, so it decides by probing the runtime rather than classifying error codes: if wavewalletdkCall never became callable the runtime never opened a database and the lock is released, otherwise it asks the daemon to stop and retains the lock unless that stop is acknowledged. It also fails fast on a restart, since a main-thread runtime that exited cannot boot another in the same page. Terminal-state handling is defined once, in the engine's phase machine, so the transports do not have to order their events. A runtime that dies because a start failed announces both the failure and the stop, and which lands first depends on transport timing; the machine lets an error phase outrank the runtimeStopped that follows, so the actionable failure always survives to the host. Rounds of narrower fixes had alternated the emit order and kept breaking one case to serve the other; making the machine authoritative retires that whole class. runtimeStopped fires on every dead-runtime path, worker failed start included, so a host keying liveness off it is never left thinking a killed runtime is still up, and request() rejects once the worker is gone so a call posted to it cannot hang forever. Browsers without the Web Locks API skip the pre-check and get a one-time warning through the host's log channel and the console; for that path, and for races between two starting tabs, both clients also classify raw locked-database failure messages onto the same wallet_locked code at the worker fatal, worker RPC, and facade error boundaries. The classifier matches cross-context contention specifically, so neither the daemon's generic fail-closed wording nor the SQLite worker's own duplicate-open guard sends a sole tab hunting for a window that does not exist, and a storage failure that looks lock-shaped but does not classify is warned so the daemon rewording its errors cannot silently disable the guidance. --- packages/web/src/clients/main.ts | 250 ++- packages/web/src/clients/transport.test.ts | 1926 ++++++++++++++++++++ packages/web/src/clients/worker.ts | 367 +++- packages/web/src/runtime-lock.test.ts | 323 ++++ packages/web/src/runtime-lock.ts | 373 ++++ packages/web/src/runtime.ts | 17 + packages/web/src/wavewalletdk-worker.js | 34 +- 7 files changed, 3244 insertions(+), 46 deletions(-) create mode 100644 packages/web/src/runtime-lock.test.ts create mode 100644 packages/web/src/runtime-lock.ts diff --git a/packages/web/src/clients/main.ts b/packages/web/src/clients/main.ts index 203cd4eb..cb0ac638 100644 --- a/packages/web/src/clients/main.ts +++ b/packages/web/src/clients/main.ts @@ -1,10 +1,13 @@ import { BaseWavelengthClient, WavelengthError, + validateRuntimeConfig, } from '@lightninglabs/wavelength-core'; import type { ActivityStreamOptions, FacadeMethod, + RuntimeConfig, + WalletInfo, } from '@lightninglabs/wavelength-core'; import { RUNTIME_ASSETS } from '../runtime-manifest.ts'; import type { WebClientOptions } from '../index.ts'; @@ -15,6 +18,13 @@ import { waitForReadyEvent, wavewalletdkCall, } from '../runtime.ts'; +import { + RuntimeLock, + NO_RUNTIME_LEASE, + isNearMissLockMessage, + isWalletLockedMessage, +} from '../runtime-lock.ts'; +import type { RuntimeLockLease } from '../runtime-lock.ts'; import { ActivityHandle, debugTs, errorMessage } from '../util.ts'; type ActivityOpen = { @@ -34,6 +44,19 @@ export class MainThreadWavelengthClient extends BaseWavelengthClient { private activityHandle: ActivityHandle | null = null; private activityOpen: ActivityOpen | null = null; private activityGeneration = 0; + private readonly lock = new RuntimeLock({ + onWarn: (message) => + this.emit({ type: 'log', payload: { level: 'warn', message } }), + }); + // The lease held by the running session, threaded into every release so a + // release only frees the lock when this session still owns it. + private lease: RuntimeLockLease = NO_RUNTIME_LEASE; + // Set once the wasm runtime is known to have exited. Calls made after that + // cannot be answered, so anything that would wait on one has to check. + private runtimeExited = false; + // Set by dispose(). A disposed client must not boot a daemon it can no longer + // drive, so start() checks this after acquiring the lock. + private disposed = false; private readonly runtimeBaseUrl: string | undefined; private readonly debug: boolean; private readonly onRuntimeReady = () => this.emit({ type: 'runtimeReady' }); @@ -51,13 +74,161 @@ export class MainThreadWavelengthClient extends BaseWavelengthClient { dispose(): void { super.dispose(); + this.disposed = true; globalThis.removeEventListener('wavewalletdk-ready', this.onRuntimeReady); + // The runtime lock is deliberately not released here. Disposing drops this + // client's listeners but cannot stop a Go runtime already running on the + // page, so the daemon may still own the wallet databases; releasing would + // let another tab open them underneath it. A clean stop(), the runtime + // exiting, or the page going away are the paths that prove it is down. } ready(): Promise { return this.ensureLoaded(); } + // start and stop are serialized against each other so a host's overlapping + // calls cannot interleave at the lock: two starts would otherwise share one + // lease, and a stop could release the lock while a start is still opening the + // databases. + start(config: RuntimeConfig): Promise { + return this.enqueueLifecycle(() => this.startLocked(config)); + } + + stop(): Promise { + return this.enqueueLifecycle(() => + // A stop after the runtime has exited has nothing to stop: bootExit + // already released the lock and announced the stop. Own the shutdown as + // satisfied rather than call super.stop(), which would run + // wavewalletdkCall('stop') against a dead bridge (Go leaves it installed + // after exit) and hang or reject instead of resolving. Mirrors the worker + // transport's stop() guard. + this.runtimeExited ? Promise.resolve() : super.stop(), + ); + } + + // startLocked is the serialized body of start(). It is the verb that opens the + // daemon's exclusive OPFS databases, so it is where the cross-tab runtime lock + // is taken: a second tab fails fast with wallet_locked instead of tripping + // over SQLite handles held by the first. Validation runs first so a config + // that can never reach the daemon does not take the lock at all. + private async startLocked(config: RuntimeConfig): Promise { + validateRuntimeConfig(config, this.serverTransport); + if (this.disposed) { + throw new WavelengthError('Wavelength client disposed', 'wavelength_error'); + } + // A Go runtime that exited cannot be restarted on the page (unlike the + // worker transport, which respawns its worker), so fail fast rather than + // acquiring a lock for a bridge that answers nothing. + if (this.runtimeExited) { + throw new WavelengthError( + 'The Wavelength main-thread runtime has exited and cannot be ' + + 'restarted in this page; reload the page to start again', + 'runtime_not_ready', + ); + } + // A redundant start on an already-running session (the double click + // enqueueLifecycle serializes, or any host that starts twice) coalesces + // rather than re-invoking the daemon. By here the lock is held and the + // runtime is up, so re-running super.start() would risk a daemon "already + // started" or a transient getInfo() rejection, whose recovery stop would + // tear the live session down and free the cross-tab lock for other tabs. + // Return the current info instead, leaving the session and its lock + // intact. To start under a different config, stop() first. + if (this.lock.held) { + return this.getInfo(); + } + this.lease = await this.lock.acquire(); + // acquire() yields even when it resolves immediately (no Web Locks), so a + // dispose() issued in the same turn can land here. Bail before booting a + // daemon into a disposed client. super.start() has not run, so no daemon + // opened a database whatever the runtime's load state, and the lock this + // attempt took is released unconditionally rather than stranded for the + // life of the page. + if (this.disposed) { + await this.lock.releaseAndSettle(this.lease); + + throw new WavelengthError('Wavelength client disposed', 'wavelength_error'); + } + // The runtime can also exit during that same acquire window (a go.run() + // trap while this is suspended). bootExit fires with this.lease still + // NO_RUNTIME_LEASE, so its release is a no-op; release the grant we now + // hold rather than strand the origin behind a dead runtime. The catch below + // cannot cover this: Go leaves wavewalletdkCall installed after it exits, so + // its function-probe stays true and the runtimeExited branch skips the + // recovery stop, leaking the lease. Mirrors the worker transport's + // post-acquire runtimeExited guard. + if (this.runtimeExited) { + await this.lock.releaseAndSettle(this.lease); + + throw new WavelengthError( + 'The Wavelength main-thread runtime exited during start', + 'runtime_not_ready', + ); + } + + try { + return await super.start(config); + } catch (err) { + // super.start() ran, so a callable runtime may have opened the databases. + // Whether to hand the lock back is decided by probing the runtime, not by + // classifying the error: classifying by code cannot catch every shape (a + // missing Go constructor, a fetch or instantiate failure with no code at + // all), and a shape it misses would strand the lock. If wavewalletdkCall + // never became a function, the runtime never became callable, so nothing + // can have opened a database and releasing is safe. This probe is the + // main-thread stand-in for the worker transport's terminate teardown. + // + // A callable runtime may hold the databases, so ask the daemon to stop + // rather than assume it never ran. A stop it acknowledges releases the + // lock through afterDaemonStopped; one it does not leaves the lock held, + // because handing it back unproven is what lets a second daemon open the + // same databases. The browser reclaims it when this tab goes away. A + // runtime that already exited is skipped: its own exit handler released + // the lock, and it answers no further calls. + if (typeof wavewalletdkCall() !== 'function') { + await this.lock.releaseAndSettle(this.lease); + } else if (!this.runtimeExited) { + // super.stop(), not this.stop(): startLocked already runs inside the + // lifecycle queue, so the enqueuing stop() override would wait on this + // very operation and deadlock. This recovery stop is part of the start. + await super.stop().catch((stopErr) => { + // The lock is correctly retained here (the stop was not + // acknowledged, so the daemon may still hold the databases), but this + // is the one path that leaves the whole origin wallet_locked with no + // other tab present, and only a page reload clears it. That is an + // error, not a filterable warning like the recoverable near-miss + // drift logs: surface it at error level so it is not lost. + this.emit({ + type: 'log', + payload: { + level: 'error', + message: + 'the wallet runtime lock is retained because a recovery stop ' + + `failed after a failed start: ${errorMessage(stopErr)}`, + }, + }); + }); + } + + throw err; + } + } + + protected beforeDaemonStop(): unknown { + // Capture the running session's lease before the stop RPC, so the release + // frees this session's lock even if a new start takes over meanwhile. + return this.lease; + } + + // Called once the daemon acknowledges a stop, which is the proof its + // databases are closed and another tab may take the wallet over. Releases the + // lease this stop captured, so a stop whose start has already been superseded + // frees nothing. + protected async afterDaemonStopped(token?: unknown): Promise { + await this.lock.releaseAndSettle(token as RuntimeLockLease); + } + protected async invokeFacade( method: FacadeMethod, params: unknown = {}, @@ -86,12 +257,44 @@ export class MainThreadWavelengthClient extends BaseWavelengthClient { return result; } catch (err) { - throw new WavelengthError(errorMessage(err), 'wavelength_error', { - cause: err, - }); + const message = errorMessage(err); + this.logNearMissLock(message); + throw new WavelengthError( + message, + // Gated to the start verb: cross-context OPFS contention only happens + // when a runtime opens the databases, so a matching message on any + // other verb is same-runtime transient contention, not another tab. + method === 'start' && isWalletLockedMessage(message) + ? 'wallet_locked' + : 'wavelength_error', + { cause: err }, + ); } } + // logNearMissLock surfaces a failure that mentions storage or locking but did + // not classify as wallet_locked. If the daemon ever rewords a contention + // error, this is what makes the silent downgrade visible instead of leaving + // the host to wonder why the multi-tab advice stopped appearing. It warns + // rather than whispers: this is the only drift signal there is, and a level + // most hosts filter out cannot do that job. isNearMissLockMessage already + // excludes routine wallet-unlock prose, so the false-positive cost is a warn + // on genuine storage failures a host is surfacing anyway. + private logNearMissLock(message: string): void { + if (!isNearMissLockMessage(message)) { + return; + } + const warning = + 'a runtime failure mentioned storage or locking but was not ' + + `classified as wallet_locked: ${message}`; + // Both channels, matching warnNoWebLocks: this near-miss is the only signal + // that the daemon reworded a contention string, and a consumer driving the + // bare client may have no log subscriber, so it is too important to lose to + // an empty listener set. + this.emit({ type: 'log', payload: { level: 'warn', message: warning } }); + console.warn(warning); + } + // startActivity opens the facade's pull-based activity stream and pumps each // entry to subscribers as an 'activity' event. The old bridge pushed a // 'wavewalletdk-activity' DOM event; the wasm bridge hands back a @@ -152,7 +355,21 @@ export class MainThreadWavelengthClient extends BaseWavelengthClient { this.activityGeneration += 1; const handle = this.activityHandle; this.activityHandle = null; - handle?.close(); + // close() is a bridge callback that can throw; a throw here (called from + // dispose() and the engine's process reconcile) would surface as an + // uncaught exception mid-teardown, so warn instead, matching the worker + // transport's $stopActivity handling. + try { + handle?.close(); + } catch (err) { + this.emit({ + type: 'log', + payload: { + level: 'warn', + message: `failed to close the activity stream: ${errorMessage(err)}`, + }, + }); + } } // pumpActivity drains the subscription handle until it ends (next() resolves @@ -234,19 +451,34 @@ export class MainThreadWavelengthClient extends BaseWavelengthClient { ); }, (err) => { - throw new WavelengthError(errorMessage(err), 'runtime_not_ready', { - cause: err, - }); + const message = errorMessage(err); + throw new WavelengthError( + message, + isWalletLockedMessage(message) ? 'wallet_locked' : 'runtime_not_ready', + { cause: err }, + ); }, ); // A runtime that exits after ready is surfaced as an error log instead, so - // the rejection is always handled (never unhandled) either way. - bootExit.catch((err) => { + // the rejection is always handled (never unhandled) either way. A dead + // runtime no longer holds the wallet databases, so the runtime lock is + // released either way to let another tab take over. The release is settled + // before runtimeStopped so a subscriber that restarts on that event finds + // the lock already free, matching the worker transport's fatal path. + bootExit.catch(async (err) => { + this.runtimeExited = true; + await this.lock.releaseAndSettle(this.lease); if (ready) { this.emit({ type: 'log', payload: { level: 'error', message: errorMessage(err) }, }); + // Tell subscribers the runtime is gone, the way the worker transport + // does from its fatal handler. Without this a host would keep showing + // a live wallet backed by a runtime that has exited. A runtime that + // dies before ready is left alone: ready() rejects on its own and the + // host reports that failure instead. + this.emit({ type: 'runtimeStopped' }); } }); diff --git a/packages/web/src/clients/transport.test.ts b/packages/web/src/clients/transport.test.ts index a9076db4..ddcfad08 100644 --- a/packages/web/src/clients/transport.test.ts +++ b/packages/web/src/clients/transport.test.ts @@ -73,6 +73,22 @@ class FakeWorker { terminate(): void {} } +// AutoReplyWorker acknowledges every request with an empty ok result, so +// multi-step verbs (start = 'start' + 'getInfo') resolve without scripting +// each response. +class AutoReplyWorker extends FakeWorker { + postMessage(message: WorkerMessage): void { + this.messages.push(message); + if (typeof message.id === 'number') { + queueMicrotask(() => + this.onmessage?.({ + data: { id: message.id, ok: true, result: {} }, + } as MessageEvent), + ); + } + } +} + describe('activity transport requests', () => { it('forwards complete activity options in main and worker modes', async () => { const { MainThreadWavelengthClient } = await import('./main.ts'); @@ -363,6 +379,38 @@ describe('activity transport requests', () => { } }); + it('does not warn when stopActivity runs after the worker runtime exited', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: FakeWorker }); + + try { + const warns: string[] = []; + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + client.subscribe((event) => { + if (event.type === 'log' && event.payload.level === 'warn') { + warns.push(event.payload.message); + } + }); + // The runtime has exited (a crash set this). The engine reconciles + // processes on runtimeStopped and calls stopActivity(); it must own the + // close as satisfied rather than reject against the dead-runtime request + // guard and emit a spurious warn on every crash. + (client as unknown as { runtimeExited: boolean }).runtimeExited = true; + client.stopActivity(); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepEqual(warns, [], 'a stop after the runtime exited must not warn'); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + value: savedWorker, + }); + } + }); + it('coalesces concurrent main-thread activity opens', async () => { const { MainThreadWavelengthClient } = await import('./main.ts'); const savedCall = (globalThis as { wavewalletdkCall?: unknown }).wavewalletdkCall; @@ -475,6 +523,1884 @@ describe('activity transport requests', () => { } }); + it('fails a worker start() fast with wallet_locked when another tab holds the runtime', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: AutoReplyWorker }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request: ( + _name: string, + _options: unknown, + callback: (lock: unknown) => unknown, + ) => Promise.resolve(callback(null)), + }, + }, + }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + await assert.rejects( + client.start({ network: 'regtest', arkServerAddress: 'h:7070' }), + (err: unknown) => { + assert.equal((err as { code?: string }).code, 'wallet_locked'); + + return true; + }, + ); + assert.ok( + !FakeWorker.latest!.messages.some((message) => message.method === 'start'), + 'a locked start must not reach the worker', + ); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + value: savedWorker, + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: savedNavigator, + }); + } + }); + + it('holds the runtime lock across worker start() and releases it on stop()', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + const requests: string[] = []; + let released = false; + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: AutoReplyWorker }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request: ( + name: string, + _options: unknown, + callback: (lock: unknown) => unknown, + ) => { + requests.push(name); + + return Promise.resolve(callback({ name })).then(() => { + released = true; + }); + }, + }, + }, + }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + await client.start({ network: 'regtest', arkServerAddress: 'h:7070' }); + assert.equal(requests.length, 1); + assert.equal(released, false); + + await client.stop(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(released, true); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + value: savedWorker, + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: savedNavigator, + }); + } + }); + + it('serializes an overlapping stop behind an in-flight start', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + const locks = grantingLocks(); + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: FakeWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: locks.navigator }); + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + const replyTo = (worker: FakeWorker, method: string) => { + const req = worker.messages.find((m) => m.method === method); + worker.onmessage?.({ + data: { id: req?.id, ok: true, result: {} }, + } as MessageEvent); + }; + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + const worker = FakeWorker.latest!; + + // start() takes the lock and posts 'start', then awaits the daemon. + const started = client.start({ network: 'regtest', arkServerAddress: 'h:7070' }); + await flush(); + assert.ok(worker.messages.some((m) => m.method === 'start')); + + // stop() issued while the start is still in flight must queue behind it, + // not fire its own 'stop' RPC and release the lock mid-start. + const stopped = client.stop(); + await flush(); + assert.ok( + !worker.messages.some((m) => m.method === 'stop'), + 'an overlapping stop must wait for the in-flight start to finish', + ); + + // Let the start finish (its 'start' RPC, then the getInfo it triggers). + replyTo(worker, 'start'); + await flush(); + replyTo(worker, 'getInfo'); + await started; + await flush(); + + // Only now does the queued stop run. + assert.ok( + worker.messages.some((m) => m.method === 'stop'), + 'the stop runs once the start has completed', + ); + replyTo(worker, 'stop'); + await stopped; + + const order = worker.messages + .filter((m) => ['start', 'getInfo', 'stop'].includes(m.method ?? '')) + .map((m) => m.method); + assert.deepEqual(order, ['start', 'getInfo', 'stop']); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: savedWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: savedNavigator }); + } + }); + + it('releases the runtime lock when the worker errors during the acquire window', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + // A lock whose grant is deferred until grantLock(), so a worker onerror can + // land while start() is suspended on the acquire. + let grantLock!: () => void; + let released = false; + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: FakeWorker }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request: ( + name: string, + _options: unknown, + callback: (lock: unknown) => unknown, + ) => + new Promise((resolveRequest) => { + grantLock = () => + resolveRequest( + Promise.resolve(callback({ name })).then(() => { + released = true; + }), + ); + }), + }, + }, + }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + const worker = FakeWorker.latest!; + const started = client + .start({ network: 'regtest', arkServerAddress: 'h:7070' }) + .then( + () => 'resolved', + (err: unknown) => (err as { code?: string }).code, + ); + + // start() is suspended on the deferred acquire; the worker dies now. + await new Promise((resolve) => setTimeout(resolve, 0)); + worker.onerror?.({ message: 'worker eval trap' } as ErrorEvent); + // The grant lands after the death: the acquire resolves into a runtime + // that is already gone, so the start must release the lock it just took + // rather than strand the whole origin behind a dead runtime. + grantLock(); + + assert.equal(await started, 'worker_error'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + released, + true, + 'a worker death during the acquire window must not strand the lock', + ); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: savedWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: savedNavigator }); + } + }); + + it('resolves a stop queued behind a failed start instead of rejecting', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + const locks = grantingLocks(); + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: RejectingWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: locks.navigator }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + // The start fails and tears the worker down; the stop queued behind it + // must own the shutdown (the runtime is already gone) rather than reject + // against a dead worker and drag the engine onto an error screen. + const started = client + .start({ network: 'regtest', arkServerAddress: 'h:7070' }) + .then(() => 'resolved', () => 'start-failed'); + const stopped = client + .stop() + .then(() => 'resolved', (err: unknown) => `stop-failed:${(err as { code?: string }).code}`); + + assert.equal(await started, 'start-failed'); + assert.equal(await stopped, 'resolved'); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: savedWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: savedNavigator }); + } + }); + + it('coalesces a redundant worker start instead of tearing the session down', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + const locks = grantingLocks(); + // Acks the first start and every getInfo, but rejects a second start the + // way a daemon that is already running would. The coalesce guard must never + // post that second start: it returns the live session's info instead. + class SecondStartRejectsWorker extends FakeWorker { + starts = 0; + postMessage(message: WorkerMessage): void { + this.messages.push(message); + if (typeof message.id !== 'number') { + return; + } + const rejectStart = message.method === 'start' && ++this.starts > 1; + queueMicrotask(() => + this.onmessage?.({ + data: rejectStart + ? { id: message.id, ok: false, error: 'daemon already started' } + : { id: message.id, ok: true, result: {} }, + } as MessageEvent), + ); + } + } + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + value: SecondStartRejectsWorker, + }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: locks.navigator }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + await client.start({ network: 'regtest', arkServerAddress: 'h:7070' }); + assert.equal(locks.state.released, false); + + // A second start on the live session must resolve without re-invoking the + // daemon or freeing the lock. Before the coalesce guard this posted a + // second 'start' RPC, whose rejection killed the healthy worker and + // released the origin lock for other tabs. + const info = await client.start({ network: 'regtest', arkServerAddress: 'h:7070' }); + assert.ok(info, 'a redundant start resolves with the live session info'); + assert.equal( + locks.state.released, + false, + 'a redundant start must not free the running session lock', + ); + const startRPCs = FakeWorker.latest!.messages.filter((m) => m.method === 'start'); + assert.equal(startRPCs.length, 1, 'a redundant start must not re-invoke the daemon'); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: savedWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: savedNavigator }); + } + }); + + it('coalesces a redundant main-thread start instead of tearing the session down', async () => { + const { MainThreadWavelengthClient } = await import('./main.ts'); + const saved = { + navigator: (globalThis as { navigator?: unknown }).navigator, + addEventListener: globalThis.addEventListener, + removeEventListener: globalThis.removeEventListener, + call: (globalThis as { wavewalletdkCall?: unknown }).wavewalletdkCall, + }; + const locks = grantingLocks(); + const stub = (name: string, value: unknown) => + Object.defineProperty(globalThis, name, { configurable: true, value }); + stub('navigator', locks.navigator); + stub('addEventListener', () => undefined); + stub('removeEventListener', () => undefined); + // A callable runtime (so loadRuntime short-circuits) whose daemon acks the + // first start and every getInfo but rejects a second start. + let starts = 0; + stub('wavewalletdkCall', async (method: string) => { + if (method === 'start' && ++starts > 1) { + throw new Error('daemon already started'); + } + return {}; + }); + + try { + const client = new MainThreadWavelengthClient(); + await client.start({ network: 'regtest', arkServerAddress: 'h:7070' }); + assert.equal(locks.state.released, false); + + // The recovery stop a failed re-start would trigger frees the lock; the + // coalesce guard returns the live session's info instead, so neither the + // session nor its lock is disturbed. + const info = await client.start({ network: 'regtest', arkServerAddress: 'h:7070' }); + assert.ok(info, 'a redundant start resolves with the live session info'); + assert.equal( + locks.state.released, + false, + 'a redundant main-thread start must not free the running session lock', + ); + assert.equal(starts, 1, 'a redundant start must not re-invoke the daemon'); + client.dispose(); + } finally { + for (const [name, value] of Object.entries(saved)) { + stub(name === 'call' ? 'wavewalletdkCall' : name, value); + } + } + }); + + it('releases the main-thread lock when the runtime exits during the acquire window', async () => { + const { MainThreadWavelengthClient } = await import('./main.ts'); + const saved = { + navigator: (globalThis as { navigator?: unknown }).navigator, + addEventListener: globalThis.addEventListener, + removeEventListener: globalThis.removeEventListener, + call: (globalThis as { wavewalletdkCall?: unknown }).wavewalletdkCall, + }; + const stub = (name: string, value: unknown) => + Object.defineProperty(globalThis, name, { configurable: true, value }); + // A lock whose grant is deferred until grantLock(), so the runtime can exit + // while start() is suspended on the acquire, exactly the window the worker + // transport already guards and the main transport must too. + let grantLock!: () => void; + let released = false; + stub('navigator', { + locks: { + request: ( + name: string, + _options: unknown, + callback: (lock: unknown) => unknown, + ) => + new Promise((resolveRequest) => { + grantLock = () => + resolveRequest( + Promise.resolve(callback({ name })).then(() => { + released = true; + }), + ); + }), + }, + }); + // wavewalletdkCall stays installed after the runtime exits (Go leaves the + // global in place), so the catch's function-probe cannot detect the death; + // only the post-acquire runtimeExited guard releases the lock. A call would + // reject against the dead runtime. + stub('wavewalletdkCall', async () => { + throw new Error('runtime exited during start'); + }); + stub('addEventListener', () => undefined); + stub('removeEventListener', () => undefined); + + try { + const client = new MainThreadWavelengthClient(); + const started = client + .start({ network: 'regtest', arkServerAddress: 'h:7070' }) + .then(() => 'resolved', (err: unknown) => (err as { code?: string }).code); + // Let the serialized start pass its pre-acquire checks and park on the + // deferred acquire, then the runtime exits underneath it (bootExit sets + // runtimeExited while this.lease is still the sentinel). + await new Promise((resolve) => setTimeout(resolve, 0)); + (client as unknown as { runtimeExited: boolean }).runtimeExited = true; + grantLock(); + + assert.equal(await started, 'runtime_not_ready'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + released, + true, + 'a runtime that exits mid-acquire must not strand the origin lock', + ); + client.dispose(); + } finally { + for (const [name, value] of Object.entries(saved)) { + stub(name === 'call' ? 'wavewalletdkCall' : name, value); + } + } + }); + + // grantingLocks stubs navigator.locks with a lock that is always available, + // reporting when the holder lets it go. + function grantingLocks() { + const state = { released: false, requests: 0 }; + const locks = { + request: ( + _name: string, + _options: unknown, + callback: (lock: unknown) => unknown, + ) => { + state.requests += 1; + + return Promise.resolve(callback({ name: 'lock' })).then(() => { + state.released = true; + }); + }, + }; + + return { state, navigator: { locks } }; + } + + // A worker whose every RPC fails, standing in for a daemon that cannot be + // reached: neither start nor the stop that follows it is acknowledged. + class RejectingWorker extends FakeWorker { + postMessage(message: WorkerMessage): void { + this.messages.push(message); + if (typeof message.id === 'number') { + queueMicrotask(() => + this.onmessage?.({ + data: { + id: message.id, + ok: false, + error: 'dial ark server: connection refused', + }, + } as MessageEvent), + ); + } + } + } + + it('releases the lock and abandons the worker when a start fails', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + const locks = grantingLocks(); + const created: RejectingWorker[] = []; + class CountingRejectingWorker extends RejectingWorker { + constructor(url: string | URL) { + super(url); + created.push(this); + } + } + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + value: CountingRejectingWorker, + }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: locks.navigator }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + await assert.rejects( + client.start({ network: 'regtest', arkServerAddress: 'h:7070' }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Terminating the worker frees the OPFS handles the start may have + // opened, so the lock is handed straight back rather than held on the + // chance a daemon survived. No graceful stop is attempted: the worker + // is gone. + assert.equal(locks.state.released, true); + assert.ok( + !FakeWorker.latest!.messages.some((m) => m.method === 'stop'), + 'a killed worker is not asked to stop', + ); + + // The retry runs on a fresh worker, not the abandoned one. + await assert.rejects( + client.start({ network: 'regtest', arkServerAddress: 'h:7070' }), + ); + assert.equal(created.length, 2, 'a failed start abandons its worker'); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: savedWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: savedNavigator }); + } + }); + + it('releases the runtime lock when the runtime never loaded', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + const locks = grantingLocks(); + + // The worker cannot fetch its runtime assets, so no daemon ever exists and + // no database is ever opened. + class AssetlessWorker extends FakeWorker { + postMessage(message: WorkerMessage): void { + this.messages.push(message); + if (typeof message.id === 'number') { + queueMicrotask(() => + this.onmessage?.({ + data: { + id: message.id, + ok: false, + error: + 'Wavelength runtime asset could not be loaded from https://x/wavewalletdk.wasm', + }, + } as MessageEvent), + ); + } + } + } + + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: AssetlessWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: locks.navigator }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + await assert.rejects( + client.start({ network: 'regtest', arkServerAddress: 'h:7070' }), + (err: unknown) => { + assert.equal((err as { code?: string }).code, 'asset_load_failed'); + + return true; + }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Holding here would lock the whole origin out of a wallet that never + // started, for as long as this tab stays open. + assert.equal( + locks.state.released, + true, + 'a runtime that never loaded cannot be holding a database', + ); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: savedWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: savedNavigator }); + } + }); + + it('never takes the runtime lock for a config that cannot start', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + const locks = grantingLocks(); + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: FakeWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: locks.navigator }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + await assert.rejects(client.start({ network: 'mainnet' }), (err: unknown) => { + assert.equal((err as { code?: string }).code, 'invalid_config'); + + return true; + }); + + assert.equal( + locks.state.requests, + 0, + 'a request that cannot reach the daemon must not touch the lock', + ); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: savedWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: savedNavigator }); + } + }); + + it('releases the runtime lock before announcing a clean stop', async () => { + const { WorkerWavelengthClient } = await import('./worker.ts'); + const savedWorker = (globalThis as { Worker?: unknown }).Worker; + const savedNavigator = (globalThis as { navigator?: unknown }).navigator; + const order: string[] = []; + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: AutoReplyWorker }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request: ( + _name: string, + _options: unknown, + callback: (lock: unknown) => unknown, + ) => + Promise.resolve(callback({ name: 'lock' })).then(() => { + order.push('released'); + }), + }, + }, + }); + + try { + const client = new WorkerWavelengthClient({ workerURL: 'fake-worker.js' }); + client.subscribe((event) => { + if (event.type === 'runtimeStopped') { + order.push('runtimeStopped'); + } + }); + await client.start({ network: 'regtest', arkServerAddress: 'h:7070' }); + await client.stop(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // A subscriber that restarts the wallet on runtimeStopped must find the + // lock already free, or its acquire races the release behind it. + assert.deepEqual(order, ['released', 'runtimeStopped']); + client.dispose(); + } finally { + Object.defineProperty(globalThis, 'Worker', { configurable: true, value: savedWorker }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: savedNavigator }); + } + }); + + it('releases the lock and announces the stop when a main-thread runtime exits', async () => { + const { MainThreadWavelengthClient } = await import('./main.ts'); + const saved = { + navigator: (globalThis as { navigator?: unknown }).navigator, + document: (globalThis as { document?: unknown }).document, + Go: (globalThis as { Go?: unknown }).Go, + fetch: globalThis.fetch, + decompression: (globalThis as { DecompressionStream?: unknown }) + .DecompressionStream, + instantiate: WebAssembly.instantiate, + addEventListener: globalThis.addEventListener, + removeEventListener: globalThis.removeEventListener, + call: (globalThis as { wavewalletdkCall?: unknown }).wavewalletdkCall, + }; + const locks = grantingLocks(); + const stub = (name: string, value: unknown) => + Object.defineProperty(globalThis, name, { configurable: true, value }); + + // Stand up just enough of a browser for loadRuntime() to reach the point + // where the Go runtime is running, then kill the runtime. + let exitRuntime!: (reason: unknown) => void; + const runPromise = new Promise((_resolve, reject) => { + exitRuntime = reject; + }); + stub('navigator', locks.navigator); + // A querySelector hit makes loadScript resolve without a real