Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile. The
write is the whole record, and updateState()'s header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's assertNoForbiddenInputs() fails the build when esbuild's
metafile reports src/shared/state.js as an input of a background bundle, naming
the import chain from the metafile's own graph. That is the resolution the
shipped bundle was built from, so no specifier syntax, no hop and no resolution
rule can slip past it; Dockerfile:42 runs make build, so it holds in CI.
A background entry point the table does not name fails the build as well. The
five defects were accidents, and so is adding a second worker entry point
without knowing that a table elsewhere needs a line for it: entry points under
src/background/ are prohibited by default and must be listed, rather than
protected only when someone remembers. That prefix is the build's only notion of
"the background", and eslint.config.js scopes the lint rule from the same
constant so the two layers cannot disagree about it.
Every way the table can rot is a failure rather than a quiet pass: a key no
bundled entry point matched, a listed module this build bundled nowhere, and an
entry that lists no modules. The second is what makes a rename of
src/shared/state.js loud instead of silently disarming the check, and it is
stronger than an existsSync() because it also fails when the module is still
there but has dropped out of every bundle. The third is refused at require time,
where the table is defined, because an empty list also empties the lint rule's
forbidden set — one character, and a plain require of the singleton in the
worker was green in make test, make lint and make build alike.
An entry is recorded as checked only once its bundle's inputs are in hand. It
used to be recorded before the output lookup that produces them, so an early
return past that point left both halves of the guarantee satisfied by a bundle
nothing had examined.
What the assertion does NOT cover is a COPY of the singleton at another path: it
is keyed by path, so a copy builds and lints clean. That is stated where the
table lives, with what the residual actually is — a copy carries the singleton's
own guard, so an unloaded read is a loud StateNotLoadedError and defects 1-3
cannot recur silently, but a copy carries loadState() too, so defects 4 and 5
(a stale read several awaits after a load, a load detaching objects an in-flight
handler is mutating) would recur over it in silence.
make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker. Each vacuous
pass above has a case, including the output lookup that finds nothing, the empty
list, the unlisted second entry point, and recordBundledInputs() itself, which
every other case used to hand-seed.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It reads
the same table, and it matches specifiers textually, so it is best-effort fast
feedback and not the guarantee — two earlier revisions of it shipped holes (a
template literal, a dynamic import(), a comment inside the call, a directory
resolved through package.json main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js. Two shapes it does not report are pinned
there as asserted non-reports, so the header's list of its bounds is measured
rather than claimed: a computed specifier (require("../shared/" + "state"),
which esbuild constant-folds into the bundle) and a symlink to the module
(esbuild reports the real path). Each is make lint exit 0 and make build exit 2.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
124 lines
6.4 KiB
JavaScript
124 lines
6.4 KiB
JavaScript
// The modules a given entry point's bundle may not contain, keyed by the
|
|
// repo-relative entry point.
|
|
//
|
|
// ONE table, read by both layers that act on it: build.js asserts it against
|
|
// esbuild's own metafile (the guarantee), and
|
|
// script/lib/eslint/noStateSingletonInBackground.js reports the same
|
|
// prohibition in the editor (fast feedback). It lives here because a second
|
|
// literal copy of the path is exactly how a rename disarms one layer while the
|
|
// other still looks enforced.
|
|
//
|
|
// src/shared/state.js holds a module-level `state` object, loaded once by
|
|
// loadState() and mutated in place from then on. That is the popup's model:
|
|
// one page, one load at boot, one lifetime. The MV3 service worker has no
|
|
// "once" — it is killed when idle and revived by the next message, nothing
|
|
// loads state at module scope, and an unpopulated read was answered out of
|
|
// DEFAULT_STATE in silence. Five defects came from that, one of which
|
|
// destroyed a wallet (https://git.eeqj.de/sneak/AutistMask/issues/324). The
|
|
// background has its own per-call storage layer in src/background/state.js
|
|
// instead.
|
|
//
|
|
// What build.js's assertion covers, measured rather than assumed:
|
|
//
|
|
// - Any import of a listed module, at any hop, in any specifier syntax,
|
|
// however esbuild resolved it. The check reads the input list esbuild
|
|
// reported for the emitted bundle, so it is the resolution the shipped
|
|
// file was built from and not a model of it. Measured on a computed
|
|
// specifier that esbuild constant-folds (`require("../shared/" +
|
|
// "state")`), on a computed specifier it resolves as a glob
|
|
// (`import("../shared/" + variable)`), and on a symlink to the module
|
|
// (esbuild reports the real path): each is `make build` exit 2.
|
|
//
|
|
// - Every background entry point, whether or not anyone remembered to list
|
|
// it. A bundled entry point under BACKGROUND_ENTRY_PREFIX with no line in
|
|
// this table fails the build (assertNoForbiddenInputs()), so adding a
|
|
// second worker entry point is protected by default rather than protected
|
|
// only if the person adding it knew about this file. Measured: bundling
|
|
// src/background/worker2.js with no line here is `make build` exit 2.
|
|
//
|
|
// - NOT covered: a COPY of a listed module at another path. The table is
|
|
// keyed by path, so `cp src/shared/state.js src/shared/stateCopy.js` plus
|
|
// a background require of the copy is `make build` exit 0 and `make lint`
|
|
// exit 0 (measured). The copy carries the singleton's own guard, so
|
|
// defects 1-3 of https://git.eeqj.de/sneak/AutistMask/issues/324 — a read
|
|
// of a field nothing loaded — become a loud StateNotLoadedError instead of
|
|
// a silent DEFAULT_STATE. Defects 4 and 5 do NOT: a copy also carries
|
|
// loadState(), 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. So the residual
|
|
// is wider than "it fails loudly". A newly WRITTEN singleton has no
|
|
// backstop at all.
|
|
//
|
|
// - NOT covered: a background-behaving entry point outside
|
|
// BACKGROUND_ENTRY_PREFIX. The default protection above is keyed on that
|
|
// directory, which is also what eslint.config.js scopes the rule to, so a
|
|
// worker entry point placed somewhere else is covered by neither layer and
|
|
// needs its own line here.
|
|
//
|
|
// The ESLint rule's bounds are its own and are narrower: it matches specifiers
|
|
// textually, so a computed specifier and a symlink to a listed module are
|
|
// reported by the build and not by the rule. Both are pinned as non-reports in
|
|
// tests/backgroundStateLintRule.test.js and are `make build` exit 2 (measured).
|
|
// A second background entry point reached by one of those two shapes is
|
|
// therefore caught by the build and not by the rule — which is the same
|
|
// division of labour as everywhere else here, not an extra hole.
|
|
//
|
|
// Every way the table itself can rot is a failure rather than a quiet pass:
|
|
//
|
|
// - a KEY no bundled entry point matched, and a listed MODULE this build
|
|
// bundled nowhere: assertForbiddenTableCovered(), at the end of a build;
|
|
// - an entry that lists NO modules, and a table with no entries at all:
|
|
// assertTableWellFormed() below, at require time — so it fails the build
|
|
// and the lint run alike, because the rule reads the same values and an
|
|
// empty list leaves it with nothing to look for.
|
|
//
|
|
// All of it is pinned by tests/buildForbiddenInputs.test.js.
|
|
|
|
// What counts as a background entry point, and therefore must be listed above.
|
|
// The build has no other notion of one: entry points are the paths handed to
|
|
// bundle(), and this prefix is the narrowest rule that names the worker's
|
|
// directory. eslint.config.js scopes the lint rule with the same prefix, from
|
|
// this constant, so the two layers cannot disagree about what "background"
|
|
// means.
|
|
const BACKGROUND_ENTRY_PREFIX = "src/background/";
|
|
|
|
const FORBIDDEN_INPUTS = {
|
|
"src/background/index.js": ["src/shared/state.js"],
|
|
};
|
|
|
|
// Refuse a table that cannot prohibit anything. An entry whose module list is
|
|
// empty passes every check in both layers while enforcing nothing: the build
|
|
// finds no module to look for and records the entry as checked, and the rule's
|
|
// forbidden set — Object.values(...).flat() — comes back empty, so a plain
|
|
// `require("../shared/state")` in the worker is green everywhere. That is a
|
|
// one-character edit, so it fails here, where the table is defined and both
|
|
// layers must load it, rather than in either layer's own checks.
|
|
function assertTableWellFormed(table) {
|
|
const entries = Object.entries(table);
|
|
if (entries.length === 0) {
|
|
throw new Error(
|
|
"FORBIDDEN_INPUTS is empty, so nothing is prohibited anywhere. " +
|
|
"Removing the last entry disables the guarantee behind " +
|
|
"https://git.eeqj.de/sneak/AutistMask/issues/324.",
|
|
);
|
|
}
|
|
for (const [entry, modules] of entries) {
|
|
if (!Array.isArray(modules) || modules.length === 0) {
|
|
throw new Error(
|
|
`FORBIDDEN_INPUTS["${entry}"] lists no modules, so it ` +
|
|
`prohibits nothing while still looking enforced. Give it ` +
|
|
`the modules that entry point may not reach, or remove ` +
|
|
`the entry.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
assertTableWellFormed(FORBIDDEN_INPUTS);
|
|
|
|
module.exports = {
|
|
BACKGROUND_ENTRY_PREFIX,
|
|
FORBIDDEN_INPUTS,
|
|
assertTableWellFormed,
|
|
};
|