Skip to content

fix(background): keep request descriptors across a worker restart - #1506

Draft
ost-ptk wants to merge 2 commits into
WALLET-1419-cw-tombstone-ordinalfrom
WALLET-1419-cw-session-mirror
Draft

fix(background): keep request descriptors across a worker restart#1506
ost-ptk wants to merge 2 commits into
WALLET-1419-cw-tombstone-ordinalfrom
WALLET-1419-cw-session-mirror

Conversation

@ost-ptk

@ost-ptk ost-ptk commented Aug 21, 2026

Copy link
Copy Markdown
Member

Description

windowManagement.requests lived only in the MV3 service worker's memory. A worker restart destroyed every request descriptor while the approval windows they describe were still on screen and still signable.

Four things broke at once:

  • Cancel-on-close. cancelRequestsDisplacedBy selects from selectOpenRequests; with no descriptor there is nothing to select, so closing the window told the dapp nothing and its promise hung to the SDK's own 30-minute timeout.
  • Response dedup. markRequestResponded early-returns before dispatching when the descriptor is missing, so a genuine post-restart response left no tombstone at all — every later response for that id also passed the guard.
  • Supersede. Two losses, not one: no descriptor to displace, and windowManagement.windowId is gone too, so createOpenWindow never enters its reuse branch and the next approval opens a second window.
  • awaitingDeviceConfirmation. The WALLET-1394 guard that keeps a window out of reuse during a Ledger confirmation silently re-armed — the flag is announced once at bracket start and never re-sent.

The fix

{ requests, windowId } is mirrored into chrome.storage.session and hydrated in the get-main-store.ts preload.

Why storage.session. It is in-memory, survives a worker restart, and is cleared when the browser closes or the extension reloads — exactly the lifetime a request descriptor wants. That is why there is no purge, no session marker and no TTL: nothing can outlive the session it belongs to. It also keeps dapp origins and tab ids off disk, and stops browser-session-scoped window/tab ids from ever being read back in a session that has reassigned them.

Why the preload and not a saga. The event that wakes a dead worker is often the approval window closing itself. windows.onRemoved awaits store init and then reads selectOpenRequests synchronously, so a saga's first await is already too late — and a window-URL rebuild is worse still, since the window whose close woke the worker is by then gone from windows.getAll. The preload reads the session area alongside the existing storage.local.get, so the map is present the moment the store exists and no handler can observe it empty.

Details worth a reviewer's attention

  • The write is a separate call to a separate area with its own catch — never a field in the twelve-key storage.local.set. That call also writes VAULT_CIPHER_KEY, and requestId is dapp-chosen with no length bound anywhere, so sharing the write would let a page fail the vault persist.
  • Rows are capped on the write side; the read is uncapped. This leaves no read-side drop order for integer-key hoisting to decide.
  • A rejected write removes the key. An absent mirror behaves exactly like today; a stale one can pin a request open for the whole browser session. Writes are serialised so an older snapshot cannot land after a newer one, and the flush never rejects — a rejection would poison the chain for every later write.
  • createStore(preloadedState) bypasses every case reducer, so the restored map is validated by a total sanitizer that drops what it cannot vouch for rather than throwing. A throw here would leave the background unable to start at all.
  • The subscriber guard compares the (requests, windowId) pair, not the slice reference — four case reducers return a fresh object for a value-equal write.
  • Chrome and Edge only, behind a build-time predicate and a runtime detect. Firefox and Safari declare "persistent": true, so their background page never dies and the mirror would be a live untested path there. The runtime half is still needed because @types/webextension-polyfill declares storage.session non-optional even where it does not exist.
  • isEphemeralBackgroundBuild is byte-identical in expression to isLedgerAvailable. Deliberate — two different concepts that happen to coincide today.
  • The subscriber's .catch on the mirror write is unreachable by construction (the flush never rejects). It is there because the write must not be able to take anything else down with it; flagging it so it is not read as dead code.

Verification

  • npx jest src/background/ — 62 suites, 786 tests pass. npx tsc --noEmit clean. knip clean.
  • windowManagement/reducer.ts stays at 100% coverage; src/background/handlers/ stays above its floor.
  • The jest trap was mutation-checked. isEphemeralBackgroundBuild is false under jest (npm test sets no BROWSER, DefinePlugin is webpack-only), so an unmocked test would exercise the disabled path and pass while asserting nothing. Flipping the mock to false fails 31 of 42 session-store tests and 2 get-main-store tests — the tests really do run the enabled path.
  • A new e2e locks in the close-as-wake ordering (second commit): the worker is stopped, the approval window is closed programmatically, and the dapp must settle with a cancel inside 15s. Verified to fail without the fix — with the gate off it times out on exactly that assertion, not on a setup step. It cannot live in the popup suite, which runs under MOCK_STATE and short-circuits the session read.

Not in this PR

The startup sweep, the open-request cap, and the wallet-reset fix are deliberately separate — they are compensating mechanisms with their own risk, and this change stands on its own without them. frameId is not sanitized yet because #1484, which introduces it, is still open; the sanitizer carries a one-line marker at the exact spot.

Linked tickets

WALLET-1419

Checklist

  • Make sure this PR title follows semantic release conventions: https://semantic-release.gitbook.io/semantic-release/#commit-message-format

  • If the PR adds any new text to the UI, make sure they are localized — no UI text added

  • Include a screenshot or recording if implementing significant UI or user flow change — background-only, no UI change

  • When this PR affects architecture changes wait for review from Dmytro before merging

`windowManagement.requests` lived only in the service worker's memory. An
MV3 restart destroyed every request descriptor while the approval windows
they describe were still on screen and still signable, which broke four
things at once: closing such a window told the dapp nothing and its promise
hung to the SDK's own 30-minute timeout; the response dedup lost its
tombstone; supersede lost both the descriptor and `windowManagement.windowId`
and so opened a second window instead of reusing one; and the WALLET-1394
guard that keeps a window out of reuse during a Ledger confirmation silently
re-armed.

The state is now mirrored into `chrome.storage.session` and hydrated in the
`get-main-store.ts` preload.

`storage.session` is in-memory, survives a worker restart, and is cleared
when the browser closes or the extension reloads — exactly the lifetime a
request descriptor wants. That is why there is no purge, no session marker
and no TTL here: nothing can outlive the session it belongs to. It also
keeps dapp origins and tab ids off disk, and keeps browser-session-scoped
window and tab ids from ever being read back in a session that reassigned
them.

Hydration goes in the preload rather than a saga because the event that
wakes a dead worker is often the window closing itself: `windows.onRemoved`
awaits store init and then reads `selectOpenRequests` synchronously, so a
saga's first await is already too late. The preload reads the area alongside
the existing `storage.local.get`, so the map is present the moment the store
exists and no handler can observe it empty.

The write is a separate call to a separate area with its own catch, never a
field in the twelve-key `storage.local.set`: that call also writes
`VAULT_CIPHER_KEY`, and `requestId` is dapp-chosen with no length bound, so
sharing the write would let a page fail the vault persist. Rows are capped on
the write side and the read is left uncapped, which leaves no read-side drop
order for key hoisting to decide. A rejected write removes the key, because
an absent mirror behaves exactly like today while a stale one can pin a
request open for the whole browser session. Writes are serialised so an older
snapshot cannot land after a newer one.

`createStore(preloadedState)` bypasses every case reducer, so the restored
map is validated by a total sanitizer that drops what it cannot vouch for
rather than throwing — a throw here would leave the background unable to
start at all.

Chrome and Edge only, behind a build-time predicate and a runtime detect.
Firefox and Safari declare `"persistent": true`, so their background page
never dies and the mirror would be a live untested path there; the runtime
half is still needed because the polyfill's types declare `storage.session`
non-optional even where it does not exist.
Locks in the ordering the mirror exists for, and the one a manual smoke
cannot reach: the worker is dead, and the event that wakes it is the approval
window closing. Moving a mouse over that window to set the scenario up would
itself wake the worker through `useUserActivityTracker` and hide the bug, so
the test drives the close programmatically and never touches the page.

The spec starts the connection request from the page and holds the promise,
stops the worker, closes the approval window, and asserts the dapp settles
with a cancel inside 15s instead of hanging. Verified to fail without the
fix — with the gate off it times out on exactly that assertion, not on a
setup step.

Stopping an MV3 worker needed a helper; there was none. Two obvious liveness
checks are wrong here: `context.serviceWorkers()` keeps its entry across a
stop, and `Worker.evaluate` keeps answering because the evaluate itself
starts a fresh worker. The helper therefore waits on the passive
`ServiceWorker.workerVersionUpdated` transition to `stopped`, and the spec
proves the restart independently by stamping a generation token on the
worker's global scope and asserting it is gone once the cancel arrives.

Its own suite and workflow rather than a case in an existing one: the popup
suite runs under `MOCK_STATE`, which short-circuits the session read and
would leave the test asserting nothing, and both suites build into
`build/chrome`, so they cannot share a job.
@ost-ptk ost-ptk changed the title WALLET 1419 cw session mirror fix(background): keep request descriptors across a worker restart Aug 21, 2026
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.

1 participant