harden: make the background physically unable to read the shared state singleton #344
Reference in New Issue
Block a user
Delete Branch "fix/324-background-state-singleton"
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 #324
closes #320
Root-cause change, not a sixth point fix. The background no longer holds an
in-memory copy of the profile at all,
src/shared/state.jsis unreachable fromits bundle, and the build fails if it ever becomes reachable again.
Evidence attribution
Every measurement below is marked [this head] (
c8d758f) or[carried forward] with the commit it was taken at. The carried-forward rows
are the eight specifier shapes from round 2, measured at
cf8cb24, which is nolonger fetchable; the matching logic they exercise is unchanged since, and
shape 1 was re-measured on this head. Everything else in this body was measured
on
c8d758f.What changed
src/background/state.js(new) — the background's whole access to theprofile.
getState()is a detached, normalized per-call read;updateState(fn)is a queued read-modify-write whose read is one storage round trip ahead of its
write. No module-level copy, because the MV3 worker has no lifetime to hold one
over. The write is the WHOLE record, and the header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
activeAddressOf(s)replaced a second, latergetState()that could disagreewith the first.
wallet_switchEthereumChainappliesapplyChainSwitchFields()(split out ofchainSwitch.js, which keeps the singleton path for the popup) insideupdateState(), instead ofonChainSwitch()on the singleton.wrapped around a prompt the user takes seconds to answer.
backgroundRefresh()refreshes a private copy of the wallets andapplies the balances that came back BY ADDRESS. It never publishes an object
other in-flight work holds, and a wallet added or deleted during the round
trip survives its write.
the same snapshot.
getProvider(rpcUrl, networkId)now REQUIRES the network id, validatedagainst
networks.js.refreshBalances(),lookupTokenInfo(),scanForAddresses()andresolveEnsName()carry it through;balances.jsnolonger requires
state.jsat all.Loud failure: reading a persisted field of the singleton before any load
throws
StateNotLoadedErrorinstead of servingDEFAULT_STATE.Mechanical enforcement: the guarantee is the bundler's, and it is tested
Two review rounds found four evasions of a hand-rolled ESLint matcher. Each was
the same mistake — the rule reimplemented a JS parser and a module resolver, and
it will keep diverging from esbuild. So the guarantee moved to the build.
The table lives in
script/lib/forbiddenBundleInputs.js: one map from arepo-relative entry point to the modules its bundle may not contain, read by
BOTH layers. It was two literal copies of the same path (
build.jsand therule), which is precisely how a rename disarms one layer while the other still
looks enforced — that is now one copy.
build.js'sassertNoForbiddenInputs()is the guarantee. It fails thebuild when esbuild's own metafile reports a forbidden module as an input of that
entry point's output, and names the import chain by walking the metafile's own
input graph. It consults the resolution esbuild actually performed, so specifier
syntax and resolution rules are not modelled at all.
Dockerfile:42runsmake build, so it is enforced in CI.Background entry points are protected by default
A bundled entry point under
src/background/with no line in the table nowfails the build. Adding a second worker entry point is exactly the kind of
accident this exists for, and the person adding one has no reason to know a
table elsewhere needs a line.
src/background/is the build's only notion of"the background" — entry points are the paths handed to
bundle()— andeslint.config.jsscopes the lint rule from the same exported constant, so thetwo layers cannot disagree about which files that is.
src/background/worker2.jsmake lintmake buildgrep -c StateNotLoadedError= 1 in the new bundle in both browsers [this head, on18ad93b]src/background/worker2.js is a background entry point with no line in FORBIDDEN_INPUTS[this head]dist/chrome/src/background/worker2.js bundles src/shared/state.js, which src/background/worker2.js must not reach: src/background/worker2.js -> src/shared/state.js[this head]src/background/state.js, listed in the tableAnti-rot: every way the table can rot now fails
Each half of an entry rots independently, and each one turns the prohibition
into a pass that checks nothing.
src/background/renamed.jsmake buildexit 2,src/background/renamed.js is listed in FORBIDDEN_INPUTS but was not bundled, so nothing checked it[carried forward,cf8cb24; pinned by a unit case re-run on this head]src/shared/stateRenamed.jsmake buildexit 2,... but this build bundled it nowhere, so the prohibition names a module that is not in this tree at that path and nothing enforces it[carried forward,cf8cb24; pinned by a unit case re-run on this head]"src/background/index.js": [], plus a plainrequire("../shared/state")in the worker — before this roundmake lintexit 0,make buildexit 0,grep -c StateNotLoadedError= 1 in BOTH background bundles [this head, on18ad93b]make lintexit 2,make buildexit 2,make testexit 2, all withFORBIDDEN_INPUTS["src/background/index.js"] lists no modules, so it prohibits nothing while still looking enforced[this head]The empty list is refused at require time, where the table is defined, because
that one also empties the lint rule's forbidden set (
Object.values(...).flat())— the build's own check cannot help a layer that never reaches the build.
assertForbiddenTableCovered()re-checks it so the build's half does not dependon where the table was loaded from.
The check can no longer record itself as done before it runs
record.entriesChecked.add(entry)ran BEFORE the metafile output lookup thatproduces the inputs. Any early return past that point left BOTH halves of the
guarantee satisfied by an entry point whose bundle was never examined. Measured
on
18ad93b: replacing theesbuild reported no metafile outputthrow withif (!entryOutput) return;wasmake test919/919, exit 0 [this head, on18ad93b].The
addnow happens after the inputs are in hand, so that same edit is caughttwice over: the entry is not recorded, and
assertForbiddenTableCovered()failswith
is listed in FORBIDDEN_INPUTS but was not bundled. The throw itself ispinned. With the disarming edit applied to the fixed tree,
make testis1 failed, 929 passed —
an output esbuild did not report fails, and records nothing as checked[this head].The lookup is genuinely the fragile step, which is why it is not left as the
only line of defence:
repoRelative()resolves againstprocess.cwd()whileesbuild's metafile output keys are cwd-relative.
The ESLint rule stays as fast local feedback, reading the same table, and is
described that way everywhere.
Per-shape evidence
The eight shapes below were each applied alone and reverted, with
make lintinthe pinned container and a full
make build. Shapes 2-8 are [carriedforward] from
cf8cb24;assertNoForbiddenInputs()'s matching logic isunchanged since. Shape 1 is [this head].
On the unmodified branch
make buildis exit 0, the receipt records 15 emittedfiles with 4 containing
constants.js, andgrep -c StateNotLoadedErroris0 on both
dist/chrome/src/background/index.jsanddist/firefox/src/background/index.js, 1 on both popup bundles [this head].make lintmake buildrequireglobalThis.__probe1 = require("../shared/state").state;src/background/index.js -> src/shared/state.js[this head]requirerequire(`../shared/state`)import()const m = await import("../shared/state");in an async fnmodule.exports.state = require(`./state`).state;appended tosrc/shared/chainSwitchFields.jsindex.js -> chainSwitchFields.js -> state.jsfromre-exportimport { state as __probe5 } from "../shared/state";index.js -> state.jsrequire(/* probe */ "../shared/state").staterequire("../shared/state" /* probe */).statepackage.jsonmainhopsrc/shared/probepkg/package.json={"main": "./bridge.js"},bridge.jsrequires../state, background requires../shared/probepkgindex.js -> probepkg/bridge.js -> state.jsdist/was absent after each failure (make buildwraps every step inscript/discard-dist-on-failure), which is why there is no grep column: thebuild never emitted a bundle to grep.
On shape 5: under this repo's commonjs
languageOptionsan ESMimportis aparse error, so what
make lintreports there is the parser, not the rule. Therule does cover the shape —
tests/backgroundStateLintRule.test.jspins it witha
sourceType: "module"fixture — but the repository lint run cannot be citedas evidence of it. It is the build that blocks it here.
Shapes the rule does not report, and the build does
All re-measured on this head, applied alone and reverted:
make lintmake buildrequire("../shared/" + "state")src/background/index.js -> src/shared/state.js[this head]import("../shared/" + variable)in an async fnimportChain()returns null and the message degrades rather than crashing) [this head]src/shared/stateLink.jstostate.js, required by the backgroundsrc/background/index.js -> src/shared/state.js(esbuild reports the real path) [this head]Both are now pinned in
tests/backgroundStateLintRule.test.jsas assertedNON-reports, so the rule's stated bounds are measured rather than claimed. That
is what
TODO.mdsays now; the previous round'sTODO.mdsaid they were"pinned by" that file when they existed only in its header comment.
The guarantee's own coverage
make checkdoes not runmake build, soassertNoForbiddenInputs()had ZEROcoverage in it.
build.jsrunsbuild()only underrequire.main === moduleand exports the checks;
tests/buildForbiddenInputs.test.jsdrives themagainst SYNTHETIC metafiles in esbuild's shape — no
dist/, no shelled-outbuild. Seventeen cases; the nine added this round are marked NEW:
(
index.js -> chainSwitchFields.js -> state.js);above);
the ordering fix, asserted through
assertForbiddenTableCovered()as well asthrough the throw;
importChain()terminates on a CYCLIC input graph and still finds the module;importChain()terminates and returns null when a cycle cannot reach it;src/background/needs no line;recordBundledInputs()records every input of every output, and thecovered check then passes on what it collected with nothing hand-seeded;
popup-only build, because a stale key over a bundled background entry point is
now the stronger failure above and fires first);
assertTableWellFormed()accepts the shipped table, rejects an entrywith no modules, and rejects a table with no entries.
Fail-first, this round [all this head]:
if (!entryOutput) return;in place of the throwan output esbuild did not report fails, and records nothing as checked(was 919/919 green on18ad93b)recordBundledInputs()body replaced with a no-oprecords every input of every output, satisfying the module half(was 919/919 green on18ad93b)"src/background/index.js": []make test2 suites fail to load,make lintexit 2,make buildexit 2The rule's own coverage is pinned
tests/backgroundStateLintRule.test.jsruns the rule through eslint'sLinterover real fixture trees in a temp dir. Thirteen cases: quoted, backtick, dynamic
import(), staticfrom, bare side-effectimport, comment before thespecifier, comment after it, the two-hop re-export, the
package.jsonmainhop, a clean background that reaches only
src/background/state.js, therepository's own
src/background/index.js(which must report nothing), and NEWthis round the two divergences as asserted non-reports.
Neither non-report case is vacuous [both this head]:
resolveRelative()widened withfs.realpathSync()— a plausible one-line wideninga symlink to the module is not reportedrequirea computed specifier is not reportedSource comments now credit the build
src/background/index.js:11,src/shared/state.js:12andsrc/shared/state.js:60named the ESLint rule as the enforcement. The third isthe written justification for the scoped loud-read guard, so it pointed at the
layer this PR demoted. All three now name
build.js's metafile assertion;eslint.config.js's own comment says the rule is the early report, not theguarantee.
Fail-first evidence (behaviour)
[carried forward, reproduced by three reviews] The three behavioural tests were
run against
src/reverted to head (git stash push -- src/, tests kept):tests/backgroundStateIsolation.test.js"the artifact isbroadcast to the endpoint of the chain it was verified against":
Expected: {"txHash": "0xfeed"}/Received: {"error": "The signed transaction is for a different network than the one that was approved.", "retryable": false, "stage": "verify"}.the refresh":
Expected: "1.5"/Received: "0.0".tests/coldWorkerSendTransaction.test.js"a cold send on Sepoliareaches the approval screen and goes out":
Expected: 11155111n/Received: 1n.Two of the five new cases pass on head, correctly and by design: "a cold send on
mainnet is prepared for mainnet" is the swap guard, and "a wallet added
mid-refresh survives the refresh's write" is already covered by the per-field
merge from #304.
getProvider()call-site audit (every site, background-reachable marked)Background-reachable, all three previously built with the mainnet fallback:
src/background/index.jshandleSendTransaction()— the#320 defect; now
getProvider(s.rpcUrl, s.networkId)from the handler's snapshot.src/background/index.jsAUTISTMASK_TX_RESPONSEbroadcast — wasgetProvider(state.rpcUrl): missing hint AND an endpoint read later than thechain id. Now both from one snapshot.
src/shared/balances.jsrefreshBalances()— reached frombackgroundRefresh(). Same missing hint; now takesnetworkId.Popup-only, no background path, all now pass
state.networkIdexplicitly:balances.jslookupTokenInfo()(addToken.js,settingsAddToken.js),balances.jsscanForAddresses()(addWallet.js, two sites),ens.jsresolveEnsName()(addressDetail.js,addressToken.js),send.js,confirmTx.js(three sites),txStatus.js.Test stub audit
chrome.storage.localis a serialization boundary. Eight files stubbed it withan aliasing
get, so the object a module held and the object "storage" heldwere one object. All eight now use
tests/support/storageStub.js, whichstructured-clones in both directions.
Assertions whose meaning changed:
tests/chainSwitchGate.test.js— mockedsrc/shared/statewholesale and hada no-op
set, so "the switch happened" was read off the mock's ownin-memory object and no persistence was exercised at all. Rewritten against
real storage:
bg.walletState()now reads the written record.tests/backgroundApproval.test.js— same shape, plus a mocked state modulethat supplied the chain.
setNetwork()now moves the storednetworkIdandendpoint together;
setActiveAddress()writes to storage. The two tests thatinjected a failing
loadStatenow install a hook on the state read, armedAFTER the approval is raised so it is the attempt's read that fails.
tests/settingsUtcTimestamps.test.js—expect(second.state.utcTimestamps) .toBe(false)beforeloadState()asserted the default of an unloaded module.That is now an error by design, so it asserts the throw instead.
tests/networkEndpoints.test.js—written()fed the next module load theprevious one's live object as its "persisted bytes"; the restart it simulates
now crosses a real serialization boundary.
tests/alarms.test.js— the "open popup just refreshed" case pokedstore.autistmask.lastBalanceRefreshon an object the worker shared; it nowwrites to storage. The latency simulation is preserved through
onOp.tests/txStatus.test.js,tests/state.test.js,tests/coldWorkerChainSwitch.test.js— stub replaced; no assertion changedmeaning.
tests/deleteWalletLostPassword.test.jsandtests/stateMerge.test.jskeptprivate
makeStorage()helpers. Both cloned correctly, so nothing was wrong —but
tests/support/storageStub.js's header says "nothing rebuilds a storagestub by hand". Both now call
makeStorageStub();_raw()becameread().tests/coldWorkerChainId.test.jsalso already cloned correctly and is leftalone. The remaining stubs are stateless —
getreturns a fresh{}andsetdiscards.
Disclosures
assertion is keyed by path, so a duplicate of the singleton's code at another
path is outside it.
cp src/shared/state.js src/shared/stateCopy.jsplus abackground
requireof the copy ismake buildexit 0,make lintexit 0, and
grep -c StateNotLoadedError= 1 in BOTH backgroundbundles [this head]. Deliberately not fixed. A copy carries the singleton's
own guard, so defects 1-3 of
#324 — a read of a field nothing
loaded — become a loud
StateNotLoadedErrorrather than a silentDEFAULT_STATE. Defects 4 and 5 do NOT: a copy also carriesloadState(),and a stale read several awaits after a load, or a load detaching the objects
an in-flight handler is mutating, are silent over a LOADED singleton whether
it is the original or a copy. A newly WRITTEN singleton has no backstop at
all. All of that is recorded in
script/lib/forbiddenBundleInputs.js.build and missed by the rule [this head]. Fine under the two-layer framing,
and now pinned as asserted non-reports rather than only described.
src/background/is covered byneither layer's default and needs its own line in the table. That is the bound
of the prefix rule, and it is the narrowest defensible definition: the build
has no other notion of a background entry point, and the lint rule's glob is
scoped from the same constant. Recorded where the table lives.
updateState()is not reentrant, and says so. Amutatethat callsupdateState()itself deadlocks the background: the queue is strictly serial.No call site does this today; the queue is deliberately NOT redesigned here
and the trap is named in the header comment above
updateState().updateState()writes the whole record. A popup write that lands insidethe one-round-trip window between its read and its write is reverted, in every
field. Accepted — the window is one storage round trip and the popup is not
writing while the worker is — and now stated in the header rather than left
for the next reader to derive.
read of a persisted field before
loadState()— unless this context hasALREADY ASSIGNED into it. The cost: a context that writes one field and then
reads a different, untouched one is still served that field's default.
Nothing in
state.jscloses that; what closes it for the background is thatthe background cannot reach the module at all, which is the build's assertion.
The alternative (per-field tracking) was implemented and measured first — it
failed ~94-103 tests across popup suites unrelated to this issue, which two
reviews independently reproduced and adjudicated as fixture gaps.
applyChainSwitchFields()does not callclearPrices();onChainSwitch()(popup) still does. The price cache is in-memory and per bundle, and the
background never fills it, so the background's old call cleared nothing.
tests/deleteAddress.test.jsandtests/transactions.test.jsgained anetwork id / a
loadState()in setup: both read state that nothing hadloaded.
normalizePersisted()now structured-clones the nested collections itreturns, so a caller may mutate the result freely.
wallet_switchEthereumChainas its trigger rather thanthe
eth_chainIdnamed in the issue: that path was already moved offloadState()by #317.Verification
[all this head]
make checkgreen onc8d758f, which is one commit on top ofnextat36bc6be: exit 0, 51 suites, 930 tests,test-verify-build46 cases,
check-censored175 tracked files, lint in the pinnedcontainer (
[lint 1/1] RUN make lintexecuted uncached,DONE 5.1s, with itsoutput), prettier clean. (Earlier revisions of this body quoted 45/861/162,
49/906/172, 50/911/173 and 51/919/175 — the figures before each rebase onto a
moving
nextand before each round's new tests.)make buildexit 0 on the unmodified branch, exit 2 for every mutation above.dist/removed afterwards; the working tree and the pushed branch are identicaland carry no probe fixture, symlink or scratch file;
docker ps -alistsnothing, and no image tag was removed.
FAIL —
needs-rework. One blocking finding; everything else verified and holds.BLOCKING: the singleton is still reachable from the background, and
make checkis green over itscript/lib/eslint/noStateSingletonInBackground.js:31—const REQUIRE_RE = /\brequire\(\s*["']([^"']+)["']\s*\)/g;The reachability walk matches only
require()with a double- or single-quoted specifier. Two specifier shapes are invisible to it, and esbuild statically resolves both, sosrc/shared/state.jslands in the emitted background bundle with no check failing.Measured in a clean clone at
4343d8f, appending tosrc/background/index.js:const probeI = require(`../shared/state`);make check→ exit 0. 853/853 tests pass,[lint 1/1] RUN make lintexecutes and reports no problems, prettier reports "All matched files use Prettier code style!".make build→grep -c StateNotLoadedError dist/chrome/src/background/index.js= 1 (0 on unmodified head). Same for"was read before loadState"and"identity collision merging". The singleton is in the worker bundle.await import("../shared/state.js")inside anasync functionmake lintclean;grep -c StateNotLoadedErroron the built background bundle = 1.For contrast, both shapes the PR body demonstrates do fire, and I reproduced them: the direct quoted require, and the two-hop re-export appended to
src/shared/chainSwitchFields.js(src/background/index.js -> src/shared/chainSwitchFields.js -> src/shared/state.js). The hole is the specifier syntax, not the graph walk — the same two shapes evade it at any hop, so a shared module the background already pulls in can re-export the singleton through a backtick require and stay green.Why it matters here specifically: the DoD of #324 is "enforced mechanically, not by review", and the whole argument for the weakened
StateNotLoadedErrorguard (Disclosure 1) is that its hole "is closed for the background by unreachability". An enforcement that a plainrequirewith the wrong quote character walks through is not the unreachability that argument rests on.Acceptable: widen the pattern to cover backticks and
import(, e.g./\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)/g, and add a rule test (or a lint-fixture check) that pins each of the four shapes — quoted require, backtick require,import(), and a two-hop re-export. Deriving the graph from esbuild's metafile instead of a regex would also close it and would match whatbuild.jsalready does forAUDITED_MODULE.Non-blocking
src/background/state.js:66—updateState()deadlocks on reentrancy: amutatethat itself callsupdateState()queues behind the turn that is awaiting it. Confirmed empirically (inner update never runs; the outer turn only completes because the probe raced it against a timeout). No current call site does this, and the header warnsmutate"must not do anything slow" but says nothing about calling back in. Worth one line in that comment.tests/deleteWalletLostPassword.test.js:102andtests/stateMerge.test.js:18each still carry a private hand-rolled cloningmakeStorage(), whiletests/support/storageStub.jsstates "nothing rebuilds a storage stub by hand". Both clone correctly, so nothing is hidden — it is a consistency point, not a defect.tests/audited" enumeration omitstests/approvalDisplayFloor.test.js. It is stateless (get: async () => ({})), so the conclusion is unaffected; the list is just short by one.Verified and holding
Sweep of all of
tests/found no storage stub the eight-file audit missed — the eight go throughtests/support/storageStub.js(whichstructuredClones onget,set,readandwrite),coldWorkerChainId/stateMerge/deleteWalletLostPasswordalready cloned, and every remaining stub is stateless. All three fail-first claims reproduce exactly againstsrc/reverted tonext(11155111nvs1n;{"txHash":"0xfeed"}vs the three-key "different network" refusal;"1.5"vs"0.0"). EverygetProvider()call site passes a loaded id; no default and no guess remains.updateState()serializes two concurrent updates, survives a throwing mutate without stalling the queue, and awaits an async mutate before writing. Disclosure 3 holds:pricesis written only byrefreshPrices(), called only fromsrc/popup/index.js, andclearPricesno longer appears in the background bundle at all. Disclosure 4 is two genuine fixture gaps, not a masked regression. Disclosure 2 holds —eth_chainIdis already offloadState()onnext, andwallet_switchEthereumChainexercises the same detach.make checkgreen in my own clone with[lint 1/1] RUN make lintexecuted rather thanCACHED; CI green on4343d8f; fast-forwardable ontonextat669c443; one commit, title ends(closes #324), body closes #320; base isnext; no Claude/Anthropic references or attribution trailers anywhere.On Disclosure 1: the stated hole is real, and the 103 failures are what the author says they are. I rebuilt the strict per-field guard and measured 94 failures across 8 suites on this tree — every one a popup unit test that hand-builds the singleton and then has a view read
state.networkIdthe fixture never set (e.g.tests/txStatus.test.jsviacurrentNetwork()→explorerUrl()→etherscanAddressUrl()). Fixture churn, not 94 production sites reading unloaded state; the popup callsloadState()at boot. The weakened form is acceptable on the merits — and note it is in fact stronger for the background than claimed, since nothing in the background ever assigns into the singleton, soadoptedstays false and a stray read would throw. That remains a second line of defence rather than the first, which is why the lint hole above is the blocking item.4343d8fc77to277ec8c8f8FAIL —
needs-rework. One blocking finding; everything else re-derived independently and holds.BLOCKING: a sixth site can still be created — two shapes evade the rule and land in the shipped worker bundle
Both measured in a clean clone at
277ec8c:make lintin the pinned container, thenmake buildand grep forStateNotLoadedError,was read before loadStateandidentity collision mergingindist/chrome/src/background/index.jsanddist/firefox/src/background/index.js(all three are 0 in both bundles on unmodified head).1. A comment inside the
require()call.script/lib/eslint/noStateSingletonInBackground.js:47—SPECIFIER_REallows only\s*between(and the opening quote (and between the closing quote and)), so any comment token breaks the match while esbuild resolves the call normally.src/background/index.js:globalThis.__probe = require(/* probe */ "../shared/state").state;make lint→ exit 0. Fullmake check→ exit 0 (45 suites, 861 tests, "All matched files use Prettier code style!"). The comment is prettier-stable —make fmtleaves it untouched — so nothing in the repo objects.make build→ all three markers = 1 in BOTH the Chrome and Firefox background bundles.require("../shared/state" /* p */)behaves identically: eslint reports nothing, prettier leaves it alone, singleton in the bundle.require("../shared/state",)(trailing comma) andrequire ("../shared/state")(space before the paren) also produce no eslint error; those two are normalized away by prettier, so they failmake fmt-checkrather than shipping — but they show the match is fragile in three independent places, not one.import(/* webpackChunkName: "…" */ "…")is the standard bundler-annotation idiom, and any inline/* eslint-… */inside the call has the same effect.2. The file resolution disagrees with esbuild, independently of the matcher.
resolveRelative()(lines 51-67) tries onlybase,base + ".js",base + ".json"andbase/index.js. esbuild resolves a directory through itspackage.jsonmain, so the walk stops at a specifier it matched perfectly well.src/shared/probepkg/package.json={"main": "./bridge.js"}andsrc/shared/probepkg/bridge.js=const { state } = require("../state");, then insrc/background/index.js:globalThis.__probe6 = require("../shared/probepkg").state;make lint→ exit 0, prettier clean.make build→StateNotLoadedError= 1 in both background bundles.Why this blocks: #324's DoD is "enforced mechanically, not by review", and the scoped loud-read guard is justified in the PR body by the background being unable to reach the module at all. Neither holds while an ordinary, prettier-stable construct walks through. Widening the regex again does not close it — the two failures are in different halves of the rule (textual matcher vs hand-written resolver), and each rework so far has closed one shape and left the next.
Acceptable: make the check authoritative rather than approximate — assert from esbuild's metafile that
src/shared/state.jsis not an input of the background bundles.build.jsalready does exactly this forAUDITED_MODULE(outputsContainingAuditedModule()), andDockerfile:42runsmake buildin CI, so the assertion would be enforced on every push. Keeping the lint rule as fast feedback is fine; the guarantee should not rest on the regex. Whatever is chosen, add both shapes above totests/backgroundStateLintRule.test.js, and correct the "covers every specifier syntax esbuild resolves statically" claim, which currently appears in the rule header, the test-file header, the commit message, the PR body andTODO.mdand is false as written.Re-derived, and holding
All four claimed shapes fire (exit 2, one error, chain named): quoted require, backtick require, dynamic
import(), and the two-hop backtick re-export throughsrc/shared/chainSwitchFields.js. With the old regex restored, exactly 5 of 8 rule tests fail — backtick, dynamic import, staticfrom, bare import, two-hop — as claimed. The negative case is not vacuous: pointingFORBIDDENatsrc/shared/networks.jsmakes "the repository's own background entrypoint" fail, and so does revertingsrc/tonext. All three fail-first claims reproduce exactly (11155111nvs1n; the "different network" refusal;"1.5"vs"0.0").make checkgreen in my own clone with[lint 1/1] RUN make lintexecuted uncached (DONE 4.9s, with output); CI green on277ec8c; fast-forwardable ontonextat669c443; one commit, title ends(closes #324), body closes #320; base isnext; no AI-vendor references or attribution trailers anywhere. Working tree and pushed branch are identical and carry no probe mutation, stray fixture or scratch file. Thetests/deleteWalletLostPassword.test.js/tests/stateMerge.test.jsreroute is mechanical (_raw()→ the stub'sread(), both clone on the way out); no assertion changed meaning;tests/coldWorkerChainId.test.jsclones both ways and does not drivestate.js, so leaving it is justified.updateState()'s queue is unchanged and the reentrancy deadlock is named in its header. The scoped guard reasoning still holds: nothing in the background assigns into the singleton, soadoptedstays false there.277ec8c8f8to480563cd16480563cd16tocf8cb248abFAIL —
needs-rework. Three defects plus one required test. The new mechanism itself held against every evasion I could construct; the findings are in its edges, its wording, and its own lack of coverage.1.
build.js:35-37— the anti-rot check covers only half the tableA
FORBIDDEN_INPUTSkey that no entry point matches fails the build, as claimed:src/background/renamed.jsgives exit 2,is listed in FORBIDDEN_INPUTS but was not bundled, so nothing checked it. A forbidden module that no longer exists does not. I changed the value alone tosrc/shared/stateRenamed.js:make buildexit 0, receipt written,verify-buildandcheck-censored --require-distpass, nothing said.So renaming or moving
src/shared/state.jssilently disarms the guarantee — and disarms the lint rule in the same moment, sincescript/lib/eslint/noStateSingletonInBackground.js:46is a second literal copy of the same path. No test pins either: the rule test's only repo-facing case is a negative one, which passes vacuously after a rename. #311 is sequenced next and rewrites this exact persistence layer, so this is not a hypothetical rot. The commit message's "A FORBIDDEN_INPUTS key that matches no bundled entry point also fails, so the table cannot rot into a vacuous pass" overstates: half the table can.Acceptable: assert that every module named in
FORBIDDEN_INPUTSwas bundled into at least one output of this build. The popup bundlessrc/shared/state.js, so that holds today and is stronger than anexistsSync— it fails both on a rename and on a module that stops being built at all.2. The "computed specifier" claim is false, in three places
script/lib/eslint/noStateSingletonInBackground.js:38-40, the header oftests/backgroundStateLintRule.test.js, and the commit message all sayrequire("../shared/" + "state")is deliberately unmatched because "esbuild cannot resolve that statically either, so it never reaches the bundle" / "there is nothing to block".Appended verbatim to
src/background/index.js,make buildis exit 2:dist/chrome/src/background/index.js bundles src/shared/state.js, which src/background/index.js must not reach: src/background/index.js -> src/shared/state.js. esbuild folds the concatenation. The variable form,await import("../shared/" + part), is resolved too — as a glob import — and is also caught, withimportChain()returning null so the message correctly degrades to no chain rather than crashing.The build catches both, which is the redesign working. But the stated reason for not matching them is wrong, and it is exactly the sentence a future reader would cite for not widening anything. Acceptable: "not matched by this rule; the build's metafile assertion catches it".
3. Three source comments still name the lint rule as the enforcement
The correction landed in the five places listed, but not in:
src/background/index.js:11-14— "is deliberately NOT imported here and must never be: see the header ofsrc/background/state.js, and the lint rule that enforces it ineslint.config.js."src/shared/state.js:12-17— "this module is unreachable from the background bundle (enforced by the ESLint rule ineslint.config.js, and by the background having its own per-call storage layer ...)".src/shared/state.js:60-61— "what closes it for the background is that the background cannot reach this module at all (eslint.config.js)."The third is load-bearing: it is the written justification for the scoped loud-read guard, and it points at the layer this PR just demoted to best-effort. Same class as #331 and #309. All three should name
build.js's assertion.4. The guarantee is unpinned, and must not stay that way
make checkdoes not runmake build, soassertNoForbiddenInputs()has zero coverage in it. CI executes it (Dockerfile:42, viascript/cibuild), but executing is not testing: invert the condition, or make theFORBIDDEN_INPUTS[entry]lookup always come back undefined, and every check in this repo stays green while the singleton walks back into the worker. That is precisely the failure modescript/test-verify-build's own header records four consecutive reviews of, and the reason that 46-case harness exists.The stated obstacles do not bind.
build.jsrunsbuild()at load — guarding that one call withrequire.main === moduleand exporting the helpers makes them testable with nodist/written and no build shelled out, against synthetic metafiles. Required assertions:inputsthrows, and the message names the import chain;importChain()terminates and returns a chain over a cyclicinputs[].importsgraph;FORBIDDEN_INPUTSkey that no bundled entry point matched fails;Non-blocking, for disclosure
An equivalent singleton that is copied rather than imported is invisible to both layers.
cp src/shared/state.js src/shared/stateCopy.jsplus a backgroundrequire:make buildexit 0,make lintexit 0, andgrep -c StateNotLoadedError= 1 in bothdist/chrome/src/background/index.jsanddist/firefox/src/background/index.js. This is inherent to a path-keyed assertion and no code change is asked for — for a literal copy the proxy guard still throws in the background (nothing there assigns, soadoptedstays false), but a newly written singleton carries no such backstop. It belongs in the PR body's disclosures.A symlink to the singleton is caught by the build (esbuild reports the real path) and missed by the rule (
make lintexit 0) — a ninth known matcher divergence, and fine under the new two-layer framing.FORBIDDEN_INPUTSprotects the one entry point it names; a second background-side entry point added later would be covered by the rule (globsrc/background/**) but not by the build. Low priority, noted.Verified and holding
All eight shapes re-derived, each applied alone and reverted: every one is
make buildexit 2 fromassertNoForbiddenInputs(), with the chain named through both the two-hop (index.js -> chainSwitchFields.js -> state.js) and thepackage.jsonmainhop (index.js -> probepkg/bridge.js -> state.js). Shape 5's honesty holds exactly —make lintreportsParsing error: 'import' and 'export' may appear only with 'sourceType: module', not the rule, and the build blocks it regardless. No false positive: unmodified branchmake buildexit 0,StateNotLoadedError= 0 in both background bundles and 1 in both popup bundles.277ec8cis no longer fetchable, so I reconstructed the round-2 matcher by hand (reverting the comment gap, thepackage.jsonmaincandidates and the trailing\s*\)); with it, exactly the 3 new rule cases fail, 908/911 — restored, 911/911. All three fail-first claims reproduce (11155111nvs1n; the "different network" refusal;"1.5"vs"0.0").getProvider()refuses an unknown network id loudly.tests/support/storageStub.jsclones both ways.make checkgreen in my own clone: 50 suites, 911 tests,test-verify-build46 cases,check-censored173 tracked files,[lint 1/1] RUN make lintexecuted uncached (DONE 5.4s, with its output), prettier clean. CI green oncf8cb24including both e2e jobs; fast-forwardable ontonextat36bc6be; one commit, title ends(closes #324), body closes #320; base isnext; no AI-vendor reference or attribution trailer anywhere; working tree and pushed branch identical, carrying no probe, fixture or scratch file.cf8cb248abto18ad93be45FAIL —
needs-rework. Four findings, all in the guarantee's own guard ratherthan in shipped behaviour: the assertion held against every evasion I could
build, and all three documented bounds are true as measured on this head.
1.
build.js:139— an entry is marked "checked" before it can be checked, and the throw that saves that is untestedrecord.entriesChecked.add(entry)runs immediately after the table lookup, fourlines before
inputsis resolved. IfassertNoForbiddenInputs()leaves earlyafter that point, the entry is still recorded as checked and
assertForbiddenTableCovered()passes — so the coverage half cannot detect acheck that bailed out. The only thing standing in that gap today is
build.js:145-147,esbuild reported no metafile output for ${out}, and thatbranch has ZERO test coverage.
Measured on this head: change those three lines to
if (!entryOutput) return;and nothing else —
make testis 919/919 passed, exit 0. In that state bothhalves of the guarantee are satisfied by an entry point whose bundle was never
examined, and
make buildexits 0.The lookup is not hypothetically fragile:
repoRelative()resolves a RELATIVEpath against
process.cwd(), and esbuild's metafile output keys arecwd-relative, so any change to where the build runs from, to
outfilevs
outdir, or to output naming makes it miss. Today that throws loudly, whichis correct — but nothing pins it, and the ordering means it is the last line of
defence rather than the second.
Acceptable: move
record.entriesChecked.add(entry)to afterinputsissuccessfully built, and add a case to
tests/buildForbiddenInputs.test.jsasserting the no-matching-output throw.
2.
script/lib/forbiddenBundleInputs.js:51— an empty module list is a third vacuous pass, and it disarms both layers at onceassertForbiddenTableCovered()fails a stale KEY and a stale MODULE, bothconfirmed. It does not fail an entry whose list is empty, and neither does the
rule:
FORBIDDENthere isObject.values(FORBIDDEN_INPUTS).flat(), so an emptyvalue leaves it with nothing to look for.
Measured,
"src/background/index.js": []as the only edit, plusglobalThis.__probeEmpty = require("../shared/state").state;appended tosrc/background/index.js:make lintexit 0make buildexit 0grep -c StateNotLoadedError= 1 in BOTHdist/chrome/src/background/index.jsanddist/firefox/src/background/index.jsNothing is reported anywhere. The singleton is back in the shipped worker with
every check in the repo green — the precise outcome the header at
script/lib/forbiddenBundleInputs.js:46-49says cannot happen ("Both halves arechecked for rot ... each fail the build rather than passing vacuously"). Fixing
it is one line: fail when a listed entry names no modules.
3.
TODO.md— "the two it is known to miss ... pinned bytests/backgroundStateLintRule.test.js" is falseThat file has eleven cases and none of them is the computed specifier or the
symlink; both appear only in its header comment, lines 23-26. Nothing asserts
that the rule does not report them, so nothing would notice if that changed. The
PR body's own wording for the same fact ("record ... as measured known
divergences") is accurate;
TODO.mdsayspinned byand is not. Third round ina row that a claim about what pins or enforces what has been wrong, which is why
it is itemized rather than waived.
4.
tests/buildForbiddenInputs.test.js—recordBundledInputs()is imported but never actually pinnedEvery
assertForbiddenTableCovered()case hand-seedsrecord.bundledInputs.add(STATE), and the one case that reads the function'soutput asserts
bundledInputs.has(STATE)is false — which is trivially trueif it records nothing. Measured: replace the body of
recordBundledInputs()with a no-op and
make testis 919/919, exit 0. That function is the soledata source for the module half of the anti-rot check added this round, so that
half rests on an unpinned helper. Its failure direction is fail-safe
(under-recording throws), so this is the smallest of the four — but one case
driving the real function into the covered check closes it.
Disclosure correction, no code change asked for
The COPY residual's stated reason is that "a copy carries the singleton's own
guard, so a background read of an unloaded field throws
StateNotLoadedError... loud". True, and I reproduced the whole bound (
make buildexit 0,make lintexit 0, marker = 1 in both background bundles). But it only coversdefects 1-3 of #324. A copy also
carries
loadState(), and defects 4 and 5 — astate.rpcUrlread severalawaits after a load, and a load detaching the objects
backgroundRefresh()ismutating — are silent over a LOADED singleton, copy or not. The accepted
residual is wider than the reason given for accepting it, and the sentence
should say so.
Related, and worth one line where the two residuals are recorded: they compound.
A second background entry point is invisible to the build (confirmed:
src/background/worker2.jsrequiring the singleton, named nowhere in the table,is
make buildexit 0 with the marker = 1 in the new bundle, caught only bymake lintexit 2). A second entry point that reaches the singleton by ashape the rule also misses — computed specifier, symlink — is green everywhere.
Verified on this head, holding
Guarantee not evaded by anything I tried: quoted require, computed
require("../shared/" + "state")(lint 0 / build 2), globimport("../shared/" + variable)(build 2, chain correctly degraded),symlink (lint 0 / build 2), stale MODULE (exit 2, named message), stale
KEY, a module path spelled
./src/shared/state.js(exit 2 via the modulehalf).
require.main === moduledid not change the build:make buildexit 0,receipt written (15 files, 4 audited),
verify-buildandcheck-censored --require-distpass,StateNotLoadedError0 in both backgroundbundles and 1 in both popup bundles. The new test drives the SHIPPED helpers
(
require("../build")); the fail-first claim reproduces exactly — the key-onlyform of
assertForbiddenTableCovered()gives 1 failed, 918 passed, thatcase, that message — and inverting the input condition, the table lookup, or the
key check each fail 4, 4 and 1 cases respectively. The three behavioural
fail-first claims reproduce against
src/atnext(11155111nvs1n;"1.5"vs"0.0"; the "different network" refusal), and the rule test'srepository case fails there too, so it is not vacuous.
make checkgreen in myown clone: 51 suites, 919 tests,
check-censored175 files,[lint 1/1] RUN make lintexecuted uncached (DONE 5.3s, with output),prettier clean. CI green on
18ad93b(check, e2e-chrome, e2e-firefox);fast-forwardable onto
next; one commit, title ends(closes #324), bodycloses #320; base is
next; noAI-vendor reference or attribution trailer anywhere; working tree and pushed
branch identical, no probe, symlink, fixture or scratch file left, no container
started that survives.
Note on evidence attribution:
cf8cb24is no longer fetchable, so the eightcarried-forward rows cannot be re-derived at that commit. Everything the body
marks "re-measured on this head" I reproduced, and four of the carried rows I
re-measured independently on this head also match, so the attribution reads as
honest.
18ad93be45toc8d758f4ebPASS. All five round-5 claims and all five documented bounds re-measured independently on
c8d758fin a fresh clone and correct;make checkgreen (51 suites, 930 tests,test-verify-build46 cases,check-censored175 tracked files,[lint 1/1] RUN make lintexecuted uncachedDONE 5.2swith its output, prettier clean); CI green on the head;nextat36bc6beis an ancestor; one commit, no AI-vendor reference or trailer;build.jsread in full is coherent after the disclosedgit checkoutmishap, and no probe artifact, symlink or scratch file survives anywhere.Two items, neither blocking.
esbuild.build()call rather than throughbuild.js'sbundle()helper never reachesassertNoForbiddenInputs(). Measured on this head (src/background/worker2.jsrequiring the singleton, bundled directly):make buildexit 2 — but fromscript/verify-build(carries a debug marker but the build did not record it as containing src/shared/constants.js), not from the forbidden-input assertion. Not a silent hole, and reliably not one:src/shared/state.jsreachessrc/shared/constants.jsthrough bothpersistedState.jsandlog.js, so any bundle containing the singleton carries the marker. Recorded as a bound, not a defect.tests/buildForbiddenInputs.test.js:210, "the prefix is the one the lint rule is scoped to", reads nothing fromeslint.config.js; it assertsBACKGROUND_ENTRY_PREFIX === "src/background/"against a literal. The underlying claim is true —eslint.config.js:13-14,106builds its glob from the imported constant, and a top-levelsrc/background/worker2.jsis reported bymake lint— and the assertion is loud in the direction that matters, since changing the constant fails the case. The case name promises more than it asserts.Reviewer disclosure: the fail-first reproduction (
"1.5"vs"0.0",11155111nvs1n, and the "different network" refusal — all three reproduce againstsrc/atnext) was run withyarn jeston two files rather than through amake/scriptentrypoint. Everything else, and every lint run, went through them.