diff --git a/apps/docs/src/content/docs/guides/handle-phases-and-errors.mdx b/apps/docs/src/content/docs/guides/handle-phases-and-errors.mdx index 47a4e7c..cbc15a7 100644 --- a/apps/docs/src/content/docs/guides/handle-phases-and-errors.mdx +++ b/apps/docs/src/content/docs/guides/handle-phases-and-errors.mdx @@ -125,8 +125,56 @@ function PayButton({ bolt11 }: { bolt11: string }) { Every error the Wavelength SDK originates extends [`WavelengthError`](/reference/wavelength-core/#WavelengthError) and carries a `code` string that identifies the failure reason at the machine level. Match on `err.code` to show a specific message rather than a generic fallback. Named -SDK codes include `runtime_not_ready`, `asset_load_failed`, and -`worker_error`; daemon-originated errors currently use `wavelength_error`. +SDK codes include `runtime_not_ready`, `asset_load_failed`, `worker_error`, +`wallet_locked`, and `runtime_lock_unavailable` (the browser refused or +cancelled the runtime lock request itself, which says nothing about another +tab; offer a plain retry). Daemon-originated errors currently use +`wavelength_error`. + +`wallet_locked` is worth handling on its own, and it belongs on the path that +starts the runtime rather than on a payment. Despite the name it has nothing to +do with the `locked` phase: that one means a wallet on this device is waiting +for its password or passkey, while this means another tab owns the runtime. +The daemon's browser storage is exclusive to one runtime per origin, so +starting the wallet while another tab already runs it fails immediately with +this code. It is an expected condition +rather than a fault: tell the user to close the other tab, and keep a retry +affordance so they can continue once they have. + +```tsx title="start.tsx" +import { useState } from 'react'; +import { + useWallet, + type RuntimeConfig, + type WavelengthError, +} from '@lightninglabs/wavelength-react'; + +function StartButton({ config }: { config: RuntimeConfig }) { + const { start } = useWallet(); + const [notice, setNotice] = useState(''); + + const run = async () => { + try { + await start(config); + } catch (err) { + // Branch on the code property rather than instanceof: a duplicate + // bundled copy of core fails instanceof across the bundle boundary. + if ((err as WavelengthError)?.code === 'wallet_locked') { + setNotice('Already open in another tab. Close it, then try again.'); + return; + } + setNotice('The wallet runtime could not start.'); + } + }; + + return ( + <> + + {notice &&

{notice}

} + + ); +} +``` ```tsx title="errors.tsx" import { useWalletSend, WavelengthError } from '@lightninglabs/wavelength-react'; diff --git a/apps/docs/src/content/docs/reference/wavelength-core.mdx b/apps/docs/src/content/docs/reference/wavelength-core.mdx index 96f56f5..aa12e51 100644 --- a/apps/docs/src/content/docs/reference/wavelength-core.mdx +++ b/apps/docs/src/content/docs/reference/wavelength-core.mdx @@ -286,9 +286,10 @@ package. -Calling `client.callFacade('start', mobileConfig)` sends this lower-level shape -directly to the daemon and bypasses public `RuntimeConfig` validation. App code -should call `client.start(config)` instead. +The daemon's start verb accepts this lower-level shape; the SDK builds it from +your [`RuntimeConfig`](#RuntimeConfig) when you call `client.start(config)`. The +lifecycle verbs are not reachable through `callFacade`, which rejects `'start'` +and `'stop'` so the cross-tab runtime lock is never bypassed. @@ -368,7 +369,9 @@ Resolves when the client is ready to `start()`. Rejects with a `WavelengthError` Starts the embedded daemon with the given config and resolves with its -initial [`WalletInfo`](#WalletInfo). +initial [`WalletInfo`](#WalletInfo). Calling `start()` while a session is +already running coalesces onto it and resolves with the current info; the +passed config is ignored, so call `stop()` first to start under a different one. @@ -509,7 +512,7 @@ The engine interface. Every method mirrors the matching `WavelengthClient` metho { name: 'client', type: 'WavelengthClient', desc: 'The underlying transport client, as an escape hatch.' }, { name: 'getSnapshot()', type: '() => WalletSnapshot', desc: 'The current immutable state snapshot; see WalletSnapshot below.' }, { name: 'subscribe(listener)', type: '(listener: () => void) => () => void', desc: 'Subscribes to snapshot changes; returns the unsubscribe function. Framework bindings call this via useSyncExternalStore.' }, - { name: 'start(config?)', type: '(config?: RuntimeConfig) => Promise', desc: "Starts the runtime. Falls back to the engine's configured config when called without an argument; throws if neither exists. A failure moves phase to 'error' and rejects. On success it best-effort calls refresh() (a locked or empty wallet can fail balance/list until bootstrap; that failure is swallowed)." }, + { name: 'start(config?)', type: '(config?: RuntimeConfig) => Promise', desc: "Starts the runtime. Falls back to the engine's configured config when called without an argument; throws if neither exists. On success it dispatches the info and best-effort calls refresh() (a locked or empty wallet can fail balance/list until bootstrap; that failure is swallowed); a failure moves 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), though the promise still resolves with the info or rejects as usual." }, { name: 'stop()', type: '() => Promise', desc: "Stops the runtime and clears the in-memory snapshot (info/balance/activity reset to null/null/[]); persisted wallet data is untouched. A failure moves phase to 'error'." }, { name: 'refresh()', type: '() => Promise', desc: 'Re-fetches info, balance, and activity concurrently.' }, { name: 'createWallet(req)', type: '(req: CreateWalletRequest) => Promise', desc: 'Creates a new wallet, refetches info, and kicks a background refresh.' }, @@ -544,7 +547,7 @@ reimplement them. { name: 'Sync auto-poll', type: "phase: 'syncing'", desc: "Calls refresh() every 2000ms until the phase leaves 'syncing'. After 5 consecutive failures it stops polling and escalates to phase 'error' rather than polling forever." }, { name: 'Restore readiness poll', type: "phase: 'restoring'", desc: 'Runs while any restoreWallet() is in flight, polling getInfo() every 1500ms until the wallet reports ready, then settles the restoreWallet() promise and kicks a refresh. When the request sets recoverState: true, the recovery scan is tracked separately through snapshot.recovery.' }, { name: 'Background-refresh failure budget', type: 'all of the above', desc: 'Any refresh kicked after a mutation (create, unlock, restore, deposit, receive, send) or by a poll above shares one consecutive-failure counter. At 5 consecutive failures the engine sets snapshot.error and escalates phase to \'error\', so a dead daemon cannot hide behind a healthy-looking ready wallet.' }, - { name: 'Unsolicited stopped transition', type: 'on runtimeStopped', desc: "A runtimeStopped client event, whether from a clean stop() or a worker crash surfaced by the transport, moves phase to 'stopped' and wipes info, balance, and activity to null, null, and [], even if the host never called stop() itself." }, + { name: 'Unsolicited stopped transition', type: 'on runtimeStopped', desc: "A runtimeStopped client event, whether from a clean stop() or a worker crash surfaced by the transport, moves phase to 'stopped' (unless the engine is already in 'error', which is preserved so a failure that caused the stop still surfaces) and wipes info, balance, and activity to null, null, and [] regardless, even if the host never called stop() itself." }, ]} /> @@ -2479,9 +2482,12 @@ as `T` (unchecked; the caller asserts the shape). The whitelist excludes the streaming `subscribe` operation and private worker -control methods such as `$ready` and `$init`. Requests use the raw mobile/wasm -shapes, while responses use the same camelCase keys and schema-aware nil -normalization as typed methods. The four scalar verbs +control methods such as `$ready` and `$init`. The lifecycle verbs `start` and +`stop` are rejected too: call the typed [`start()`](#start) / [`stop()`](#stop) +methods, which own the runtime lifecycle (and, on the web transports, the +cross-tab runtime lock). Requests use the raw mobile/wasm shapes, while +responses use the same camelCase keys and schema-aware nil normalization as +typed methods. The four scalar verbs `confirmedBalanceSat`, `pendingInboundSat`, `walletReady`, and `isRunning` are included for portable bridge callers. @@ -2796,6 +2802,8 @@ export const errorCodeSig = `type WavelengthErrorCode = | 'runtime_not_ready' | 'asset_load_failed' | 'worker_error' + | 'wallet_locked' + | 'runtime_lock_unavailable' | 'unsupported_facade_method' | 'invalid_cursor' | 'invalid_config' @@ -2819,18 +2827,22 @@ Base error class thrown by all Wavelength SDK packages. Carries a machine-readab Stable, machine-readable error classification on `WavelengthError`. Named SDK -codes are listed below; daemon-originated errors currently fall back to -`wavelength_error`. The `(string & {})` arm keeps the union open for forward -compatibility while still offering autocomplete on the known codes. +codes are listed below; most daemon-originated errors fall back to +`wavelength_error`, except recognized storage-contention messages on `start()`, +which the web transports map to `wallet_locked`. The `(string & {})` arm keeps the union +open for forward compatibility while still offering autocomplete on the known +codes. | Code | Thrown when | |---|---| -| `wavelength_error` | The generic default: any SDK error without a more specific code, and all daemon-originated failures today. | +| `wavelength_error` | The generic default: any SDK error without a more specific code, and daemon-originated failures today, except recognized storage-contention messages on `start()` that map to `wallet_locked`. | | `runtime_not_ready` | The wasm runtime is not callable: it exited before signaling ready, or its call entry point is missing once loading has finished. | | `asset_load_failed` | `ready()` fails to load the runtime assets (wasm binary or its supporting files). | | `worker_error` | The worker transport's underlying Worker fails or crashes. | +| `wallet_locked` | The wallet is already running in another tab or window of the same origin. The daemon's browser storage is exclusive to one runtime, so `start()` fails immediately rather than corrupting or losing data. Unrelated to the `locked` phase, which means a wallet on this device is waiting to be unlocked. | +| `runtime_lock_unavailable` | The browser refused or dropped the runtime lock request itself (for example while the document is shutting down). Unlike `wallet_locked`, this says nothing about another tab holding the wallet. | | `unsupported_facade_method` | A call names a method the daemon facade does not expose. | | `invalid_cursor` | `startActivity()` is given a cursor that is not a nonnegative safe integer. | | `invalid_config` | `RuntimeConfig` validation fails before the daemon starts. | diff --git a/apps/docs/src/content/docs/web/support/troubleshooting.mdx b/apps/docs/src/content/docs/web/support/troubleshooting.mdx index d99f6e6..f053f9f 100644 --- a/apps/docs/src/content/docs/web/support/troubleshooting.mdx +++ b/apps/docs/src/content/docs/web/support/troubleshooting.mdx @@ -15,6 +15,23 @@ base URL and set **`runtimeBaseUrl`** on `createWebClient()`. Open the failing URL in the browser network tab; a 404 almost always means the base path or version folder is wrong. See [Hosting runtime assets](/web/get-started/hosting-runtime-assets/). +### Wallet is open in another tab (`wallet_locked`) + +The client throws `WavelengthError` with code **`wallet_locked`** when `start()` +runs while another tab or window of the same origin is already running the +wallet. The daemon's OPFS storage is exclusive to one runtime, so the second tab +fails immediately instead of colliding on the database. This is unrelated to the +`locked` phase, which means a wallet on this device is waiting to be unlocked. + +**Fix:** This is expected, not a bug. Tell the user to close the other tab and +offer a retry (the demo shows "Already open in another tab. Close it, then try +again." with a retry button, not a wipe hatch). Handle it on the path that starts +the runtime, matching on `err.code === 'wallet_locked'`; see +[Handle phases and errors](/guides/handle-phases-and-errors/). A related code, +**`runtime_lock_unavailable`**, means the browser refused or dropped the lock +request itself (for example while the document is shutting down) and says nothing +about another tab; offer a plain retry. + ### Page is not cross-origin isolated Symptoms: `SharedArrayBuffer` is undefined, worker startup fails, or SQLite/OPFS diff --git a/apps/web-wallet-demo/src/App.tsx b/apps/web-wallet-demo/src/App.tsx index 7ef247a..6e77ba1 100644 --- a/apps/web-wallet-demo/src/App.tsx +++ b/apps/web-wallet-demo/src/App.tsx @@ -4,7 +4,7 @@ import { useWalletActivity, useWalletBalance, } from "@lightninglabs/wavelength-react"; -import type { WalletKind } from "@lightninglabs/wavelength-react"; +import type { WalletKind, WavelengthError } from "@lightninglabs/wavelength-react"; import { AppShell } from "./components/layout/AppShell"; import { RecoveryBanner } from "./components/RecoveryBanner"; import { ExitBanner } from "./components/ExitBanner"; @@ -273,7 +273,48 @@ export function App() { ); - case "error": + case "error": { + // Duck-typed on `code` rather than instanceof: a duplicate bundled copy + // of core (the hazard core's isPasskeyCancelled documents) would fail + // instanceof and silently downgrade these expected conditions to the + // generic screen, wipe button included. + const errorCode = (error as WavelengthError | null)?.code; + + // A wallet_locked failure is an expected multi-tab condition, not a + // runtime fault: another tab of this origin holds the wallet's exclusive + // OPFS databases. Swap the raw error surface for actionable copy; the + // retry succeeds once the other tab stops the runtime or closes. + if (errorCode === "wallet_locked") { + return ( + + ); + } + + // The browser refused the lock request itself, which says nothing about + // the wallet data. Retrying is the whole remedy, so keep the destructive + // wipe affordance out of it. + if (errorCode === "runtime_lock_unavailable") { + return ( + + ); + } + return ( ); + } case "ready": default: diff --git a/apps/web-wallet-demo/src/screens/onboarding/ErrorScreen.tsx b/apps/web-wallet-demo/src/screens/onboarding/ErrorScreen.tsx index 1ccf70c..ad4b4b5 100644 --- a/apps/web-wallet-demo/src/screens/onboarding/ErrorScreen.tsx +++ b/apps/web-wallet-demo/src/screens/onboarding/ErrorScreen.tsx @@ -8,24 +8,29 @@ import { PrimaryButton } from "../../components/ui/Button"; // ErrorScreen serves the `error` phase: the runtime failed to initialise or // start. It surfaces the message and offers a retry, plus the wipe escape // hatch for when stored data (a stale database, say) is what keeps the -// runtime from starting. +// runtime from starting. Expected conditions (the wallet already running in +// another tab, say) override the title/sub/message with friendlier copy and +// hide the wipe button, which cannot help there. export function ErrorScreen({ network, message, onRetry, busy, + title = "Runtime error", + sub = "Something went wrong starting the wallet runtime.", + showWipe = true, }: { network: string; message: string; onRetry: () => void; busy: boolean; + title?: string; + sub?: string; + showWipe?: boolean; }) { return ( - +
{busy ? "Retrying…" : "Try again"} -
- -
+ {showWipe && ( +
+ +
+ )}
); diff --git a/apps/web-wallet-demo/wavewalletdk-smoke.spec.js b/apps/web-wallet-demo/wavewalletdk-smoke.spec.js index 12abd64..40f513c 100644 --- a/apps/web-wallet-demo/wavewalletdk-smoke.spec.js +++ b/apps/web-wallet-demo/wavewalletdk-smoke.spec.js @@ -145,6 +145,124 @@ test("wallet create and address state persist with OPFS SQLite", async ({ }); }); +test("a second tab fails fast with a friendly locked message and can take over", async ({ + page, + context, +}, testInfo) => { + const password = "test-password"; + const baseURL = testInfo.project.use.baseURL; + const dataDir = `/wavewalletdk-smoke-lock-${Date.now()}`; + const swapDatabaseFileName = `/wavewalletdk-swaps-lock-${Date.now()}.db`; + + await createReadyWallet(page, { baseURL, dataDir, swapDatabaseFileName, password }); + await expect(page.getByTestId("account-pubkey")).toBeVisible({ timeout: 60000 }); + + // A second tab of the same origin must fail the start fast on the Web Locks + // pre-check and show the actionable multi-tab copy, never the raw SQLite + // trace the exclusive OPFS handles would otherwise produce. + const second = await context.newPage(); + await second.goto("/"); + const startRuntime = second.getByRole("button", { name: "Start runtime" }); + await expect(startRuntime).toBeVisible({ timeout: 30000 }); + await configureRuntime(second, baseURL, dataDir, swapDatabaseFileName); + await startRuntime.click(); + + await expect( + second.getByRole("heading", { name: "Wallet open in another tab" }), + ).toBeVisible({ timeout: 30000 }); + await expect( + second.getByText("This wallet is already running in another tab"), + ).toBeVisible(); + // The wipe escape hatch is hidden for this expected condition. + await expect(second.getByText("Clear wallet data")).toBeHidden(); + + await testInfo.attach("second-tab-locked", { + body: await second.screenshot({ fullPage: true }), + contentType: "image/png", + }); + + // Closing the first tab releases the lock and, with it, the OPFS handles; + // Try again in the second tab must then boot the same wallet and land on + // the unlock screen (the daemon absorbs any handle-release lag by retrying + // the open internally). + await page.close(); + await second.getByRole("button", { name: "Try again" }).click(); + + const unlock = second.getByRole("button", { name: "Unlock", exact: true }); + await expect(unlock).toBeVisible({ timeout: 60000 }); + await second.getByLabel("Password", { exact: true }).fill(password); + await unlock.click(); + await expect(second.getByTestId("account-pubkey")).toBeVisible({ + timeout: 60000, + }); +}); + +test("stopping the runtime in one tab hands the wallet to another", async ({ + page, + context, +}, testInfo) => { + const password = "test-password"; + const baseURL = testInfo.project.use.baseURL; + const dataDir = `/wavewalletdk-smoke-handoff-${Date.now()}`; + const swapDatabaseFileName = `/wavewalletdk-swaps-handoff-${Date.now()}.db`; + + await createReadyWallet(page, { baseURL, dataDir, swapDatabaseFileName, password }); + await expect(page.getByTestId("account-pubkey")).toBeVisible({ timeout: 60000 }); + + const second = await context.newPage(); + await second.goto("/"); + const startRuntime = second.getByRole("button", { name: "Start runtime" }); + await expect(startRuntime).toBeVisible({ timeout: 30000 }); + await configureRuntime(second, baseURL, dataDir, swapDatabaseFileName); + await startRuntime.click(); + await expect( + second.getByRole("heading", { name: "Wallet open in another tab" }), + ).toBeVisible({ timeout: 30000 }); + + // The locked copy tells the user they can stop the runtime in the other tab + // instead of closing it. That path releases the lock through the daemon's + // acknowledged stop (afterDaemonStopped), a different proof than the + // browser reclaiming it on tab close, so it gets its own end-to-end leg. + // Stop the runtime from the nav (the only "Stop runtime" control on the + // home screen; the Settings screen adds a second, so stay off it here). + await page.getByRole("button", { name: "Stop runtime" }).click(); + await expect( + page.getByRole("button", { name: "Start runtime" }), + ).toBeVisible({ timeout: 60000 }); + + await second.getByRole("button", { name: "Try again" }).click(); + const unlock = second.getByRole("button", { name: "Unlock", exact: true }); + await expect(unlock).toBeVisible({ timeout: 60000 }); +}); + +test("a refused lock request shows a retry, not the wipe hatch", async ({ + page, +}, testInfo) => { + const baseURL = testInfo.project.use.baseURL; + const dataDir = `/wavewalletdk-smoke-lockfail-${Date.now()}`; + const swapDatabaseFileName = `/wavewalletdk-swaps-lockfail-${Date.now()}.db`; + + // Make the browser refuse the lock outright, which is a different condition + // from another tab holding it: nothing is wrong with the wallet data, so the + // screen must offer a plain retry and must not invite the user to wipe. + await page.addInitScript(() => { + navigator.locks.request = () => + Promise.reject(new DOMException("denied", "SecurityError")); + }); + + await page.goto("/"); + const startRuntime = page.getByRole("button", { name: "Start runtime" }); + await expect(startRuntime).toBeVisible({ timeout: 30000 }); + await configureRuntime(page, baseURL, dataDir, swapDatabaseFileName); + await startRuntime.click(); + + await expect( + page.getByRole("heading", { name: "Could not start just now" }), + ).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect(page.getByText("Clear wallet data")).toBeHidden(); +}); + // An invoice with an amount in its HRP, an amountless invoice, and an address. // The screen must ask for an amount only for the address; the amountless // invoice is unsendable in v1, so it gets a notice instead. diff --git a/packages/core/src/base-client.test.ts b/packages/core/src/base-client.test.ts index b93392e..6aacb1b 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 a577be9..12f3ec9 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 bfc5b9f..fecbabe 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 7f987a3..e97b60f 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 8438e90..d4d8422 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 fdca0ff..564da76 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 fcdafc5..2891d5b 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 d4a4a48..1ea8f99 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' diff --git a/packages/web/src/clients/main.ts b/packages/web/src/clients/main.ts index 203cd4e..cb0ac63 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 a9076db..ddcfad0 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