fix(background): keep request descriptors across a worker restart - #1506
Draft
ost-ptk wants to merge 2 commits into
Draft
fix(background): keep request descriptors across a worker restart#1506ost-ptk wants to merge 2 commits into
ost-ptk wants to merge 2 commits into
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
windowManagement.requestslived 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:
cancelRequestsDisplacedByselects fromselectOpenRequests; 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.markRequestRespondedearly-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.windowManagement.windowIdis gone too, socreateOpenWindownever 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 intochrome.storage.sessionand hydrated in theget-main-store.tspreload.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.onRemovedawaits store init and then readsselectOpenRequestssynchronously, so a saga's firstawaitis already too late — and a window-URL rebuild is worse still, since the window whose close woke the worker is by then gone fromwindows.getAll. The preload reads the session area alongside the existingstorage.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
storage.local.set. That call also writesVAULT_CIPHER_KEY, andrequestIdis dapp-chosen with no length bound anywhere, so sharing the write would let a page fail the vault persist.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.(requests, windowId)pair, not the slice reference — four case reducers return a fresh object for a value-equal write."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-polyfilldeclaresstorage.sessionnon-optional even where it does not exist.isEphemeralBackgroundBuildis byte-identical in expression toisLedgerAvailable. Deliberate — two different concepts that happen to coincide today..catchon 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 --noEmitclean.knipclean.windowManagement/reducer.tsstays at 100% coverage;src/background/handlers/stays above its floor.isEphemeralBackgroundBuildisfalseunder jest (npm testsets noBROWSER, DefinePlugin is webpack-only), so an unmocked test would exercise the disabled path and pass while asserting nothing. Flipping the mock tofalsefails 31 of 42 session-store tests and 2 get-main-store tests — the tests really do run the enabled path.MOCK_STATEand 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.
frameIdis 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