fix: settle a site approval on the port that carries its teardown (closes #275) #289
Reference in New Issue
Block a user
Delete Branch "issue-275-site-approval-race"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #275.
The defect
btn-approvesent the decision withruntime.sendMessage()and closed thewindow on the next line. The close disconnects the approval port, and the
disconnect handler settled a pending SITE approval as a rejection. Those two
events travelled independent channels with nothing ordering them, so whichever
landed first decided the outcome — and driven in a tab the teardown won every
time: the user allowed the connection and the dApp was told they had refused.
The fix
The decision now goes out on the approval port the popup already opens in
show(), which is the same port the close disconnects. One channel is ordered:a message posted on a port is delivered before that port's own disconnect. So
the approval is settled by the decision and the disconnect then finds nothing
pending to reject — whatever the teardown timing is. Nothing is awaited,
nothing is timed, and the popup closes exactly as immediately as before. The
post is wrapped in a
try: a port whose worker is gone throws, and that mustnot cost the popup its close.
windows.onRemovedno longer decides a site approval whose port is connectedeither. In the fallback-window shape that event races the decision on a channel
of its own, which is the same defect one level over; the port disconnect says
the same thing in a defined order, so it is left to say it. A window that
closes before its popup ever connected has nothing else to speak for it and is
still rejected there, so no dApp is left waiting on a window that is gone.
"Connected" means the extension's own popup connected: the flag takes the same
isExtensionSender()check the decision takes, so a port from a page sendercannot switch that settlement path off and strand the dApp.
Each approval type now has exactly one close authority: site by port
disconnect, tx/sign by
windows.onRemoved(unchanged — their disconnecthandler still leaves them pending so the user can reopen the toolbar popup).
AUTISTMASK_APPROVAL_RESPONSEis gone. The port name carries the approval id,so the popup no longer names one at all, and the
Unauthorized sendercheckthat message carried moved onto the port (factored into
isExtensionSender(),which the remaining popup-only messages now share). The port disconnect itself
is unchecked exactly as before — a content script that guessed a UUID could
always disconnect one; that is pre-existing and not touched here.
What I established about
chrome.action.openPopup()windows.onRemovedcan never fire for aprompt opened that way, so the port disconnect is the ONLY close signal that
exists on the production path. That is why "leave a site approval pending on
disconnect", the shape tx/sign use, is not available here — it would make
close-without-deciding hang the dApp forever.
present in a tab: dismissing the popup destroys the document exactly as
window.close()does, and the decision and the teardown are emitted back toback either way. Which one arrives first there is NOT measured and is not
measurable from this harness — headless Chromium opens the browser-action
popup but Playwright never exposes it as a page.
property of the port, not on how the document was opened: the decision and
the disconnect are the same channel in both shapes, and the unit tests drive
the
action.openPopup()shape (no window created, port disconnect the onlysignal) as well as the fallback-window shape.
So the "every time" in the issue remains a tab result. Production is not
asserted to have behaved the same way; it is asserted to be unable to behave
differently now.
The harness accommodation is removed
tests/e2e/run.jsno longer patcheswindow.closeon the reserved approvaltab. The two site-prompt tests now drive the shipped decide-then-close in a
real Chromium.
Removing it exposed a harness flake unrelated to this bug:
page.click()resolves only after the renderer acknowledges the action, and a page torn down
by the handler never gets to. It hit
#btn-reject-signand#btn-reject-txtoo — windows that have always closed themselves and were never accommodated —
so it is pre-existing and was simply exposed more often once four more clicks
started self-closing.
clickAndClose()tolerates that one error for the fivebuttons that close their own window and rethrows anything else.
What proves the click landed is not the same for each of those five, and the
helper now says so per button.
#btn-reject-signand#btn-reject-txleavethe approval pending, so an unclicked prompt leaves the dApp promise unsettled;
#btn-approvecannot reachsettled === "resolved"without a decision. Thesite
#btn-rejectproves nothing on its own — a page that went away unclickeddisconnects the port, the background settles that as 4001, and 4001 is what
assertUserRejectionaccepts. That one call site arms a capture-phase clicktrace (a synchronous
localStoragewrite on the extension origin, read backfrom another page of that origin) and asserts the click reached the button.
The trace only observes: nothing about the shipped decide-then-close is
deferred, patched or reordered.
Demonstrated failing first
Real browser, accommodation removed, unfixed code (
make test-e2e):The user clicked Allow and the page got 4001. Everything after it fails as a
consequence — the origin never became authorized, so no later prompt was raised
at all.
Unit, unfixed code (
make test): the two approve-then-close cases fail and thefour rejection cases pass, which is the defect exactly.
Tests added
tests/backgroundApproval.test.js, the approve cases all emitting the closeIMMEDIATELY after the decision with nothing awaited in between:
action.openPopup()shape, no window)connects the site
windows.onRemovedthat landsFIRST, which is the interleaving the guard in that listener exists for — the
approval is still pending when the event arrives, so the listener really
reaches it and really has to decline it
windows.onRemovedthat followswindows.onRemovedisSite &&half of the skip. The popup connects its port in
show()before it knows thetype and the background sets
portConnectedtype-agnostically, so a txapproval carries the flag too; without the conjunct the window event would
skip it,
windowClosedwould never be set, and the page would hangchrome.runtime.onConnectis captured rather than swallowed in the stub, andthe port stub never reorders a decision against the disconnect that follows it.
How this coexists with #271
windows.onRemovedis shared ground with#271, which added
abandonedResult()and recordsapproval.windowClosedwhen the settle isrefused, so a tx approval whose window closed under an in-flight attempt does
not hold the page open. This PR keeps that shape verbatim and adds only its own
skip ahead of it:
An earlier revision of this PR carried its own inline
isSiteternary there.That is redundant against
abandonedResult(), which already returns{approved: false, remember: false}for a site approval and{error: {code, message}}for tx/sign with the same 4001 and the same wording,so the ternary is gone and the settle values are unchanged either way.
Verification
Ran, on this branch rebased onto
nextat7690fe6:make fmt— clean, no changes.make check— green, exit 0: 761 tests, 31 suites; eslint and prettier cleanin the pinned container, which is where
#152 now runs lint.
make test-e2e— green, 51/51, in the pinned Playwright container, includingok 43 - eth_requestAccounts rejected at the promptandok 44 - eth_requestAccounts approved.Each guard this PR relies on was neutered in turn and put the suite red, then
restored; the captured output is in the comments below, along with the re-run
of the
portConnectedprobe against the merged#271 code.
make test-e2e-firefoxwas NOT run.FAIL — needs-rework.
1.
tests/backgroundApproval.test.js:528-541— "approving in the fallback window survives the window event too" does not exercise the window event.The test emits
port.decide(true, false)and only thenbg.closeWindow(1). The decision has already deleted the entry frompendingApprovals, so thewindows.onRemovedlistener iterates and finds nothing — the guard the test is named for never executes. Proven: replacingsrc/background/index.js:886(if (isSite && approval.portConnected) continue;) with a no-op leaves all 31 tests in that file green.The guard is load-bearing. A probe emitting the events in the order the guard exists for —
bg.closeWindow(1)BEFOREport.decide(true, false), which is exactly the "ordered against nothing" interleaving the PR body describes for the fallback shape — passes with the guard ({result: [address]}) and fails without it ({error: {code: 4001}}). So the one defence againstwindows.onRemoveddeciding an approved site connection has no test, and deleting it regresses silently.Acceptable: a test that fires the window event before the decision, and asserts the approval still resolves.
2.
src/background/index.js:309-313—portConnectedis set with no sender check, and it disables the only backstop.runtime.onConnectsetspendingApprovals[id].portConnected = truefor any connector. The decision path got the check (isExtensionSender(port.sender), line 316); the flag that switches offwindows.onRemoveddid not. A content script that namedapproval:<id>and holds the port open therefore suppresses theonRemovedrejection, and if the real popup never connected its own port nothing else ever settles the approval — the dApp promise hangs forever. That is a new liveness hole in the same direction as #262, introduced by this PR; the unchecked connect was pre-existing, but before this change it could not disable a settlement path. Guessing a v4 UUID is required, so severity is low, but the asymmetry is one line.Acceptable:
if (pendingApprovals[id] && isExtensionSender(port.sender)) pendingApprovals[id].portConnected = true;3.
tests/e2e/run.js:1639-1645— theclickAndClosejustification is false for#btn-reject.The comment claims "a click that did not land leaves the dApp promise unsettled and the assertion after the call still fails". That holds for
#btn-reject-signand#btn-reject-tx, whose disconnect handler leaves the approval pending. It does not hold for the site prompt: a page that went away without the click landing produces a port disconnect, which settles 4001, which is exactly whatassertUserRejectionaccepts. Soeth_requestAccounts rejected at the prompt returns a rejection (#183)(run.js:1878) can now pass without the reject button ever having been clicked. The approve test at run.js:1907 is unaffected — its assertion issettled === "resolved", which a swallowed click cannot produce.Acceptable: narrow the swallow to the approve button, or say plainly in the comment that the site-reject case is not discriminated by its own assertion.
4. Nit —
src/background/index.js:324:resetPopupUrl()aftersettleApproval()is dead.settleApprovalalready calls it (line 152). Carried over from theAUTISTMASK_APPROVAL_RESPONSEhandler it replaces; same at line 335.Verified green and not at issue: the primary fix is correct.
make checkgreen ond32ffe7(710 tests, 29 suites, prettier clean);make test-e2egreen 40/40 in the pinned container with the harness accommodation removed, including test 33 (approve-then-immediate-close in real Chromium); the two port-channel unit tests go red against thenextsource and green with it; the sender check on the decision is load-bearing; no double settle and no approve-after-close in any interleaving I drove; basenext; CI green; mergeable; title carries(closes #275); no attribution trailers.Disclosure: the FIFO claim on the production
chrome.action.openPopup()path is not measurable from this harness, as the PR body states; I did not independently verify it and accept the disclosure.make test-e2e-firefoxwas not run by me either.d32ffe7c3ato3430b1136fReworked on
3430b11, rebased ontonextatd9d50f0. The port-channel fixitself is untouched.
1. The fallback-window test now fires
bg.closeWindow(1)BEFOREport.decide(true, false), so the approval is still pending whenwindows.onRemovedreaches it and the guard really runs(
tests/backgroundApproval.test.js, "approving in the fallback window survivesa window event that lands first"). The old ordering is kept as a second, named
case. Neutering
src/background/index.js:888(
if (isSite && approval.portConnected) continue;) puts exactly the newone red:
Guard restored: 33/33.
2.
portConnectedis now set only forisExtensionSender(port.sender)(
src/background/index.js:316), the same check the decision path takes. Newtest "a port from a page sender does not silence the window event": a page
sender connects, holds the port open, the window closes, and the dApp must
still get 4001. Reverting the gate to the unchecked assignment:
Received: nullis the hang: nothing settles the approval at all. Gaterestored: 33/33.
3. Correct: the site reject was not discriminated by its own outcome. The
clickAndClosecomment now states per button what does prove the click landed,and the site-reject call site witnesses it directly —
armClickTrace()installsa capture-phase listener that writes one key with
localStorage.setItem()before the button's handler runs, and
assertClickLanded()reads it back fromenv.page(same extension origin). Observation only; nothing is deferred orpatched. Probe: replacing the click with
popup.close()— the vacuous case —leaves every rejection assertion passing and fails only on the new one:
That log line is printed by
assertUserRejectiononly after all five of itsassertions pass, so it is the vacuity itself, captured. The approve test is
unchanged.
4. Both
resetPopupUrl()calls aftersettleApproval()removed. Removingthe one in
onDisconnectalso drops a stale reset on the branch where theapproval was already gone, which could have cleared the popup URL a newer
prompt had just set.
Not a finding, decided:
decideSite()now wraps thepostMessage()in atry.runtime.sendMessage()did not throw synchronously, so before this PR adead worker still let the popup close; on a port it throws and the close would
be skipped, leaving the popup stuck open. The approval died with the worker, so
there is nothing to report and the only correct action left is to close. Not
unit-tested: no popup-view harness exists and the condition is a torn-down
worker.
Verification, after the rebase:
make fmt,make checkgreen (712 tests, 29suites, prettier clean);
make test-e2egreen 44/44 in the pinned container,one clean run, no flake hit.
make test-e2e-firefoxnot run. PR body updatedwhere it claimed the outcome assertion proved the site-reject click.
FAIL — needs-rebase.
1.
src/background/index.js— thewindows.onRemovedlistener conflicts semantically withnextatc06765e(#271), and the obvious resolution regresses it.git merge origin/next(c06765e) into3430b11leaves one conflict, in exactly the function this PR rewrites.nextnow reads:This PR's side replaces the whole body with its own inline
isSiteternary and a baresettleApproval(id, ...). Taking this PR's side verbatim drops bothabandonedResult()and thewindowClosedassignment — andreleaseApproval()innextsettles onapproval.windowClosedprecisely so a tx approval whose window was closed during an in-flight attempt does not hold the page's promise (and the new single-approval slot) open forever. Resolving by "ours" therefore silently reintroduces the hang #271 fixed.Acceptable resolution — keep
next's settle shape and add only the new guard;abandonedResult()already produces{approved: false, remember: false}for a site approval, so this PR's ternary becomes redundant:Re-run
make checkandmake test-e2eafter the rebase;tests/backgroundApproval.test.jsauto-merges but both sides added cases to it and the combined file is unverified.Everything else checked passes: the fix is correct and the tests are not vacuous — reverting
src/with the tests kept puts 3 cases red, and each of the three guards neutered individually puts exactly its own named case red (portConnectedskip → "window event that lands first"; theisExtensionSendergate on connect → "a port from a page sender does not silence the window event"; the gate on the decision message → the page-sender case). No coded rejection stripped, invented or misreported; 4001 still on every rejection path in both shapes.Disclosures: gates run by me on
3430b11— containerizeddocker build .with theRUN make checklayer executing UNCACHED (DONE 20.4s, 29 suites / 712 tests, prettier clean, lint clean);make test-e2egreen 44/44 in the pinned Playwright container, one run, accommodation removed, no flake.make test-e2e-firefoxnot run. The FIFO ordering claim for the productionchrome.action.openPopup()path is not measurable from this harness, as the PR body states — not independently verified, disclosure accepted. My g3 probe (decision sender check removed) crashed the suite rather than failing an assertion, which still demonstrates the check is load-bearing but is a weaker signal than g1/g2. All probes were run in a throwaway copy of my own clone, since deleted; nothing was pushed and no branch was mutated.3430b1136fto43d533d1b943d533d1b9to87358e08e8Rebased,
87358e0. No substance change.What the rebase pulled in. Base moved twice while I worked:
d9d50f0→c06765e(#271, theabandonedResult()/windowClosedwork) →743b196(#259, the browser suites in
CI). Two conflicts, both resolved by hand; everything else auto-merged.
src/background/index.js,windows.onRemoved— resolved as specified inthe review:
next's settle shape kept verbatim, this PR contributing only the skip. Finalbody:
The
isSiteternary is deleted. Confirmed redundant before deleting, notassumed:
abandonedResult()returns{approved: false, remember: false}onthe non-tx/sign branch, and
{error: {code, message}}otherwise withAPPROVAL_REJECTED_CODE === 4001andAPPROVAL_REJECTED_MESSAGE === "User rejected the request."— the same two values the ternary produced, on the sametwo branches.
const isSitestays because the skip above still needs it.TODO.md— both conflicts were additions at the same position, all entrieskept.
git diff origin/next -- TODO.mddeletes 0 lines; the#259,
#271 and
#275 entries are all present.
Combined test file.
tests/backgroundApproval.test.jsauto-merged and bothsides' cases run together: 42 cases in that file, 746 total across 30 suites
(was 712/29 pre-rebase — the delta is
#271's).
Gates, on
87358e0:make fmt— clean, no changes.make check— green, 746 passed / 746 total, 30 suites, prettier clean.script/cibuild— green. TheRUN make checklayer executed UNCACHED(
#11 [7/8] RUN make check...#11 DONE 18.2s), same 746/30, prettierclean. This is the containerized coverage, since
script/linton the host isprettier-only.
make test-e2e— green, 44/44 in the pinned Playwright container, one run,no flake hit. Includes
ok 36 - eth_requestAccounts rejected at the prompt returns a rejection (#183)andok 37 - eth_requestAccounts approved returns the selected address (#183). Re-run on the final head after#259 rewrote
script/test-e2e.Probes, proving both sides survive the resolution.
(a)
if (isSite && approval.portConnected) continue;neutered →exactly one case red, the one it exists for:
(b) With that still neutered,
#271's
windowClosedcasesstay green — so the guard is not carrying them and they are not carrying it:
(c) Restored →
make checkgreen, 746/746, and the working tree isbyte-identical to the commit (
git statusclean,git diffempty), so therestore is the committed text and not a re-typing of it.
Mergeable per Gitea.
make test-e2e-firefoxstill not run. Nothing merged andnextnot pushed.FAIL — needs-rework. Scope: the conflict resolution only.
src/background/index.js:1105-1106— theisSite &&conjunct inif (isSite && approval.portConnected) continue;is load-bearing in production but has zero test coverage.src/popup/views/approval.js:444opens theapproval:<id>port inshow()before the type is known, andsrc/background/index.js:497-504setsportConnected = truetype-agnostically — so a tx/sign approval in the fallback window also carries the flag. Drop the conjunct andwindows.onRemovedskips a tx approval whose popup connected:windowClosedis never set,releaseApproval()never settles it, and the dApp hangs — the exact #271 regression this resolution exists to prevent. I removed the conjunct in my clone and the suite stayed 746/746 green, so nothing guards it. Acceptable: one case asserting a tx approval whose window closes with its port connected still settles 4001. This passes on the committed code and fails with the conjunct dropped (verified both ways):The committed behaviour is correct — this is a missing regression test on the one line that keeps #271 alive, not a live defect.
Everything else in scope passes:
abandonedResult()/settleApproval()/releaseApproval()and thewindowClosedwrite are byte-identical tonext; the deleted ternary is exactly equivalent toabandonedResult()on both branches (4001 / "User rejected the request."); both sides' cases survived the auto-merge (+278/-2, the two deletions being stub lines replaced by capturing stubs, no assertion dropped or weakened);TODO.mdis +12/-0 with #259/#271/#275 intact; PR body claims match the tree.Probes, re-derived independently: (a) neutering the guard reddens only
approving in the fallback window survives a window event that lands first; (b) with it still neutered #271's fourwindowClosedcases stay green; (c) restored source is byte-identical to the commit (sha256) and all green.Gates:
make check746 tests / 30 suites green; containerizedmake checkgreen with theRUN make checklayer executing UNCACHED;make test-e2e44/44 including cases 36 and 37; CI green on87358e0; merges clean onto currentnext(ab1c184, no conflicts).Disclosures: all mutations were made in a scratch clone and reverted (source sha256-verified back to the committed bytes); nothing committed or pushed. To prove uncached execution I ran
docker build --no-cache .—script/cibuild's own command plus the flag — scoped to this single image, with no prune; the image was removed afterwards.make test-e2e-firefoxnot run locally; CI'se2e-firefoxis green on the head commit.87358e08e8toeb7cacc4f2eb7cacc4f2tof14b3b8bdcf14b3b8bdctoeb4b053f02Reworked,
eb4b053. Nosrc/behaviour change.1. Test added verbatim as supplied, in the
a site connection decided as the popup closesblock:a tx window closed with the port connected still rejects. Discrimination re-derived on the final base — withisSite &&present 761/761; with it dropped 760/761, sole failure being the new case:Conjunct restored: source sha256 back to the committed bytes,
git diffempty, 761/761.Rebase.
nextmoved three times during this run:4b7a678(#153) →47bf386(#152) →7690fe6(#280); #265 came along the way. Conflicts, all by hand:src/background/index.js—windowsApi→windowsNsand theruntimeApi()/windowsApi()accessors from #153; thewindows.onRemovedbody is unchanged from the resolution you approved.src/popup/views/approval.js—show()is nowasync/await sendMessage(...)per #153; the port connect keeps itsapprovalPort =binding.init(ctx)→init(_ctx)per #152's eslint.TODO.md— additions at the same position both times, everything kept.git diff origin/next -- TODO.mdis +12/-0.One code change outside the test, forced by #152's new eslint:
tests/e2e/run.jsassertClickLanded()hadlet seen = null;, flaggedno-useless-assignmentsince the loop assigns before any read. Nowlet seen;. Nothing else in the diff.grep -rnE '\bchrome\.|\bbrowser\.' src/— 202 hits, no code: 190 blocklist hostnames insrc/shared/phishingBlocklist.json, 9 doc-comment lines insrc/shared/browserApi.jsitself, and 3 prose comments elsewhere (src/shared/etherscanLabels.js:83,src/popup/restorableViews.js:15,src/popup/viewRouter.js:13).Gates, on
eb4b053:make fmt— clean.make check— exit 0, 761 passed / 761 total, 31 suites; eslint and prettier clean in the pinned lint container.make test-e2e— 51/51 in the pinned Playwright container, one run, includingok 43 - eth_requestAccounts rejected at the prompt returns a rejection (#183)andok 44 - eth_requestAccounts approved returns the selected address (#183).make test-e2e-firefoxnot run.Mergeable per Gitea. Nothing merged;
nextnot pushed.PASS. Narrow re-review only: the #153 conflict resolution, the added
isSite &&test, and theassertClickLandedinitialiser.Disclosure: verified by mutation probe in a throwaway clone (both mutations reverted, tree back at
eb4b053, nothing committed or pushed); evidence returned to the requester.