The popup's saveState() and the background's updateState() each read the stored record, change it and write it back, each ordered only within its own context, so a write by one could land between the other's read and write and be lost. Both now hold the browser's lock (navigator.locks) from the read to the write, through withLock() in src/shared/browserApi.js, and the recovery screen's reset takes it too. A page in a Firefox private window gets locks of its own and is not covered. The lock also orders a page's own saves, so both per-context queues are gone. The unit tests get a small lock from a Jest setup file. Model: opus-5-5
90 lines
4.6 KiB
JavaScript
90 lines
4.6 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 holding the lock every write of the stored
|
|
// record takes, so no other write lands in between, by
|
|
// the background or by any page except a page in a
|
|
// Firefox private window (README, "Stored state and its
|
|
// version"). 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, withLock } = 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);
|
|
}
|
|
|
|
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 write is the
|
|
// WHOLE record, which is safe only because the lock (withLock() in
|
|
// src/shared/browserApi.js) is held from the read to the write: two
|
|
// background turns, or a turn and a page's save, never interleave
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
|
|
// while it is held, except a page in a Firefox private window, which Firefox
|
|
// gives locks of its own (README, "Stored state and its version").
|
|
//
|
|
// `mutate` must also not call updateState() itself, directly or through
|
|
// anything it awaits: the inner turn waits for the lock the outer one holds,
|
|
// and the outer one is waiting on the inner one. That deadlocks every write of
|
|
// the stored record, not just the caller. Mutate the record you were handed.
|
|
//
|
|
// Resolves with the record that was written.
|
|
function updateState(mutate) {
|
|
return withLock("autistmask", () => updateStateOnce(mutate));
|
|
}
|
|
|
|
module.exports = { getState, updateState };
|