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.
97 lines
4.8 KiB
JavaScript
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 };
|