fix: approving a site connection races the popup teardown and can be recorded as a rejection #275
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
btn-approveinsrc/popup/views/approval.jscallsruntime.sendMessage()and thenwindow.close(). The close disconnects the approval port, andport.onDisconnectsettles a pending site approval as a rejection. Whether the user's approval or their window closing arrives first is a race.Measured by the e2e work on #183: driven in a tab, the teardown wins every time — the user clicks Approve and the dApp is told they rejected. The harness has to defer that one
window.close()and close the page itself after the outcome, with the reason documented at the call site.Transaction and signature approvals are NOT affected: their disconnect handler leaves the approval pending rather than rejecting it. It is the site-connection path specifically.
Reachability — establish this before choosing a fix
The measurement is from a tab, because the browser-action popup cannot be driven (headless Chromium opens it but Playwright never exposes it as a page). In production the site prompt goes through
chrome.action.openPopup(), and the teardown timing there may differ. So the observed "every time" is a tab result, not necessarily a production result.That uncertainty is a reason to fix it, not to defer it: the ordering is unspecified either way, and the failure mode is that a user who approved a connection is reported as having refused it.
Implementation requirements
chrome.action.openPopup()path.Definition of done
window.close()accommodation can be removed, or the PR body says why it must stay.TODO.mdupdated in the same commit.make checkpasses.Plan.
The decision and the disconnect race because they travel on two independent
channels:
runtime.sendMessage()is a one-off port of its own, the teardownrides the approval port, and nothing orders them relative to each other. No
amount of claiming or flag-setting in the background fixes that, because the
claim would have to arrive over the same racing channel.
So: move the site-connection decision onto the approval port the popup already
opens in
show().port.postMessage()thenwindow.close()puts the decisionand the disconnect on ONE channel, which is FIFO — the decision is delivered
first by construction, whatever the teardown timing is. The background settles
on that message;
onDisconnectthen finds nothing pending and rejects nothing.Close-without-deciding still disconnects with the approval pending, so it still
rejects, and so does Reject.
AUTISTMASK_APPROVAL_RESPONSEgoes away with itssender check carried over to the port (the id comes from the port name, so the
popup no longer names an approval id at all).
This is the tx/sign shape — the deciding party holds the channel and the
disconnect defers to it — without giving the site path a
windows.onRemovedbackstop it cannot have: underchrome.action.openPopup()the toolbar popup is not a window, so the portdisconnect is the ONLY close signal, which is why leaving it pending outright
is not available here.
The popup does not delay its close and no timing is tuned.
Proof, both demonstrated red against the unfixed code first:
tests/backgroundApproval.test.js— raise a site approval, post thedecision on the port, disconnect immediately, assert the dApp gets the
address. Driven in both shapes, including the production one where
action.openPopup()succeeds and no window exists. Plus: disconnect with nodecision rejects, Reject rejects, a decision from a non-extension sender is
ignored.
tests/e2e/run.js— remove the deferred-window.close()accommodation, sothe shipped popup really does approve-then-immediate-close in a real
Chromium and the existing "approved returns the selected address" assertion
becomes the real-browser proof. If it will not come out green the
accommodation stays and the PR body says why.
The PR body will state what I established about
chrome.action.openPopup()versus the tab measurement, and will carry the red output.
Built as planned, in
#289 (base
next).Definition of done:
the approval port, which is the same channel the close disconnects, so it is
delivered ahead of the disconnect.
windows.onRemovedalso stopped decidinga site approval whose port is connected: in the fallback-window shape that
event raced the decision on a channel of its own, the same defect one level
over.
before its popup ever connected still rejects, so nothing hangs.
tests/backgroundApproval.test.js(decision and close with nothing awaitedbetween them, in the toolbar-popup shape production uses and in the
fallback-window shape) and, now, in a real Chromium.
TODO.mdupdated in the same commit.Demonstrated red first. Real browser, accommodation removed, unfixed code:
eth_requestAccountscame back to the page as{"settled":"rejected","code":4001}after Allow was clicked, and the sevenlater tests fell over behind it because the origin never became authorized —
29/37. Unit: the two approve-then-close cases failed with the 4001 rejection
and the four rejection cases passed, which is the defect exactly.
Verified on the branch rebased onto
nextat0be20d7:make fmt, thenmake checkgreen (710 tests, prettier clean), andmake test-e2egreen 40/40in the pinned container.
make test-e2e-firefoxwas not run. Full evidence andwhat I established about the production
chrome.action.openPopup()path are inthe PR body.