fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 30s

Approve and window.close() left the popup on the next line, and the decision
and the disconnect the close caused travelled independent channels with nothing
ordering them. The disconnect handler settled a pending site approval as a
rejection, so whichever landed first decided the outcome. Driven in a tab the
teardown won every time: the user allowed the connection and the dApp was told
they had refused.

The decision now goes out on the approval port the popup already opens, 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 before the teardown is even seen, and the disconnect then
finds nothing pending to reject. Nothing waits, nothing is timed, and the popup
closes exactly as immediately as before.

windows.onRemoved no longer decides a site approval whose port is connected
either. 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.

Rejecting reports a rejection, and so does closing without deciding, in both
shapes. AUTISTMASK_APPROVAL_RESPONSE is gone; the port name carries the
approval id, so the popup no longer names one, and the sender check the message
carried moved to the port.

tests/backgroundApproval.test.js drives decide-then-disconnect with nothing
awaited in between, in the toolbar-popup shape that production uses and in the
fallback-window shape, and asserts every close-without-deciding path still
rejects. tests/e2e/run.js drops the deferred-window.close() accommodation it
carried for this bug, so the two site-prompt tests now drive the shipped
decide-then-close in a real Chromium.
This commit is contained in:
2026-08-14 04:22:12 +00:00
parent 0be20d7270
commit d32ffe7c3a
5 changed files with 373 additions and 76 deletions

View File

@@ -1596,32 +1596,13 @@ async function reserveApprovalTab(env) {
// one down with it.
env.approvalTab = await env.ctx.newPage();
// The one accommodation this section makes to the shipped code, and the
// reason for it.
//
// Both approval buttons call runtime.sendMessage() and then window.close()
// on the next line. Closing this page disconnects the approval port, and
// the disconnect handler in src/background/index.js settles a pending
// site approval as a rejection. In a tab those two race and the teardown
// wins: the approve message is never acted on, and the page is told the
// user rejected. Measured — with the close left in place the approval
// resolves as a rejection every time; with it deferred it resolves as an
// approval every time.
//
// It is deferred, not removed: the harness closes the page itself once
// the outcome has been observed, which is what window.close() would have
// done, only after the message it was racing has been processed.
//
// This affects the site-connection prompt only. The sign and transaction
// prompts run in windows the extension opens itself, with window.close()
// untouched, and their disconnect handler deliberately keeps a tx or sign
// approval pending rather than rejecting it — so there is no race there
// to accommodate. Whether the same ordering holds in a real toolbar popup
// is not observable from a headless harness and is reported rather than
// assumed either way.
await env.approvalTab.addInitScript(() => {
window.close = function () {};
});
// This tab runs the shipped popup with nothing patched. The site
// approval buttons decide and then close on the next line, and the two
// site-approval tests below are therefore the real-browser
// approve-then-immediate-close and reject-then-immediate-close cases: the
// decision rides the approval port, which also carries the disconnect the
// close causes, so it is delivered ahead of it and the outcome does not
// depend on the teardown timing (#275).
await env.approvalTab.goto("about:blank");
await sleep(APPROVAL_TAB_SETTLE_MS);
return env.approvalTab;
@@ -1670,6 +1651,28 @@ async function closeApprovalPages(ctx) {
}
}
// Click a button whose own handler closes the window it lives in — every
// Reject, and Allow on the site prompt.
//
// page.click() dispatches the click and then waits for the renderer to
// acknowledge it, and a page torn down by the handler never gets to. The
// dispatch is what the test needs and the log shows it happening ("performing
// click action") immediately before the failure; the page going away is the
// button working, not the click failing. Observed on #btn-reject-sign and
// #btn-reject-tx, whose windows have always closed themselves.
//
// This swallows nothing that matters: a click that did not land leaves the
// dApp promise unsettled and the assertion after the call still fails. A
// button that is missing or unclickable raises a different error, which is
// rethrown.
async function clickAndClose(page, selector) {
try {
await page.click(selector);
} catch (e) {
if (!String((e && e.message) || e).includes("has been closed")) throw e;
}
}
// Record every message the approval window sends to the background worker.
//
// This is the direct observation the password check needs. It is installed
@@ -1890,7 +1893,7 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
// origin in deniedSites and every later test in this section is
// auto-rejected with no prompt at all, which would look like a pass.
await popup.uncheck("#approve-remember");
await popup.click("#btn-reject");
await clickAndClose(popup, "#btn-reject");
await assertUserRejection(
env.dapp,
@@ -1921,7 +1924,7 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
await popup.check("#approve-remember");
await popup.click("#btn-approve");
await clickAndClose(popup, "#btn-approve");
outcome = await settleRequest(env.dapp, "accounts");
} finally {
@@ -2029,7 +2032,7 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign");
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
env.dapp,
@@ -2132,7 +2135,7 @@ test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", asy
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign");
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
env.dapp,
@@ -2290,7 +2293,7 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-tx");
await popup.click("#btn-reject-tx");
await clickAndClose(popup, "#btn-reject-tx");
await assertUserRejection(
env.dapp,