fix: answer the page when a background handler throws (closes #280) #282
Reference in New Issue
Block a user
Delete Branch "issue-280-handlerpc-catch"
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 #280.
What changed
handleRpc(...).then(sendResponse)had no.catch(), andsendResponseis theonly thing that settles the dApp's
window.ethereum.request()promise. Any throwinside
handleRpcsent nothing back, the content script posted nothing, and thepage's promise stayed pending forever — no error, no timeout, indistinguishable
from a slow wallet.
A rejected
handleRpcnow answers:-32603is the JSON-RPC internal error EIP-1474 defines and EIP-1193 defers to forRPC-layer failures. No EIP-1193 4xxx code describes "the wallet broke", and none was
invented. The cause is not put in
message: the page gets a stable full sentence,and
log.errorfputs the method and the throw on the background console, so thefailure is visible rather than swallowed.
Sibling-handler ruling
Complete inventory of async escape points in
src/background/index.js—grep -n "\.then\|\.catch\|(async ()"returns five sites:handleRpc(...).then(...)(async () => {...})()behindAUTISTMASK_TX_RESPONSE(async () => {...})()behindAUTISTMASK_SIGN_RESPONSEresult.catch(...)inopenApprovalstartBackgroundJobs().catch(...)The two IIFEs are the same shape one level down. Every statement is inside a
try,but a throw out of one of the
catchblocks escapes as an unhandled rejection, andneither the popup nor the page is ever answered. Each gets a last-resort
.catch()that settles the approval through
settleApproval()— the existing chokepoint,with no new
deleteorresolve— and then answers the popup. The transactionone reports
stage: "broadcast", because it cannot tell whether the transactionreached the network, and that is the wording that does not invite a second send.
Every other handler on the message path (
AUTISTMASK_GET_APPROVAL,AUTISTMASK_APPROVAL_RESPONSE,AUTISTMASK_ACTIVE_CHANGED,AUTISTMASK_REMOVE_SITE,and the synchronous branches of the two response handlers) is synchronous: it calls
sendResponseand returns before any await, so it cannot leave a promise pending.Tests
Three unit tests in
tests/backgroundApproval.test.js, each driven by a realfailure rather than a hook in the handler under test:
handleRpc: extension storage rejects, whichgetState()awaits unguarded, on aplain
eth_accounts.describeTxFailurethrows while classifying a genuineverification failure (an artifact signed at a nonce the approval never displayed).
failureIsRetryablethrows while classifying a genuine verificationfailure (the active address moved after approval).
Node aborts the worker on an unhandled rejection; a service worker does not — the
promise simply never settles. The suite records unhandled rejections instead of dying
on them, so the assertion that the page was answered is what reports the failure,
and the recording is asserted empty alongside it.
No e2e case. Provoking a storage failure in a real browser needs a contrived hook,
and
tests/e2e/harness.jsdocuments that Playwright exposes no error event forservice workers, so the harness could not observe it either way. The background
console line is the visibility, and the unit test is what fails on regression.
Demonstrated failing first
make teston this branch withsrc/background/index.jsreverted to itsnextstate (tests unchanged):Number of calls: 0on the RPC path is precisely the defect: nothing was ever sentback, so the page waits forever.
Received: nullon the two approval paths is thesame thing for the promise the approval holds.
make checkGreen with the fix in place:
Also re-run inside the container via
script/cibuild, which runsmake checkfromthe
Dockerfile. Step#11 [7/8] RUN make checkexecuted (notCACHED— thesource change invalidates the layer), with the same 684 tests, 18 verify-build cases
and clean prettier;
docker buildexited 0. No prune of any kind was run.make fmtrun and included;TODO.mdupdated in the same commit.handleRpc(...).then(sendResponse) had no .catch(), and sendResponse is the only thing that settles the dApp's window.ethereum.request() promise. Any throw inside handleRpc therefore sent nothing back: the content script posted nothing, and the page's promise stayed pending forever with no error and no timeout, indistinguishable from a slow wallet. handleRpc does real work -- state loads, provider calls, transaction population, approval plumbing -- so "it does not throw today" was not a property anyone was maintaining. A rejected handleRpc now answers { code: -32603, message }. -32603 is the JSON-RPC internal error EIP-1474 defines and EIP-1193 defers to for RPC-layer failures; no EIP-1193 4xxx code describes "the wallet broke" and none was invented for it. The cause is not put in the message: the page gets a stable sentence, the background console gets the method and the throw, so the failure is visible rather than swallowed. The two async IIFEs behind AUTISTMASK_TX_RESPONSE and AUTISTMASK_SIGN_RESPONSE are the same shape one level down. Every statement is inside a try, but a throw from one of the catch blocks escapes as an unhandled rejection and neither the popup nor the page is answered. Each gets a last-resort .catch() that settles the approval through settleApproval() -- the existing chokepoint, with no new delete or resolve -- and answers the popup. The transaction one reports the broadcast stage, because it cannot tell whether the transaction reached the network and that is the wording that does not invite a second send. Every other message handler on the path is synchronous and cannot leave a promise pending. Each of the three is driven by a real failure rather than a hook in the handler: a rejecting extension-storage read, which getState() awaits unguarded, and a failure classifier that throws while classifying a genuine verification failure. All three were demonstrated failing against the unfixed code, the RPC one with sendResponse at zero calls, which is precisely the page-side hang.FAIL —
needs-rework.1.
src/background/index.js:1103— the transaction last-resort catch reportsstage: TX_STAGE_BROADCASTon a path where the transaction provably never reached the network.The IIFE can only reject from inside one of its own two
catchblocks. The first (lines 1031-1052) runs entirely beforeprovider.broadcastTransaction()is ever called, and this PR's own test asserts exactly that attests/backgroundApproval.test.js:1150(expect(bg.broadcastTransaction).not.toHaveBeenCalled()). Withretryable: falseandstage: "broadcast",describeSigningFailure()(src/shared/approvalVerify.js:652-655) appends " The transaction may still have reached the network. Check the account before sending it again." andsrc/popup/views/approval.js:659renders it. So the one case the new test exercises tells the user their transaction may be on chain when it demonstrably is not — the exact copy defect #271 was filed over, whose definition of done reads "A transaction that failed on a nonce collision before broadcast reports copy that says so, not 'may still have reached the network'".The justification given at
src/background/index.js:1086-1088, attests/backgroundApproval.test.js:1155-1156, inTODO.mdand in the PR body — that the handler "cannot tell whether the transaction reached the network" — is false. It is one local away. Acceptable:let stage = TX_STAGE_VERIFY;in the IIFE, set toTX_STAGE_BROADCASTimmediately beforeprovider.broadcastTransaction(...), and reported from the last-resort catch — so a verify-phase escape reportsTX_STAGE_VERIFY("This request can no longer be signed. Please start it again from the site.") and only a broadcast-phase escape keepsTX_STAGE_BROADCAST, with a test asserting each.2.
tests/backgroundApproval.test.js:294-303— theprocess.on("unhandledRejection")recorder is unnecessary and its comment states behaviour this repo does not exhibit.The comment claims "Node aborts the worker process on an unhandled rejection". Under this repo's Jest 30 setup it does not. Removing only the
process.on(...)registration (keepingconst unhandledRejections = []) and re-runningmake testwithsrc/background/index.jsreverted tonextgives an identical red result —Tests: 3 failed, 681 passed, 684 total,Test Suites: 1 failed, 27 passed, 28 total— with no worker abort; with the fix in place and the registration removed,684 passed, 28 total. A strayPromise.reject()planted inside this same file also still fails its own test with the recorder installed, so it masks nothing either. The recorder and the threeexpect(unhandledRejections).toEqual([])assertions at lines 1110, 1159 and 1199 are dead weight carrying a false rationale in a security-critical test file. Acceptable: delete the registration, the array and the three assertions, keepingsettleIncludingRejections()with a comment describing what it actually waits for.Everything else checked passes, including the claim interlock (
holdsClaim: truecan only ever retire the approval this attempt holds — every escape out of both IIFEs is synchronous with respect to the lastclaimApproval/releaseApprovalmutation and.catch()runs as a microtask, andsettleApproval()deletes beforeresolve(), so a throw after a successful broadcast makes the last-resort settle a no-op and the page keeps itstxHash), the independently re-derived five-site sibling inventory,-32603and its full-sentence message, anti-vacuity re-verified by reverting the fix locally, containerizedmake check(layer#11 [7/8] RUN make checkran uncached in 19.8s: 684 tests, 18 verify-build cases, prettier clean), and a clean merge onto currentnext(0be20d7) with 706 tests passing on the merged tree.Disclosures: CI on
9665ac4has beenpending/"Waiting to run" since 06:06 with no runner picking it up, so it is neither green nor red — the containerizedmake checkabove is the substitute evidence. The sign path's last-resort deliberately omitsstage, which is correct.make test-e2ewas run five times:nextbaseline 40/40; PR head 36/37 then 33/37; PR merged withnext40/40 then 38/40 — different tests fail on each run and the merged tree went fully green once, so I attribute this to timing flakiness in the e2e harness under shared-host load rather than to this change, and it likely deserves its own issue.make test-e2e-firefoxwas not run.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.