Skip to content

web: reject concurrent worker runtime starts#55

Closed
jgmcalpine wants to merge 3 commits into
lightninglabs:mainfrom
jgmcalpine:fix/48-runtime-lock
Closed

web: reject concurrent worker runtime starts#55
jgmcalpine wants to merge 3 commits into
lightninglabs:mainfrom
jgmcalpine:fix/48-runtime-lock

Conversation

@jgmcalpine

Copy link
Copy Markdown

Summary

  • Add a stable runtime_locked error classification for worker runtime contention.
  • Prevent a second same-origin default worker client from reaching SQLite/OPFS startup while another current client owns the runtime lock.
  • Preserve the existing error phase and Try again recovery flow.

Technical Notes

The default worker transport now acquires a non-blocking, origin-scoped Web Lock immediately before dispatching the daemon start request.

The lock is held for the runtime's storage-owning lifetime and released after:

  • a failed initial start
  • a successful stop
  • a fatal runtime exit
  • client disposal

It remains held after a failed stop or when the daemon starts successfully but the subsequent getInfo() request fails.

Worker start and stop requests are serialized in invocation order so overlapping lifecycle operations cannot release the lock while a later runtime is starting.

When another same-origin tab owns the lock, the second caller receives:

new WavelengthError(
  'This wallet is already open in another tab. Close the other tab and try again.',
  'runtime_locked',
)

Browsers without navigator.locks retain the existing behavior.

This change does not modify the main-thread transport, SQLite runtime assets, lifecycle phases, wipe behavior, or the existing retry UI.

Steps to Test

Run the package checks:

pnpm build
pnpm typecheck
pnpm --filter @lightninglabs/wavelength-core test
pnpm --filter @lightninglabs/wavelength-web test
pnpm --filter @lightninglabs/wavelength-react test
pnpm check:packages
pnpm --filter @lightninglabs/wavelength-docs run build

Run the browser regression:

pnpm --filter web-wallet-demo run wasm:fetch
pnpm --filter web-wallet-demo run build
pnpm --filter web-wallet-demo run test

On a new Playwright installation, install Chromium first:

pnpm --filter web-wallet-demo exec playwright install chromium

The two-page regression verifies that:

  1. Page A starts and owns the worker runtime.
  2. Page B receives the friendly runtime_locked error.
  3. Page B does not reach the SQLite in-memory fallback or migration failure.
  4. Closing page A releases ownership.
  5. Try again succeeds in page B without a reload or storage wipe.

Manual QA

Build and serve the local demo:

pnpm --filter web-wallet-demo run wasm:fetch
pnpm --filter web-wallet-demo run build
cd apps/web-wallet-demo
node smoke-server.js

Then open http://127.0.0.1:8790 in two tabs in the same browser profile:

  1. In tab A, click Start runtime and wait for either Create wallet or Unlock.
  2. In tab B, click Start runtime.
  3. Confirm tab B shows the existing Runtime error screen with: This wallet is already open in another tab. Close the other tab and try again.
  4. Confirm the page and browser console do not show the prior SQLite fallback or migration errors, including:
  • falling back to :memory:
  • OPFS VFS unavailable
  • SQLITE_CANTOPEN
  1. Close tab A.
  2. In tab B, click Try again without reloading or wiping storage.
  3. Confirm tab B reaches Create wallet or Unlock successfully.

Result

Tab B fails before SQLite startup and displays the actionable runtime contention message:

Screenshot 2026-07-22 at 5 47 54 PM

Related Issues & Pull Requests

Closes #48
Related to #49

Name runtime_locked in the public error union and document its worker-start semantics for same-origin contention.
Guard worker startup with an origin-scoped Web Lock and serialize lifecycle RPCs in invocation order so ownership follows the daemon lifetime.
Exercise the same-origin two-tab failure, reject SQLite fallback markers, and prove Try again succeeds after the owning tab closes.
@jamaljsr

Copy link
Copy Markdown
Member

Thanks for digging into #48, @jgmcalpine. I'd been working on the same fix in parallel over in #56, and put a lot of time into weeding out the edge cases and race conditions around the lock lifecycle. Wanted to lay out where the two line up and where #56 does more.

Where they overlap:

  • Both take a Web Lock before the daemon opens the OPFS databases, so a second tab fails fast instead of tripping over the exclusive handles.
  • Both add a typed contention code (runtime_locked here, wallet_locked in Fail fast with wallet_locked when the wallet is open in another tab #56).
  • Both scope the lock to one runtime per origin.
  • Both serialize start/stop so overlapping lifecycle calls can't race the lock.

Where #56 goes further:

  • Covers the main-thread transport too, not just the worker.
  • Handles the runtime-death-races-the-start-rejection ordering in the engine phase machine, so the error survives the stop it caused.
  • Enforces lifecycle-through-typed-methods by rejecting raw callFacade('start'/'stop').
  • Pairs with a daemon-side fail-closed change (Fail closed when the wasm wallet database can't persist to OPFS wavelength#1046) as the correctness backstop.

Given that extra coverage, we're going to go with #56. Really appreciate the effort here.

@jamaljsr jamaljsr closed this Jul 24, 2026
@jgmcalpine

Copy link
Copy Markdown
Author

Thanks for digging into #48, @jgmcalpine. I'd been working on the same fix in parallel over in #56, and put a lot of time into weeding out the edge cases and race conditions around the lock lifecycle. Wanted to lay out where the two line up and where #56 does more.

Where they overlap:

  • Both take a Web Lock before the daemon opens the OPFS databases, so a second tab fails fast instead of tripping over the exclusive handles.
  • Both add a typed contention code (runtime_locked here, wallet_locked in Fail fast with wallet_locked when the wallet is open in another tab #56).
  • Both scope the lock to one runtime per origin.
  • Both serialize start/stop so overlapping lifecycle calls can't race the lock.

Where #56 goes further:

  • Covers the main-thread transport too, not just the worker.
  • Handles the runtime-death-races-the-start-rejection ordering in the engine phase machine, so the error survives the stop it caused.
  • Enforces lifecycle-through-typed-methods by rejecting raw callFacade('start'/'stop').
  • Pairs with a daemon-side fail-closed change (Fail closed when the wasm wallet database can't persist to OPFS wavelength#1046) as the correctness backstop.

Given that extra coverage, we're going to go with #56. Really appreciate the effort here.

Thanks for the detailed comparison, that makes sense. I appreciate you taking the time to explain the additional edge cases and coverage in #56. Glad the underlying issue is fixed, it has been really interesting digging into wavelength.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web: surface a clear 'wallet already open in another tab' error instead of raw SQLite migration trace

2 participants