fix(sidebar): make outbox records write-once so foreign reclaim is race-free

The per-window token/recheck reclamation still performed a non-atomic
compare-then-delete on a mutable foreign key: a live owner could rewrite
its key between the reclaim decision-read and the removeItem, and the
whole-blob `queuedAt <= head.created_at` gate dropped same-second and
legacy `queuedAt=0` records that had not provably lost LWW.

Records are now write-once: a key is `<prefix>:<pubkey>:<relay>:<nonce>:<seq>`
and is never rewritten. A new edit writes a new key (next zero-padded seq)
then deletes its own older keys (write-before-delete, so a crash leaves at
least one record). Foreign reclamation reads an immutable record, proves it
reclaimable against durable relay evidence, and deletes it with no recheck.
Whole-blob supersession is strict (`queuedAt < head.created_at`); replay runs
before reclamation in every hook so a same-second record is consumed into
pending first; the legacy v1 shared key is only ever replayed, never deleted.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-26 20:57:11 -04:00
parent adb3d17c19
commit 323bf68107
8 changed files with 413 additions and 194 deletions

View File

@@ -257,13 +257,15 @@ export function clearChannelSectionsOutbox(
}
/**
* Reclaim foreign outbox keys the relay head itself supersedes: a whole-blob
* record queued at or before the durable head's `created_at` (`queuedAt` ≤
* `headCreatedAt`) lost LWW to a blob the relay already holds, so dropping it
* matches the relay's own resolution. A record queued AFTER the head is live
* intent the head does not yet reflect and is kept — never reclaimed merely for
* losing a local max-`queuedAt` comparison. Never touches this window's own key
* and rechecks each key before removing. Call only after a successful fetch.
* Reclaim foreign outbox keys the relay head itself STRICTLY supersedes: a
* whole-blob record queued strictly before the durable head's `created_at`
* (`queuedAt` < `headCreatedAt`) lost LWW to a blob the relay already holds, so
* dropping it matches the relay's own resolution. A same-second record
* (`queuedAt` == `headCreatedAt`) is kept — one-second clock granularity cannot
* prove it lost, so it drains only when a strictly-newer head lands. A record
* queued after the head is live intent and is likewise kept. Records are
* write-once so the delete needs no recheck; never touches this window's own
* keys or the legacy shared key. Call only after a successful fetch.
*/
export function reclaimSupersededSectionsOutbox(
pubkey: string,
@@ -276,6 +278,6 @@ export function reclaimSupersededSectionsOutbox(
pubkey,
relayUrl,
parseChannelSectionPayload,
(record) => record.queuedAt <= headCreatedAt,
(record) => record.queuedAt < headCreatedAt,
);
}

View File

@@ -192,13 +192,15 @@ export function clearChannelSortOutbox(pubkey: string, relayUrl: string): void {
}
/**
* Reclaim foreign outbox keys the relay head itself supersedes: a whole-blob
* record queued at or before the durable head's `created_at` (`queuedAt` ≤
* `headCreatedAt`) lost LWW to a blob the relay already holds, so dropping it
* matches the relay's own resolution. A record queued AFTER the head is live
* intent the head does not yet reflect and is kept — never reclaimed merely for
* losing a local max-`queuedAt` comparison. Never touches this window's own key
* and rechecks each key before removing. Call only after a successful fetch.
* Reclaim foreign outbox keys the relay head itself STRICTLY supersedes: a
* whole-blob record queued strictly before the durable head's `created_at`
* (`queuedAt` < `headCreatedAt`) lost LWW to a blob the relay already holds, so
* dropping it matches the relay's own resolution. A same-second record
* (`queuedAt` == `headCreatedAt`) is kept — one-second clock granularity cannot
* prove it lost, so it drains only when a strictly-newer head lands. A record
* queued after the head is live intent and is likewise kept. Records are
* write-once so the delete needs no recheck; never touches this window's own
* keys or the legacy shared key. Call only after a successful fetch.
*/
export function reclaimSupersededSortOutbox(
pubkey: string,
@@ -211,7 +213,7 @@ export function reclaimSupersededSortOutbox(
pubkey,
relayUrl,
parseChannelSortPayload,
(record) => record.queuedAt <= headCreatedAt,
(record) => record.queuedAt < headCreatedAt,
);
}

View File

@@ -5,21 +5,25 @@ import test from "node:test";
//
// All four sidebar-sync lanes persist an unpublished edit so it survives a
// quit/community-switch inside the 2s publish debounce. localStorage offers no
// atomic compare-and-delete or transactional read-modify-write, so a single key
// shared across windows can never be mutated safely: one window's read→write or
// read→remove races a peer's write and drops its still-unpublished edit.
// atomic compare-and-delete or transactional read-modify-write, so neither a
// single shared key nor a per-window key a window OVERWRITES can be reclaimed
// by a peer safely: the value can change between the reclaim decision-read and
// the delete (the recheck race a byte-compare narrows but cannot close).
//
// The fix keys the outbox PER WINDOW: `<prefix>:<pubkey>:<relay>:<nonce>`, where
// the nonce is stable per window (sessionStorage). Each window is the sole
// writer of its own key, so a hot-path write is one unconditional `setItem` — no
// read, no shared-key contention (prong b is designed out, not guarded). Resume
// enumerates ALL windows' keys: merge lanes (stars/mutes) fold every record
// (order-independent); whole-blob lanes (sort/sections) replay the max-`queuedAt`
// record, ties broken by key. Redundant FOREIGN keys are reclaimed at boot,
// gated on durable relay evidence and re-read immediately before removal so a
// live peer's fresh write in the recheck gap is never destroyed. A window never
// removes another window's key on the hot path and never touches its own key
// during reclamation.
// The fix keys the outbox per window AND write-once:
// `<prefix>:<pubkey>:<relay>:<nonce>:<seq>`, where the nonce is stable per
// window (sessionStorage) and `seq` is a per-window monotonic counter. A window
// NEVER rewrites a key: a new edit writes a NEW key, then deletes its own older
// keys (write-before-delete, so a crash leaves ≥1 record, never zero). Because
// records are immutable, a booting peer that proves a foreign key reclaimable
// against durable relay evidence can delete it with no recheck — nothing can
// have changed at that key since the proof. Resume enumerates ALL windows'
// keys: merge lanes (stars/mutes) fold every record (order-independent);
// whole-blob lanes (sort/sections) replay the max-`queuedAt` record, ties broken
// by key. Reclamation runs AFTER replay so a same-second record the head appears
// to supersede is consumed into pending first. Whole-blob supersession is STRICT
// (`queuedAt` < head `created_at`) so a same-second record is never dropped, and
// the legacy v1 shared key is never deleted by v2 (it is mutable; only replayed).
//
// This matrix drives the shared helpers directly through a mock Storage seam —
// no relay, no timers — so each interleaving is deterministic. The seam mocks
@@ -102,8 +106,12 @@ const PREFIX = {
};
// A foreign window's key: same (pubkey, relay) scope, a different nonce than
// this process's own. `legacyKey` is the pre-per-window shared key (no nonce).
const foreignKey = (lane, nonce) => `${PREFIX[lane]}:${SCOPE}:${nonce}`;
// this process's own, plus a zero-padded `seq`. `legacyKey` is the
// pre-per-window shared key (no nonce/seq).
const SEQ_WIDTH = 12;
const pad = (seq) => String(seq).padStart(SEQ_WIDTH, "0");
const foreignKey = (lane, nonce, seq) =>
`${PREFIX[lane]}:${SCOPE}:${nonce}:${pad(seq)}`;
const legacyKey = (lane) => `${PREFIX[lane]}:${SCOPE}`;
const writeAt = (ls, key, store, queuedAt) =>
ls.setItem(key, JSON.stringify({ store, queuedAt }));
@@ -119,56 +127,64 @@ const sectionStore = (secs, assignments = {}) => ({
assignments,
});
// ── (i) Recheck skips a foreign write injected between decision-read and delete ─
// ── (i) Reclaim never deletes a foreign edit written in the decision→delete gap ─
//
// reclaimOutbox re-reads each foreign key immediately before removing it and
// skips when the value changed since the reclaim decision. A peer that rewrote
// its key in that gap owns a fresh edit that must survive. Merge lane (stars):
// the head subsumes the enumerated value, so reclaim would fire — but the peer's
// interposed write must be preserved.
// Records are write-once: an owner's fresh edit lands on a NEW key, never a
// rewrite of the enumerated one. reclaimOutbox proves the enumerated (stale)
// key reclaimable and deletes it; a peer edit injected during enumeration lives
// under its own key and is evaluated on its own merits (kept when the head does
// not subsume/supersede it). We interpose that fresh write via the read-mutate
// seam to prove GC removes only the proven-stale key and never the fresh one.
test("(i) stars: reclaim recheck skips a foreign key rewritten in the decision→delete gap", () => {
test("(i) stars: reclaim deletes the proven-stale key and keeps a fresh edit written in the gap", () => {
withStorage((ls) => {
const key = foreignKey("stars", "peerB");
const stale = foreignKey("stars", "peerB", 0);
// Foreign edit the fetched head has already absorbed → decision = reclaim.
writeAt(ls, key, starStore({ a: starEntry(true, 100, 1) }), 100);
// Between the enumerate read (#1) and the recheck read (#2), the peer writes
// a FRESH edit the head does not reflect.
ls.onReadMutate(key, 1, (map) =>
writeAt(ls, stale, starStore({ a: starEntry(true, 100, 1) }), 100);
// During enumeration (read of the stale key), the peer commits a FRESH edit
// to a NEW key the head does not reflect — the write-once analogue of the
// owner rewriting in the gap.
const fresh = foreignKey("stars", "peerB", 1);
ls.onReadMutate(stale, 1, (map) =>
map.set(
key,
fresh,
JSON.stringify({
store: starStore({ a: starEntry(false, 300, 9) }),
store: starStore({ z: starEntry(true, 300, 9) }),
queuedAt: 300,
}),
),
);
// Head carries `a` at (100,1) — subsumes the enumerated value.
// Head carries `a` at (100,1) — subsumes the stale value, not the fresh one.
stars.reclaimSubsumedStarsOutbox(
PK,
RELAY,
starStore({ a: starEntry(true, 100, 1) }),
);
assert.ok(ls.has(key), "peer's fresh edit must survive the recheck");
const survived = stars.readChannelStarsOutbox(PK, RELAY);
assert.deepEqual(survived.channels.a, starEntry(false, 300, 9));
assert.ok(!ls.has(stale), "proven-stale key is reclaimed");
assert.ok(ls.has(fresh), "peer's fresh edit under a new key survives");
assert.deepEqual(
stars.readChannelStarsOutbox(PK, RELAY).channels.z,
starEntry(true, 300, 9),
);
});
});
test("(i) sort: reclaim recheck skips a foreign key rewritten in the decision→delete gap", () => {
test("(i) sort: reclaim deletes the proven-stale key and keeps a fresh edit written in the gap", () => {
withStorage((ls) => {
const key = foreignKey("sort", "peerB");
writeAt(ls, key, sortStore({ dms: "alpha" }), 100);
ls.onReadMutate(key, 1, (map) =>
const stale = foreignKey("sort", "peerB", 0);
writeAt(ls, stale, sortStore({ dms: "alpha" }), 100);
const fresh = foreignKey("sort", "peerB", 1);
ls.onReadMutate(stale, 1, (map) =>
map.set(
key,
fresh,
JSON.stringify({ store: sortStore({ dms: "recent" }), queuedAt: 500 }),
),
);
// Head created_at 200 supersedes the queuedAt=100 decision, but not the
// interposed queuedAt=500 write.
// Head created_at 200 strictly supersedes the queuedAt=100 stale record,
// not the interposed queuedAt=500 fresh key.
sort.reclaimSupersededSortOutbox(PK, RELAY, 200);
assert.ok(ls.has(key), "peer's fresh edit must survive the recheck");
assert.ok(!ls.has(stale), "proven-stale key is reclaimed");
assert.ok(ls.has(fresh), "peer's fresh edit under a new key survives");
assert.equal(sort.readChannelSortOutbox(PK, RELAY).groups.dms, "recent");
});
});
@@ -183,13 +199,13 @@ test("(ii) stars (merge): both windows' distinct-channel edits resume", () => {
withStorage((ls) => {
writeAt(
ls,
foreignKey("stars", "A"),
foreignKey("stars", "A", 0),
starStore({ a: starEntry(true, 100, 1) }),
100,
);
writeAt(
ls,
foreignKey("stars", "B"),
foreignKey("stars", "B", 0),
starStore({ b: starEntry(true, 200, 1) }),
200,
);
@@ -203,13 +219,13 @@ test("(ii) mutes (merge): both windows' distinct-channel edits resume", () => {
withStorage((ls) => {
writeAt(
ls,
foreignKey("mutes", "A"),
foreignKey("mutes", "A", 0),
muteStore({ a: muteEntry(true, 100, 1) }),
100,
);
writeAt(
ls,
foreignKey("mutes", "B"),
foreignKey("mutes", "B", 0),
muteStore({ b: muteEntry(true, 200, 1) }),
200,
);
@@ -221,10 +237,15 @@ test("(ii) mutes (merge): both windows' distinct-channel edits resume", () => {
test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-superseded", () => {
withStorage((ls) => {
writeAt(ls, foreignKey("sort", "A"), sortStore({ channels: "alpha" }), 100);
writeAt(
ls,
foreignKey("sort", "B"),
foreignKey("sort", "A", 0),
sortStore({ channels: "alpha" }),
100,
);
writeAt(
ls,
foreignKey("sort", "B", 0),
sortStore({ channels: "recent" }),
200,
);
@@ -239,13 +260,13 @@ test("(ii) sections (whole-blob): the newest queued window resumes; older is LWW
withStorage((ls) => {
writeAt(
ls,
foreignKey("sections", "A"),
foreignKey("sections", "A", 0),
sectionStore([{ id: "s1", name: "One", order: 0 }]),
100,
);
writeAt(
ls,
foreignKey("sections", "B"),
foreignKey("sections", "B", 0),
sectionStore([{ id: "s2", name: "Two", order: 0 }]),
200,
);
@@ -255,13 +276,54 @@ test("(ii) sections (whole-blob): the newest queued window resumes; older is LWW
});
});
// ── (iii) Legacy v1 shared key: replays, then reclaimed only by relay gating ───
// ── (iii) Same-second whole-blob: head at t keeps a record queued at t ─────────
//
// A pre-per-window build wrote one shared key. It enumerates as one more record
// (queuedAt 0 for a bare store), resumes, and is reclaimed by the same relay-
// gated rule — never dropped on replay alone (mixed dev/DMG fleet residual).
// One-second clock granularity means a record queued in the same second as the
// head cannot be proven to have lost LWW. Strict supersession (`queuedAt` <
// head `created_at`) keeps it; a strictly-earlier record is reclaimed.
test("(iii) stars: legacy shared key resumes, then is reclaimed once the head subsumes it", () => {
test("(iii) sort: same-second record kept, strictly-earlier reclaimed", () => {
withStorage((ls) => {
const sameSecond = foreignKey("sort", "same", 0);
const earlier = foreignKey("sort", "old", 0);
writeAt(ls, sameSecond, sortStore({ dms: "recent" }), 100);
writeAt(ls, earlier, sortStore({ dms: "alpha" }), 99);
sort.reclaimSupersededSortOutbox(PK, RELAY, 100);
assert.ok(ls.has(sameSecond), "same-second record (queuedAt == head) kept");
assert.ok(!ls.has(earlier), "strictly-earlier record reclaimed");
});
});
test("(iii) sections: same-second record kept, strictly-earlier reclaimed", () => {
withStorage((ls) => {
const sameSecond = foreignKey("sections", "same", 0);
const earlier = foreignKey("sections", "old", 0);
writeAt(
ls,
sameSecond,
sectionStore([{ id: "s2", name: "Two", order: 0 }]),
100,
);
writeAt(
ls,
earlier,
sectionStore([{ id: "s1", name: "One", order: 0 }]),
99,
);
sections.reclaimSupersededSectionsOutbox(PK, RELAY, 100);
assert.ok(ls.has(sameSecond), "same-second record (queuedAt == head) kept");
assert.ok(!ls.has(earlier), "strictly-earlier record reclaimed");
});
});
// ── (iv) Legacy v1 shared key: replays, and is NEVER deleted by v2 ─────────────
//
// A pre-per-window build wrote one shared, MUTABLE key. v2 enumerates it as one
// more record (queuedAt 0 for a bare store) so it resumes, but never deletes it
// under any relay gating — a live old-build window may be rewriting it, and
// queuedAt 0 makes supersession meaningless (mixed dev/DMG fleet residual).
test("(iv) stars: legacy shared key resumes and is never reclaimed", () => {
withStorage((ls) => {
// Legacy bare store (no envelope) from a pre-per-window build.
ls.setItem(
@@ -274,27 +336,20 @@ test("(iii) stars: legacy shared key resumes, then is reclaimed once the head su
starEntry(true, 100, 1),
"legacy entry resumes",
);
// A head that does NOT subsume it (older rev) keeps it.
stars.reclaimSubsumedStarsOutbox(
PK,
RELAY,
starStore({ a: starEntry(true, 50, 0) }),
);
assert.ok(ls.has(legacyKey("stars")), "unsubsumed legacy entry is kept");
// A head that subsumes it reclaims it.
// Even a head that fully subsumes it must not delete the mutable v1 key.
stars.reclaimSubsumedStarsOutbox(
PK,
RELAY,
starStore({ a: starEntry(true, 100, 1) }),
);
assert.ok(
!ls.has(legacyKey("stars")),
"subsumed legacy entry is reclaimed",
ls.has(legacyKey("stars")),
"legacy v1 key is never deleted by v2",
);
});
});
test("(iii) sections: legacy shared key resumes, then is reclaimed once the head supersedes it", () => {
test("(iv) sections: legacy shared key resumes and is never reclaimed", () => {
withStorage((ls) => {
// Legacy entry as a {store, queuedAt} envelope from an interim build.
writeAt(
@@ -306,52 +361,138 @@ test("(iii) sections: legacy shared key resumes, then is reclaimed once the head
assert.deepEqual(sections.readChannelSectionsOutbox(PK, RELAY).sections, [
{ id: "s1", name: "One", order: 0 },
]);
// Head created_at before the queued stamp keeps it (not yet superseded).
sections.reclaimSupersededSectionsOutbox(PK, RELAY, 50);
// A head strictly past the queued stamp still must not delete the v1 key.
sections.reclaimSupersededSectionsOutbox(PK, RELAY, 999);
assert.ok(
ls.has(legacyKey("sections")),
"un-superseded legacy entry is kept",
);
// Head created_at at/after the queued stamp supersedes and reclaims it.
sections.reclaimSupersededSectionsOutbox(PK, RELAY, 100);
assert.ok(
!ls.has(legacyKey("sections")),
"superseded legacy entry is reclaimed",
"legacy v1 key is never deleted by v2",
);
});
});
// ── (iv) Whole-blob replay tie → deterministic nonce (key) tiebreak ────────────
// ── (v) Owner crash between write-new and delete-old → both replay-coalesce ────
//
// writeOwnOutbox writes the new key BEFORE deleting older own keys. A crash in
// that gap leaves two own records for the same window; replay coalesces them
// (merge fold / whole-blob max) with no loss and no duplicate publish.
test("(iv) sort: equal-queuedAt records resolve by key so replay is deterministic", () => {
test("(v) stars: two own records from a write-new/delete-old crash merge-coalesce", () => {
withStorage((ls) => {
writeAt(ls, foreignKey("sort", "aaa"), sortStore({ forums: "alpha" }), 100);
const base = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`;
// Simulate the crash residue: the pre-crash key (seq 0) plus the freshly
// written key (seq 1) both present. Merge must keep both channels.
writeAt(
ls,
foreignKey("sort", "zzz"),
`${base}:${pad(0)}`,
starStore({ a: starEntry(true, 100, 1) }),
100,
);
writeAt(
ls,
`${base}:${pad(1)}`,
starStore({ b: starEntry(true, 200, 1) }),
200,
);
const resumed = stars.readChannelStarsOutbox(PK, RELAY);
assert.deepEqual(resumed.channels.a, starEntry(true, 100, 1));
assert.deepEqual(resumed.channels.b, starEntry(true, 200, 1));
});
});
test("(v) sort: two own records from a crash resume the newer seq (padded key order)", () => {
withStorage((ls) => {
const base = `${PREFIX.sort}:${SCOPE}:${outboxWindowNonce()}`;
// Same-second seqs crossing a digit boundary: unpadded, "9" > "10"
// lexically and would wrongly resume the OLDER edit. Zero-padding makes the
// higher seq win the whole-blob tiebreak.
writeAt(ls, `${base}:${pad(9)}`, sortStore({ dms: "alpha" }), 100);
writeAt(ls, `${base}:${pad(10)}`, sortStore({ dms: "recent" }), 100);
assert.equal(
sort.readChannelSortOutbox(PK, RELAY).groups.dms,
"recent",
"newer seq resumes despite the digit-boundary crossing",
);
});
});
// ── (vi) Reload seeds seq above surviving own keys → no key reuse/overwrite ────
//
// After a reload the sessionStorage nonce survives but the in-memory seq counter
// restarts. A fresh write must allocate a seq ABOVE the max surviving own key so
// it never overwrites (and thus mutates) an existing immutable record.
test("(vi) reload: a write after surviving own keys allocates a strictly-higher key", () => {
withStorage((ls) => {
const base = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`;
// A surviving own key from before the (simulated) reload.
writeAt(
ls,
`${base}:${pad(5)}`,
starStore({ a: starEntry(true, 50, 1) }),
50,
);
// First write of the "new session" — seq counter cold, must seed above 5.
stars.writeChannelStarsOutbox(
PK,
starStore({ b: starEntry(true, 100, 1) }),
RELAY,
);
// The pre-reload key must NOT have been overwritten; the new write lands on
// a strictly-higher key, and delete-old drops the seq-5 key.
const ownKeys = [];
for (let i = 0; i < ls.length; i++) {
const k = ls.key(i);
if (k?.startsWith(`${base}:`)) ownKeys.push(k);
}
assert.equal(ownKeys.length, 1, "delete-old leaves exactly one own key");
assert.ok(
ownKeys[0] > `${base}:${pad(5)}`,
"new key seq is strictly above the surviving key",
);
// No data loss: the newest edit resumes.
assert.deepEqual(
stars.readChannelStarsOutbox(PK, RELAY).channels.b,
starEntry(true, 100, 1),
);
});
});
// ── (vii) Whole-blob replay tie → deterministic nonce (key) tiebreak ───────────
test("(vii) sort: equal-queuedAt records resolve by key so replay is deterministic", () => {
withStorage((ls) => {
writeAt(
ls,
foreignKey("sort", "aaa", 0),
sortStore({ forums: "alpha" }),
100,
);
writeAt(
ls,
foreignKey("sort", "zzz", 0),
sortStore({ forums: "recent" }),
100,
);
// Same queuedAt → the lexicographically-greater key wins (…:zzz).
// Same queuedAt → the lexicographically-greater key wins (…:zzz:…).
assert.equal(sort.readChannelSortOutbox(PK, RELAY).groups.forums, "recent");
});
});
// ── (v) Merge-lane replay is order-independent ─────────────────────────────────
// ── (viii) Merge-lane replay is order-independent ──────────────────────────────
test("(v) stars: same-channel records fold to the max entry regardless of key order", () => {
test("(viii) stars: same-channel records fold to the max entry regardless of key order", () => {
withStorage((ls) => {
// Lower-rev record under a lexicographically-greater key (enumerated later)
// must still lose to the higher-rev record — merge is order-independent.
writeAt(
ls,
foreignKey("stars", "aaa"),
foreignKey("stars", "aaa", 0),
starStore({ c: starEntry(true, 100, 5) }),
100,
);
writeAt(
ls,
foreignKey("stars", "zzz"),
foreignKey("stars", "zzz", 0),
starStore({ c: starEntry(false, 100, 2) }),
200,
);
@@ -364,7 +505,7 @@ test("(v) stars: same-channel records fold to the max entry regardless of key or
});
});
// ── (vi) GC no-op when the head subsumes/supersedes nothing ────────────────────
// ── (ix) GC no-op when the head subsumes/supersedes nothing ────────────────────
//
// The hook calls reclaim only inside the `apply-remote` branch, so a `failed`
// head fetch (or `absent`) never invokes it — that guard is structural in the
@@ -372,9 +513,9 @@ test("(v) stars: same-channel records fold to the max entry regardless of key or
// subsumes/supersedes nothing removes nothing: a foreign edit newer than the
// head is live intent and is kept, so a stale/empty head can never over-collect.
test("(vi) stars: a head that subsumes nothing reclaims nothing", () => {
test("(ix) stars: a head that subsumes nothing reclaims nothing", () => {
withStorage((ls) => {
const key = foreignKey("stars", "B");
const key = foreignKey("stars", "B", 0);
writeAt(ls, key, starStore({ a: starEntry(true, 300, 2) }), 300);
// Empty head subsumes no channel → keep everything.
stars.reclaimSubsumedStarsOutbox(PK, RELAY, starStore({}));
@@ -382,9 +523,9 @@ test("(vi) stars: a head that subsumes nothing reclaims nothing", () => {
});
});
test("(vi) sort: a head older than the queued edit supersedes nothing", () => {
test("(ix) sort: a head older than the queued edit supersedes nothing", () => {
withStorage((ls) => {
const key = foreignKey("sort", "B");
const key = foreignKey("sort", "B", 0);
writeAt(ls, key, sortStore({ dms: "recent" }), 300);
// headCreatedAt=0 (absent-equivalent) < queuedAt → keep.
sort.reclaimSupersededSortOutbox(PK, RELAY, 0);
@@ -394,26 +535,33 @@ test("(vi) sort: a head older than the queued edit supersedes nothing", () => {
// ── Own-key round trip: write, read, clear (single-window baseline) ────────────
test("own key: write resumes, clear removes only this window's own key", () => {
test("own key: write resumes, clear removes only this window's own keys", () => {
withStorage((ls) => {
stars.writeChannelStarsOutbox(
PK,
starStore({ a: starEntry(true, 100, 1) }),
RELAY,
);
const ownKey = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`;
assert.ok(ls.has(ownKey), "own edit is written under this window's nonce");
const ownBase = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`;
const hasOwn = () => {
for (let i = 0; i < ls.length; i++) {
const k = ls.key(i);
if (k?.startsWith(`${ownBase}:`)) return true;
}
return false;
};
assert.ok(hasOwn(), "own edit is written under this window's nonce");
// A foreign peer key is untouched by an own-key clear.
writeAt(
ls,
foreignKey("stars", "peer"),
foreignKey("stars", "peer", 0),
starStore({ z: starEntry(true, 9, 1) }),
9,
);
stars.clearChannelStarsOutbox(PK, RELAY);
assert.ok(!ls.has(ownKey), "own key cleared");
assert.ok(!hasOwn(), "own keys cleared");
assert.ok(
ls.has(foreignKey("stars", "peer")),
ls.has(foreignKey("stars", "peer", 0)),
"foreign key untouched by own clear",
);
});

View File

@@ -52,38 +52,42 @@ export function clampPublishCreatedAt(
);
}
// ── Per-window durable outbox ────────────────────────────────────────────────
// ── Per-window durable outbox (write-once, append-only) ──────────────────────
//
// Each sidebar-sync lane persists an unpublished edit so it survives a
// quit/community-switch inside the 2s publish debounce. The durability boundary
// is localStorage, which offers no atomic compare-and-delete or transactional
// read-modify-write, so a single key shared across every window can never be
// mutated safely: one window's read→write or read→remove can race a peer's
// write in the gap and drop its still-unpublished edit (the defect a per-write
// ownership token could narrow but not close).
// read-modify-write: a single key shared across windows can never be mutated
// safely, and even a per-window key a window OVERWRITES can change between a
// peer's reclaim decision-read and its delete (the recheck race a byte-compare
// narrows but cannot close).
//
// The outbox is therefore keyed PER WINDOW:
// `<prefix>:<pubkey>:<relay>:<nonce>`, where `nonce` is stable for one window's
// lifetime (parked in sessionStorage — survives reload, gone on window close).
// Each window is the ONLY writer of its own key, so a hot-path write is a single
// unconditional `setItem` with no read and no shared-key contention: the write
// race is designed out rather than guarded.
// The outbox is therefore keyed per window AND write-once. A key is
// `<prefix>:<pubkey>:<relay>:<nonce>:<seq>`, where `nonce` is stable for one
// window's lifetime (sessionStorage — survives reload, gone on window close) and
// `seq` is a per-window monotonic counter. A window NEVER rewrites a key: a new
// edit writes a NEW key (next `seq`) as a single unconditional `setItem`, then
// deletes its own now-superseded key(s). Both are own-key operations and the
// write precedes the delete, so a crash between them leaves ≥1 record for
// replay to coalesce, never zero.
//
// A window clears only its own key once its publish completes. Redundant foreign
// keys (a peer that published then crashed, or an edit the relay has since
// absorbed) are reclaimed at boot, gated on durable relay evidence so a live
// peer's genuinely-unpublished edit is never destroyed:
// Because records are immutable, foreign reclamation is safe by construction: a
// booting window reads an immutable foreign record, proves it reclaimable
// against durable relay evidence, and deletes it. Nothing can have changed at
// that key since the proof — the only competing interleave is the owner
// deleting it first, and `removeItem` on an absent key is a no-op. No byte
// recheck and no destructive-path residual remain.
// - merge lanes: delete a record the fetched relay head already subsumes.
// - whole-blob lanes: delete a record the head's own timestamp supersedes
// (`queuedAt` ≤ head `created_at`) — never one that
// merely loses a local comparison.
// Reclamation runs only when the head fetch succeeded, never on a failed fetch,
// and re-reads each foreign key immediately before removing it so a value that
// changed since the decision is left for its owner. This is not atomic —
// localStorage cannot be — but it narrows the residual to a recheck→remove gap
// that additionally requires the owner to quit before its own debounce publish
// (its in-memory pending re-drives otherwise). After any successful publish the
// relay head is newest, so foreign keys still drain to zero across boots.
// - whole-blob lanes: delete a record the head STRICTLY supersedes
// (`queuedAt` < head `created_at`); a same-second record
// is kept until a strictly-newer head lands.
// Reclamation runs only after a successful head fetch, never touches this
// window's own keys, and never touches the legacy v1 shared key — that key is
// mutable (a live old-build window may be rewriting it) and its `queuedAt=0`
// makes supersession meaningless, so no gating makes deleting it safe; v2 only
// ever replays it. The bounded cost is at most one lingering legacy key per lane
// per (pubkey, relay), and only when the last old-build session quit with an
// unpublished edit.
const OUTBOX_WINDOW_NONCE_KEY = "buzz-sidebar-outbox-window.v1";
@@ -100,7 +104,7 @@ function mintNonce(): string {
/**
* The stable per-window nonce that scopes this window's outbox keys. Minted
* once and parked in sessionStorage so a reload re-owns the same key while a
* once and parked in sessionStorage so a reload re-owns the same keys while a
* new window gets its own; an unavailable sessionStorage (private mode, test
* harness without one) falls back to a process-lifetime in-memory nonce.
*/
@@ -126,12 +130,11 @@ function scopeSuffix(pubkey: string, relayUrl: string): string {
return `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
/** This window's own outbox key — the only key it ever writes or removes. */
export function ownOutboxKey(
prefix: string,
pubkey: string,
relayUrl: string,
): string {
/**
* The base every own key for this (lane, pubkey, relay, window) shares:
* `<prefix>:<pubkey>:<relay>:<nonce>`. Own keys append `:<seq>`.
*/
function ownKeyBase(prefix: string, pubkey: string, relayUrl: string): string {
return `${prefix}:${scopeSuffix(pubkey, relayUrl)}:${outboxWindowNonce()}`;
}
@@ -144,6 +147,42 @@ function outboxScopePrefix(
return `${prefix}:${scopeSuffix(pubkey, relayUrl)}:`;
}
// Per-window monotonic `seq`, keyed by own-key base. Lazily seeded above the max
// `seq` among this window's surviving own keys so a reload (nonce survives in
// sessionStorage, this in-memory counter restarts) can never reuse — and thus
// overwrite — a key. Overwriting an own key would silently break immutability
// and reopen the reclaim race.
const ownSeqCounter = new Map<string, number>();
// `seq` is zero-padded to a fixed width so a key's lexicographic order matches
// its numeric order. That makes the whole-blob replay tiebreak (max `queuedAt`,
// then key string) pick the higher `seq` — the newer edit — when a crash
// between write-new and delete-old leaves two same-second own keys with
// adjacent seqs that cross a digit boundary (…:9 vs …:10). 12 digits bound a
// window's edit count far past any reachable value.
const SEQ_WIDTH = 12;
/** This window's own keys for a base (`<base>:<seq>`). */
function ownKeys(base: string): string[] {
const p = `${base}:`;
return localStorageKeys().filter((k) => k.startsWith(p));
}
/** Allocate this window's next write-once own key for a base. */
function nextOwnKey(base: string): string {
let last = ownSeqCounter.get(base);
if (last === undefined) {
last = -1;
for (const k of ownKeys(base)) {
const seq = Number(k.slice(base.length + 1));
if (Number.isInteger(seq) && seq > last) last = seq;
}
}
const seq = last + 1;
ownSeqCounter.set(base, seq);
return `${base}:${String(seq).padStart(SEQ_WIDTH, "0")}`;
}
/** A durable outbox record enumerated across all windows for a lane. */
export type OutboxRecord<T> = {
key: string;
@@ -151,8 +190,6 @@ export type OutboxRecord<T> = {
// Seconds since epoch when the edit was queued (0 for a legacy entry written
// before per-window keys, which therefore never wins a whole-blob tie).
queuedAt: number;
// The exact stored string at enumeration time, for the pre-delete recheck.
raw: string;
isOwn: boolean;
};
@@ -193,10 +230,16 @@ function parseEnvelope<T>(
}
/**
* Persist this window's unpublished edit under its own key. A single
* unconditional `setItem` — no read, no merge, no shared-key contention.
* Best-effort: the in-memory pending edit still drives this session's publish
* even if the persisted copy could not be written.
* Persist this window's unpublished edit under a fresh write-once key.
*
* Allocates the next `seq` for this window and `setItem`s the record there (a
* single unconditional write — no read, no merge, no shared-key contention),
* THEN deletes this window's older own keys. Write-before-delete: a crash
* between the two leaves ≥1 record for replay to coalesce, never zero. Because
* a key is written exactly once and never rewritten, a peer's boot-time reclaim
* of a proven-stale foreign key can never race a rewrite. Best-effort: the
* in-memory pending edit still drives this session's publish even if the
* persisted copy could not be written.
*/
export function writeOwnOutbox(
prefix: string,
@@ -205,24 +248,32 @@ export function writeOwnOutbox(
store: unknown,
nowSecs: number = Math.floor(Date.now() / 1_000),
): void {
const base = ownKeyBase(prefix, pubkey, relayUrl);
try {
const key = nextOwnKey(base);
window.localStorage.setItem(
ownOutboxKey(prefix, pubkey, relayUrl),
key,
JSON.stringify({ store, queuedAt: nowSecs }),
);
// Drop this window's now-superseded own keys (all but the one just written).
for (const k of ownKeys(base)) {
if (k !== key) window.localStorage.removeItem(k);
}
} catch {
// Best-effort durability.
}
}
/** Remove this window's own outbox key (its edit published or is a no-op). */
/** Remove all of this window's own outbox keys (its edit published or a no-op). */
export function clearOwnOutbox(
prefix: string,
pubkey: string,
relayUrl: string,
): void {
try {
window.localStorage.removeItem(ownOutboxKey(prefix, pubkey, relayUrl));
for (const k of ownKeys(ownKeyBase(prefix, pubkey, relayUrl))) {
window.localStorage.removeItem(k);
}
} catch {
// Ignore — a stale own key is re-evaluated on the next publish/boot.
}
@@ -241,7 +292,8 @@ function localStorageKeys(): string[] {
/**
* Enumerate every window's durable outbox record for a lane, plus the single
* legacy shared key from a pre-per-window build (treated as one more record so
* an edit persisted by a prior build still resumes and is later reclaimed).
* an edit persisted by a prior build still resumes; never reclaimed — see
* `reclaimOutbox`).
*/
export function enumerateOutbox<T>(
prefix: string,
@@ -251,7 +303,7 @@ export function enumerateOutbox<T>(
parseStore: (json: unknown) => T | null,
): OutboxRecord<T>[] {
const scopePrefix = outboxScopePrefix(prefix, pubkey, relayUrl);
const own = ownOutboxKey(prefix, pubkey, relayUrl);
const ownPrefix = `${ownKeyBase(prefix, pubkey, relayUrl)}:`;
const records: OutboxRecord<T>[] = [];
try {
for (const key of localStorageKeys()) {
@@ -263,8 +315,7 @@ export function enumerateOutbox<T>(
key,
store: parsed.store,
queuedAt: parsed.queuedAt,
raw,
isOwn: key === own,
isOwn: key.startsWith(ownPrefix),
});
}
}
@@ -275,7 +326,6 @@ export function enumerateOutbox<T>(
key: legacyKey,
store: legacy.store,
queuedAt: legacy.queuedAt,
raw: legacyRaw,
isOwn: false,
});
}
@@ -287,9 +337,12 @@ export function enumerateOutbox<T>(
/**
* Reclaim redundant FOREIGN outbox keys whose edits durable relay evidence
* shows are safe to drop (`shouldReclaim`). Never touches this window's own
* key. Re-reads each key immediately before removing it and skips if the stored
* value changed since enumeration, so a live owner's fresh write is preserved.
* shows are safe to drop (`shouldReclaim`). Records are write-once, so a proven
* key cannot have changed since enumeration — the delete needs no recheck and
* cannot destroy a peer's fresh edit (a new edit lives under a new key). Never
* touches this window's own keys, and never touches the legacy v1 shared key:
* that key is mutable and `queuedAt=0`, so no gating makes deleting it safe;
* v2 only ever replays it. Call only after a successful head fetch.
*/
export function reclaimOutbox<T>(
prefix: string,
@@ -306,15 +359,10 @@ export function reclaimOutbox<T>(
relayUrl,
parseStore,
)) {
if (record.isOwn) continue;
if (record.isOwn || record.key === legacyKey) continue;
if (!shouldReclaim(record)) continue;
try {
// Recheck: only remove if the value is byte-identical to what the
// reclaim decision was made against. A peer that rewrote the key in the
// gap owns a fresh edit we must not destroy.
if (window.localStorage.getItem(record.key) === record.raw) {
window.localStorage.removeItem(record.key);
}
window.localStorage.removeItem(record.key);
} catch {
// Leave for the next boot's reclamation.
}

View File

@@ -107,21 +107,25 @@ export function useChannelMutes(
if (cancelled) return;
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
// Head fetch succeeded: reclaim any foreign window's outbox key the
// head already subsumes (a peer that published then quit). Gated on the
// fetched head and rechecked per key, so a live peer's unpublished edit
// is never destroyed. A `hold` (absent/failed head) reclaims nothing.
reclaimSubsumedMutesOutbox(pubkey, relayUrl, result.data.store);
}
// "hold": seed already performed by bootstrap (if first-sync), or blocked.
// Resume any edit persisted to the durable outbox before a prior
// quit/community-switch so a click made <2s before teardown still syncs.
// Replay runs BEFORE reclamation so a same-second record the head appears
// to supersede is consumed into pending here and can never be GC'd out.
const outbox = readChannelMutesOutbox(pubkey, relayUrl);
if (outbox) {
managerRef.current?.publishMutes(outbox);
} else {
clearChannelMutesOutbox(pubkey, relayUrl);
}
if (result.action === "apply-remote") {
// Head fetch succeeded: reclaim any foreign window's write-once outbox
// key the head already subsumes (a peer that published then quit).
// Gated on the fetched head; records are immutable so no recheck is
// needed and a live peer's unpublished edit (under a different key) is
// never destroyed. A `hold` (absent/failed head) reclaims nothing.
reclaimSubsumedMutesOutbox(pubkey, relayUrl, result.data.store);
}
});
return () => {
cancelled = true;

View File

@@ -144,26 +144,31 @@ export function useChannelSections(
if (cancelled) return;
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
// Head fetch succeeded: reclaim any foreign window's outbox key the
// relay head itself supersedes (`queuedAt` ≤ head `created_at`). Gated
// on the fetched head and rechecked per key, so a live peer's edit
// queued after the head is kept. A `hold` (absent/failed) reclaims none.
reclaimSupersededSectionsOutbox(
pubkey,
relayUrl,
result.data.createdAt,
);
}
// "hold": seed already performed by bootstrap (if first-sync), or
// blocked (failed fetch / prior watermark). The reconciliation effect
// below retries a failed fetch; here we only resume any edit that was
// persisted to the durable outbox before a prior quit/community-switch.
// Replay runs BEFORE reclamation so a same-second record the head appears
// to supersede is consumed into pending here and can never be GC'd out.
const outbox = readChannelSectionsOutbox(pubkey, relayUrl);
if (outbox) {
managerRef.current?.publishSections(outbox);
} else {
clearChannelSectionsOutbox(pubkey, relayUrl);
}
if (result.action === "apply-remote") {
// Head fetch succeeded: reclaim any foreign window's write-once outbox
// key the head STRICTLY supersedes (`queuedAt` < head `created_at`).
// Gated on the fetched head; records are immutable so no recheck is
// needed and a live peer's edit queued at/after the head is kept. A
// `hold` (absent/failed) reclaims none.
reclaimSupersededSectionsOutbox(
pubkey,
relayUrl,
result.data.createdAt,
);
}
});
return () => {
cancelled = true;

View File

@@ -142,21 +142,26 @@ export function useChannelSortPreference(
if (cancelled) return;
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
// Head fetch succeeded: reclaim any foreign window's outbox key the
// relay head itself supersedes (`queuedAt` ≤ head `created_at`). Gated
// on the fetched head and rechecked per key, so a live peer's edit
// queued after the head is kept. A `hold` (absent/failed) reclaims none.
reclaimSupersededSortOutbox(pubkey, relayUrl, result.data.createdAt);
}
// "hold": seed already performed by bootstrap (if first-sync), or blocked
// (failed fetch / prior watermark). Resume any edit persisted to the
// durable outbox before a prior quit/community-switch.
// durable outbox before a prior quit/community-switch. Replay runs BEFORE
// reclamation so a same-second record the head appears to supersede is
// consumed into pending here and can never be GC'd out.
const outbox = readChannelSortOutbox(pubkey, relayUrl);
if (outbox) {
managerRef.current?.publishSortPrefs(outbox);
} else {
clearChannelSortOutbox(pubkey, relayUrl);
}
if (result.action === "apply-remote") {
// Head fetch succeeded: reclaim any foreign window's write-once outbox
// key the head STRICTLY supersedes (`queuedAt` < head `created_at`).
// Gated on the fetched head; records are immutable so no recheck is
// needed and a live peer's edit queued at/after the head is kept. A
// `hold` (absent/failed) reclaims none.
reclaimSupersededSortOutbox(pubkey, relayUrl, result.data.createdAt);
}
});
return () => {
cancelled = true;

View File

@@ -107,21 +107,26 @@ export function useChannelStars(
if (cancelled) return;
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
// Head fetch succeeded: reclaim any foreign window's outbox key the
// head already subsumes (a peer that published then quit). Gated on the
// fetched head and rechecked per key, so a live peer's unpublished edit
// is never destroyed. A `hold` (absent/failed head) reclaims nothing.
reclaimSubsumedStarsOutbox(pubkey, relayUrl, result.data.store);
}
// Resume every window's edit persisted to the durable outbox before a
// prior quit/community-switch so a click made <2s before teardown still
// syncs. Merges all records (order-independent), then publishes.
// syncs. Merges all records (order-independent), then publishes. Replay
// runs BEFORE reclamation so a same-second record the head appears to
// supersede is consumed into pending here and can never be GC'd out.
const outbox = readChannelStarsOutbox(pubkey, relayUrl);
if (outbox) {
managerRef.current?.publishStars(outbox);
} else {
clearChannelStarsOutbox(pubkey, relayUrl);
}
if (result.action === "apply-remote") {
// Head fetch succeeded: reclaim any foreign window's write-once outbox
// key the head already subsumes (a peer that published then quit).
// Gated on the fetched head; records are immutable so no recheck is
// needed and a live peer's unpublished edit (under a different key) is
// never destroyed. A `hold` (absent/failed head) reclaims nothing.
reclaimSubsumedStarsOutbox(pubkey, relayUrl, result.data.store);
}
});
return () => {
cancelled = true;