Files
AutistMask/src/background/state.js
T
clawbot ad6aa7b20d
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m46s
e2e / e2e-firefox (push) Successful in 34s
fix: version stored state, validate its shape, and give a corrupt blob a way out (closes #311)
Stored state had no version and no structural validation, so a corrupt blob produced a completely blank popup with no message and no recovery control, and made every dApp RPC call from every page answer a generic -32603. There was no reset or wipe control anywhere in the UI.

saveState() now stamps a schema version and loadState() validates the shape. A version it does not understand, or a wallets array it cannot parse, lands on a recovery screen that names the problem, offers the stored record verbatim for export, and offers a destructive reset behind a typed confirmation. Unversioned but valid state -- which every existing install has -- migrates in place and keeps working; it is never shown a wipe prompt. A dApp call against unusable state answers -32007, which EIP-1474 leaves unassigned, rather than -32603. networkById() refuses an unknown id loudly instead of returning mainnet, and networkId is validated so a corrupt value cannot be used as an object key.

Fields the gate does not refuse are floored by type, container and entries both: a malformed trackedTokens or tokenBalances entry is dropped rather than dereferenced. Verified by an independent sweep of 1152 corrupt blobs producing no blank popup, with the same harness showing 9 blanks against the previous revision.
2026-08-23 20:04:00 +02:00

97 lines
4.8 KiB
JavaScript

// The background's access to the persisted profile.
//
// There is no in-memory copy here, and that is the whole design. The MV3
// service worker is terminated when idle and revived by the next message, so
// anything held at module scope is either absent or arbitrarily stale, and
// src/shared/state.js's module-level `state` singleton — which nothing in the
// worker ever populates — silently served DEFAULT_STATE to whoever read it.
// Five defects came out of that (https://git.eeqj.de/sneak/AutistMask/issues/324),
// and every point fix for one of them added a loadState() that created the
// next: loading detaches the objects an in-flight handler is holding.
//
// So the background reads per call and writes read-modify-write:
//
// getState() one storage read, normalized, detached. Nothing else
// holds the object it returns, so a handler may keep it
// across any number of awaits and no concurrent work can
// move it.
// updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all inside a queue, so two background writes
// never interleave, and the read is one storage round trip
// ahead of the write rather than a page lifetime ahead of
// it (which is what made the popup's saveState() need a
// per-field merge against a baseline at all).
//
// A handler that must both read and write therefore does its network work
// against a snapshot it owns, and applies the RESULT inside updateState().
// It never publishes an object other in-flight work is holding.
const { storageGet, storageSet } = require("../shared/browserApi");
const { normalizePersisted } = require("../shared/persistedState");
const {
STATE_SCHEMA_VERSION,
assertStateUsable,
} = require("../shared/stateSchema");
// A fresh, fully-normalized, detached copy of the persisted profile.
//
// Normalized rather than raw: a legacy or malformed record is self-healed the
// same way loadState() heals it for the popup, so the background is never the
// one context reasoning about a shape the rest of the extension repairs.
//
// Throws StateUnusableError for a record this build cannot make sense of,
// before normalization gets a chance to paper over it — the same gate, in the
// same place, as the popup's loadState(). Every handler that consults the
// profile comes through here, so a dApp call against such a record is answered
// with the specific error the dispatcher maps that to (src/background/index.js)
// rather than dereferencing its way into a generic -32603.
async function getState() {
const result = await storageGet("autistmask");
assertStateUsable(result.autistmask);
return normalizePersisted(result.autistmask);
}
// Serializes the read-modify-write turns below. Two of them interleaved would
// each read before the other wrote, and the second write would carry the first
// one's fields back to their pre-turn values.
let updateQueue = Promise.resolve();
async function updateStateOnce(mutate) {
const s = await getState();
await mutate(s);
s.hasWallet = Boolean(s.wallets && s.wallets.length > 0);
// Stamped on every write, exactly as the popup's saveState() stamps it:
// whichever context writes last, the record in storage is in this build's
// shape and says so.
s.schemaVersion = STATE_SCHEMA_VERSION;
await storageSet({ autistmask: s });
return s;
}
// Apply `mutate` to a record read fresh from storage and write the result
// back. `mutate` receives a detached, normalized profile and mutates it in
// place; it may be async, but it must not do anything slow — the window
// between the read and the write is the window in which another context's
// write is lost, and keeping it to one storage round trip is what makes a
// whole-record write safe here. Concretely: the write is the WHOLE record, so
// a popup write that lands inside that window is reverted, in every field, by
// the record this turn read before it. That is accepted because the window is
// one round trip long and the popup is not writing while the worker is;
// widening it is what would make it a real hazard.
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the queue is strictly serial, so the inner turn waits on
// the outer one, which is waiting on the inner one. That deadlocks the whole
// background, not just the caller. Mutate the record you were handed.
//
// Resolves with the record that was written.
function updateState(mutate) {
const turn = updateQueue.then(() => updateStateOnce(mutate));
// The queue must advance even when a turn rejects, or every update after
// it queues behind a promise that never settles.
updateQueue = turn.catch(() => {});
return turn;
}
module.exports = { getState, updateState };