Skip to content

fix(sync): converge channel sidebar state across devices on the same identity - #6525

Open
wpfleger96 wants to merge 13 commits into
mainfrom
wpfleger/channel-sections-sync-fixes
Open

fix(sync): converge channel sidebar state across devices on the same identity#6525
wpfleger96 wants to merge 13 commits into
mainfrom
wpfleger/channel-sections-sync-fixes

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 21, 2026

Copy link
Copy Markdown
Member

Channel-section sidebar state (the sidebar's channel groupings) diverges between a user's dev build and installed DMG on the same identity: sections differ at app open, sometimes self-heal after minutes, sometimes never, and manual "kick" edits inconsistently force convergence. The cause is a set of client-side sync gaps in the desktop sync managers. This change is client-only — it does not touch the relay or database.

The sidebar has two payload shapes with different convergence needs, and this PR fixes each with the model that fits it: channel sections and sort use whole-blob last-write-wins; stars and mutes are per-entry sets that converge by max-merge. All four surfaces share one publish-timestamp clamp helper (clampPublishCreatedAt in sidebarSyncWatermark.ts, with the MAX_PUBLISH_FUTURE_SECS = 840 rationale in one place) so a skewed remote head can never wedge any lane's future publishes.

Multi-window durable outbox (all four lanes)

Every lane persists an unpublished edit to localStorage so it survives a quit or 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 cannot be mutated safely: one window's read-then-write or read-then-remove races a peer's write in the gap and drops its still-unpublished edit. Will runs the dev build and the DMG concurrently on one identity, so this is a live race, not a theoretical one.

The outbox is therefore keyed per window and write-once: a key is <prefix>:<pubkey>:<relay>:<nonce>:<seq>, where the nonce is minted once and parked in sessionStorage (survives reload, gone on window close) and seq is a per-window monotonic counter, zero-padded so lexicographic key order matches numeric order. A window never rewrites a key: a new edit writes a new key as a single unconditional setItem, then deletes its own older keys (write-before-delete, so a crash between the two leaves at least one record for replay to coalesce, never zero). The seq counter is seeded above the max surviving own key at boot, so a reload — where the sessionStorage nonce survives but the in-memory counter restarts — can never reuse and thus mutate an existing key. Resume enumerates every window's key: merge lanes (stars/mutes) fold all records order-independently; whole-blob lanes (sort/sections) replay the max-queuedAt record with a deterministic key tiebreak. A window clears only its own keys once its publish completes.

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, so no byte recheck is needed and a peer's fresh edit (which lands on a new key) can never be destroyed. Merge lanes delete a record the fetched relay head already subsumes; whole-blob lanes delete a record the head's created_at strictly supersedes (queuedAt < head), so a same-second record — which one-second clock granularity cannot prove lost LWW — is kept until a strictly-newer head lands. Reclamation runs only on a successful head fetch (never on a failed or absent one) and after replay, so a same-second record the head appears to supersede is consumed into pending before any reclaim can consider it. The single legacy shared key from a pre-per-window build is read as one more record (queuedAt 0) and is never deleted by this build: that key is mutable (a concurrently-live old build may be rewriting it) and its queuedAt 0 makes supersession meaningless, so no gating makes deleting it safe. Because it is never deleted, whole-blob lanes replay it exactly once per value rather than resurrecting it above the current relay head on every boot: a per-lane marker (<prefix>-legacy-consumed:<pubkey>:<relay>) records the exact raw string a boot replayed, and a later boot skips the legacy record while its raw matches. The marker is written only after the intent is durably transferred into this window's own v2 key (the synchronous publish path), so a crash in that gap replays the blob once more rather than losing it; a live old build that rewrites the key with new intent stores a different raw and is replayed again. Merge lanes need no marker — a head-subsumed legacy fold is skipped at replay (no redundant publish) and an unsubsumed one is genuine intent that drains once published. The bounded cost is at most one lingering legacy key (and one marker) per lane per (pubkey, relay), and only when the last old-build session quit with an unpublished edit.

Channel sections (whole-blob LWW)

  • Adopt the winner on lost LWW. A local edit that lost whole-blob LWW at pre-publish time was silently republished as remote content while the UI kept showing the edit. The sync manager now adopts the winning remote head — writes it through to React state and localStorage, advances the sync watermark — and skips publishing. The pre-publish check compares the fetched head against a canonical (created_at, id) baseline frozen when the edit was queued, not the live watermark, so a remote observed during the debounce window that became head after the edit began is adopted rather than overwritten.
  • Durable outbox — never drop an edit. Edits made inside the 2s publish debounce were lost on quit or community switch. The per-window outbox above persists every edit synchronously and resumes it on next mount. Adopt clears this window's key so a superseded edit can never be replayed back into divergence.
  • Single-owner pending-edit convergence. Every remote arrival (bootstrap, live, periodic reconcile) defers to a pending local edit whose own debounced publish resolves via publish-or-adopt, so a passive remote can never clobber the optimistic edit or strand its outbox. Publish cycles are serialized — at most one runs at a time; a newer edit queued mid-cycle defers and the in-flight cycle's completion re-drives it — so concurrent generations can never interleave over shared manager state. The per-edit baseline is owned by its generation, and a completing publish folds its own accepted head forward via canonicalMax, so a prior generation's own write is never mistaken for a competing remote and adopted away, and a stale generation cannot sign or publish after a newer edit exists (guarded both after the pre-publish fetch and immediately before signing).
  • Fold an ambiguously-accepted head forward. A publish whose ACK is lost (the promise rejects as a timeout) may still have been accepted by the relay. Each attempt's signed event id is retained; when a later cycle's pre-publish fetch returns a head whose id matches a prior attempt, that head is folded forward as our own accepted predecessor and the queued edit publishes above it, rather than adopting it away and erasing the edit. A head the relay never accepted can never surface by id, so the fold is gated on an exact id match — a genuine foreign winner is still adopted.
  • Canonical equal-timestamp tie-break. Same-second whole-blob writes from two devices are ordered to match the relay/database (created_at DESC, id ASC — lowest event id wins), so the UI converges on the event the relay actually stored instead of the largest id it happened to see first.
  • Reconciliation loop. Stale-at-open state waited for a reconnect event a healthy socket never fires. A single scheduler periodically refetches the head (steady 60s, bounded backoff on failure) and refreshes on window visibility, so divergence self-heals without a reconnect. This, together with the live subscription, is how a device that lost LWW discovers and adopts the winning head.

Channel sort (whole-blob LWW)

Sort preference is a whole-blob value on the same divergence-prone lifecycle path Will reported, but it lacked the durable machinery sections got: its publish catch only logged (no retry), destroy() discarded a pending edit, and bootstrap whole-blob-replaced local state. It now mirrors the sections manager's durable lane exactly — the per-window buzz-channel-sort-outbox above, generation-CAS single-flight publishes with re-drive, bounded-backoff retry, adopt-winner on lost LWW that writes through to state and localStorage, and the shared future-drift clamp. The hook mirrors useChannelSections: a pending-aware applyRemote that defers to an in-flight local edit instead of cancelling it, adopt-sink write-through, outbox resume on bootstrap, and the 60s reconcile loop with visibility refresh.

Stars and mutes (per-entry Lamport-rev max-merge)

Stars and mutes are per-channel sets, not a single blob, so whole-blob LWW is the wrong model: an integer-second updatedAt register cannot distinguish a local click from a stale remote landing in the same wall-clock second, and four rounds of ownership/dirty-set/canonical-supersession machinery layered on top could not close it. This PR folds them onto the same monotonic, order-independent model the read-state layer already uses.

  • One max-merge on every path. Each entry gains an additive optional per-channel Lamport rev (missing ⇒ 0; the payload stays version: 1 so older builds keep parsing our blobs). A single mergeStores orders by updatedAtrev → the starred/muted-true leaf — a commutative, associative, idempotent total order that ends in the 500-entry bound. Every observation path (bootstrap, live, reconnect, reconcile, pre-publish, cross-window storage) calls the same merge with no ordering, staleness guard, or ownership overlay. This deletes mergeApplyingRemote, mergeStoresWithTie, mergeCanonicalSupersession, dirtyChannelIds, and the event-clock/canonical-supersession branch.
  • Logical-monotonic click stamp. A click stamps updatedAt = max(now, localEntry.updatedAt, maxUpdatedAtSeen(id)) and mints rev = max(localEntry.rev, maxRevSeen(id)) + 1, so its (updatedAt, rev) tuple strictly dominates every state the replica has observed for that channel — it can never lose to a same-second remote, and rev earns its place only in the one second the wall clock cannot resolve. This mirrors the read-state layer's max(now, maxFetchedCreatedAt + 1) idiom. The high-waters live in the sync manager as one per-channel map with two fields, fed by a single observe() on every ingest path.
  • Durable outbox + bounded retry + reconcile. Stars sync keeps a generation-CAS + single-flight publish lane, bounded-backoff retry on publish failure, and the per-window durable outbox above (folded on resume by the same max-merge), so an in-flight publish can never clear a newer pending edit and a silently-lost publish converges within 60s; mutes sync mirrors it. Remote ingestion never touches the pending lane — only a completion or no-op for the owning generation may clear it.
  • Pubkey-only scoping unchanged. Stars/mutes stay keyed by pubkey (no relay suffix, no migration): channel ids are globally unique, so .has(channel.id) over the active community's channel list produces no cross-relay bleed.

Residuals are documented and accepted: an old build behind a future-stamped entry cannot reverse it until its wall clock catches up (identical to today's shipped LWW); a >500-same-second-entry eviction + same-second-remount window can lose a single click deterministically (never a divergence); and two windows' truly-concurrent whole-blob edits resolve by LWW/max-queuedAt (the newest intent wins, as with two physical devices) — the guarantee is that no window's publish erases another's still-unpublished intent, not a merge of concurrent whole-blob edits.

@wpfleger96
wpfleger96 requested a review from a team as a code owner August 21, 2026 23:12
@wpfleger96
wpfleger96 force-pushed the wpfleger/channel-sections-sync-fixes branch from 3da6677 to d0c23c9 Compare August 24, 2026 19:34
@wpfleger96 wpfleger96 changed the title fix(sync): converge channel sections across devices on the same identity fix(sync): converge channel sidebar state across devices on the same identity Aug 25, 2026
Duncan and others added 8 commits August 26, 2026 10:54
Channel-section sidebar state diverged between a user's devices and
sometimes never self-healed. Four client-side gaps fed the divergence:

- A local edit that lost whole-blob LWW was silently republished as remote
  content while the UI kept showing the edit. Now the manager adopts the
  winning remote head (writes it through to state + storage, advances the
  watermark) and skips publishing, unifying with the relay's OK-false
  conflict path as one convergence mechanism.
- Edits made inside the 2s publish debounce were dropped on quit or
  community switch. A durable localStorage outbox persists every edit
  synchronously and resumes it on next mount; adopt clears the outbox so a
  superseded edit can never be replayed.
- A skewed remote head could push the published createdAt past the relay's
  future-drift window and wedge all later publishes. createdAt is now
  clamped inside that window.
- Stale-at-open state waited for a reconnect that a healthy socket never
  fires. A reconciliation loop periodically refetches the head (steady 60s,
  backoff on failure) and refreshes on window visibility.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three cross-layer races defeated the one-convergence-mechanism design:

- An older in-flight publish unconditionally cleared pending state on
  completion, erasing a newer edit queued mid-flight. Each pending edit now
  carries a monotonic generation; a completion clears pending/outbox/retry only
  via compare-and-swap on the generation it published.
- Hook-level remote application (bootstrap/live/periodic) cancelled the pending
  publish's timers without deciding supersession, stranding the durable outbox
  and clobbering the optimistic edit. applyRemote now defers entirely to a
  pending edit, whose own debounced publish converges via publish-or-adopt; the
  manager's adopt path clears pending before write-through so the winning remote
  still applies.
- The equal-timestamp tie-break kept the largest event id, opposite the
  relay/database canonical order (created_at DESC, id ASC → lowest id wins).
  applyRemote now applies a strictly-lower id and ignores ids >= the last
  applied, so the UI converges on the event the relay actually stored.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
useChannelStars, useChannelMutes, and useChannelSortPreference carried the
same inverted equal-timestamp comparator as channel sections: applyRemote kept
the largest event id, opposite the relay/database canonical order (created_at
DESC, id ASC -> lowest id wins). Two devices writing the same second could
leave the UI showing an event the relay did not store.

Apply a strictly-lower id and ignore ids >= the last applied, matching the
sections fix and the relay winner across all four 30078 sidebar surfaces. Each
hook gains a regression test: larger-then-lower id delivery at equal timestamp,
lower-id store wins (mutation-checked - reverting >= to <= fails each).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two convergence holes one layer under the pass-1 fixes:

Sections: the pre-publish head check compared the fetched head against
the mutable lastRemoteCreatedAt, which a live event observed during the
debounce window already advanced to that same head — equality fell
through to publish and the local blob overwrote a remote that became
head after the edit was queued. Freeze a canonical head baseline
(created_at, id) at publishSections and compare the fetched head against
that generation baseline instead, adopting when the head advanced.

Stars/mutes: applyRemote admits the canonical lower-id winner but then
mergeStores resolved equal per-entry updatedAt as local/prev-wins, so a
stale larger-id value delivered first survived and undid the winner. Add
mergeApplyingRemote which resolves an entry-timestamp tie toward the
canonical incoming blob while keeping strictly-newer local entries.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Each prior round patched one cross-generation interleaving and opened
another a layer deeper. Kill the race class structurally instead.

Sections: serialize publish cycles (one in-flight at a time; a newer edit
queued mid-cycle defers and the completion re-drives it). The per-edit
pre-publish baseline is frozen at queue time, so a genuine remote observed
during the debounce window still adopts, while our own accepted head is
folded forward via canonicalMax so a stale generation's own write is never
mistaken for a competing remote and adopted away. Dual generation guards in
doPublish (post-fetch and pre-publish) stop a stale generation signing or
publishing after a newer edit exists.

Stars/mutes: scope mergeApplyingRemote (remote-wins on entry-tie) and the
pending-publish cancel to fire only on a canonical supersession of an
already-applied same-timestamp larger-id head. Every other application
(bootstrap/live/newer-timestamp) keeps local-wins mergeStores and does not
cancel the pending publish, so a later same-second local click is no longer
clobbered by an older remote entry that decrypts late.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…biguous-ACK heads

Round-4 client-side convergence fixes for channel-sections/stars/mutes sync,
closing two silent edit-loss variants that survived publish serialization.

Ambiguous-ACK fold: a publish whose ACK is lost may still have been accepted
by the relay. Retain each attempt's signed id; when a later cycle's pre-publish
fetch returns a head whose id matches a prior attempt, fold it forward as our
own accepted predecessor and publish above it instead of adopting it away and
erasing the queued edit. A head the relay never accepted can never surface by
id, so the fold is proof-gated on an exact id match.

Canonical-supersession dirty overlay (stars + mutes): a lower-id canonical
correction that arrives after a same-second local click must not clobber the
click. Apply the correction to the prior remote layer, then overlay entries
changed locally since that layer; never cancel a pending publish merely because
a correction arrived.

Client-only: loss discovery relies on the existing pre-publish fetch, live
subscription, and reconcile loop rather than a relay conflict signal.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace the LWW register plus ownership/dirty-set/canonical-supersession
machinery with a per-entry Lamport `rev` and a single max-merge on every
path, mirroring the read-state data model. Each entry carries an additive
optional `rev` (missing implies 0; payload stays `version: 1` so older
builds keep parsing our blobs). One `mergeStores` orders by
updatedAt then rev then the starred/muted-true leaf, and ends in the
500-entry bound.

Clicks stamp `updatedAt = max(now, localEntry?.updatedAt ?? 0,
maxUpdatedAtSeen(id))` and mint `rev = max(localEntry.rev, maxRevSeen(id))
+ 1`, so a click strictly dominates every state its replica has observed
and cannot lose to a same-second remote. The sync managers hold a
per-channel two-field high-water map fed by a single `observe()` on every
ingest path. Stars sync keeps the generation-CAS + single-flight lane and
bounded-backoff retry plus a durable outbox so an in-flight publish can
never clear a newer pending edit; mutes sync mirrors it. Remote ingestion
never touches the pending lane. Deleted: mergeApplyingRemote,
mergeStoresWithTie, mergeCanonicalSupersession, dirtyChannelIds,
lastAppliedRemoteTs/lastAppliedEventId, and the event-clock branch.

Sections and sort are untouched.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… contract

The hard-eviction-branch test asserted only the rev tuple outcome on two
one-entry stores while its comment claimed the >500-entry eviction/remount
setup. Build the real fixture: >500 equal-updatedAt entries so the bound
evicts the target by the id tiebreak, then a remounted rev-1 click merged
against the retained rev-100 remote, asserting the deterministic rev-100
outcome in both merge orders. Stars and mutes.

The unobserved-future mixed-fleet residual was stated but never exercised
directly: the fast-clock suites cover only the observed-future fix. Add a
click with an empty high-water at t, then a genuinely unobserved
opposite-value head at t+300 that wins on the primary updatedAt key. Both
hook suites.

Test-only; no production source changes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes at exact head 0d797b550e405eea9557e053f884533ad3dd891a. The relay ordering and the per-entry max-merge are coherent, but the lifecycle still has reproducible edit-loss paths.

[P1] The durable outboxes are not safe across desktop windows

Sections, stars, and mutes share one localStorage outbox key per identity + relay, but each window owns only an in-memory generation. A write in one window therefore replaces another window's pending payload, and a completion in either window removes the shared key without proving that it still owns the persisted value.

For sections, window A can start publishing edit A, window B can replace the outbox with newer edit B, and A's ACK then clears B's outbox. If B closes before its debounce fires, the next bootstrap applies relay head A before discovering there is no outbox, so B is permanently lost. See channelSectionsStorage.ts:211-245, channelSectionsSync.ts:262-281,284-313,522, and useChannelSections.ts:138-156.

Stars and mutes can lose independent clicks even earlier: two windows starting from the same store can click different channels before receiving each other's asynchronous storage event. Their whole-store main/outbox writes race, teardown cancels both timers, and remount resumes only the last blob. The storage handler merges only into React state; it does not durably merge the main store or outbox. See channelStarsStorage.ts:206-242, useChannelStars.ts:57-72,95-113, and channelStarsSync.ts:256-260,383-393 (mutes mirror these paths).

Please give persisted attempts cross-window ownership, not only manager-local generations. A per-operation outbox whose owner deletes only its own record, or an actual cross-window serialization mechanism, would close both overwrite and stale-clear races. Add multi-window tests that interleave write, ACK, storage delivery, teardown, and remount.

[P1] Sort preferences still drop pending edits on ordinary lifecycle and failure paths

publishSortPrefs keeps intent only in memory (channelSortSync.ts:113-121). destroy() deliberately cancels and discards it (:252-262), while a failed publish only logs and never retries (:167-210). A live remote also cancels the debounce while leaving pendingStore stranded (useChannelSortPreference.ts:82-103).

Reproduction: change a sort mode and quit/switch communities within two seconds, or let one publish time out and remount. Bootstrap then whole-blob-replaces the local cache with the relay head (useChannelSortPreference.ts:108-122), visibly reverting the user's choice. Please carry the durable outbox, generation ownership, serialized retry, and pending-aware remote application used by sections over to sort, with lifecycle tests.

[P2] Future relay heads can wedge stars, mutes, and sort publishing

Sections clamps created_at inside the relay's future-drift window (channelSectionsSync.ts:463-472), but stars (channelStarsSync.ts:298-301), mutes (matching code), and sort (channelSortSync.ts:183-186) stamp lastRemoteCreatedAt + 1 without a cap. If a self-authored head was accepted near the relay's +900s boundary, a correctly clocked second device emits +901s; the relay rejects it, and retries continue deriving from the same head until wall time catches up. Apply the same bounded timestamp rule across all four sidebar sync surfaces.

CI is green at this head. I did not duplicate the CI-equivalent suite locally; these failures are source-reproduced interleavings absent from the current tests.

@wpfleger96
wpfleger96 force-pushed the wpfleger/channel-sections-sync-fixes branch from 0d797b5 to 16aeab6 Compare August 26, 2026 14:55

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Re-reviewed exact current head 16aeab68b094baaba508a0f2fc91c06b0c430c1c against base ef0d2025683869418e8eee22ac5b5ac16c5198b7 after the force-rebase. Changes are still required.

All 19 files in this PR have the same Git blob IDs as at previously reviewed head 0d797b550e405eea9557e053f884533ad3dd891a, including all production sync files and tests. The only sidebar differences between the old and new repository trees are three UI files inherited from the new base; they do not touch the NIP-78 sync producers, consumers, or their direct dependencies. Consequently, the previous review's blockers survive unchanged:

  1. P1: sections/stars/mutes still lack cross-window ownership for the shared durable outboxes. Manager-local generation fencing cannot stop one desktop window from overwriting another window's persisted payload or from unconditionally clearing the newer payload after its own older ACK/no-op/adopt completion. The existing tests remain single-manager and do not cover two windows sharing the key.
  2. P1: sort still drops pending intent. It still has only an in-memory pendingStore, no durable outbox or failure retry, destroy() still cancels and nulls the edit, and a live remote can still cancel the timer while leaving the intent stranded.
  3. P2: stars, mutes, and sort still derive created_at = max(now, lastRemoteCreatedAt + 1) without the future-drift clamp used by sections. A self-authored head accepted near the relay's +900s limit can therefore wedge subsequent writes until wall time catches up.

Please address the concrete reproductions and repair boundaries in the review on 0d797b550: #6525 (review)

Current CI is not green: Desktop Smoke E2E (2) failed at this head while several desktop jobs remain in progress. I am not using that still-unclassified failure as a separate code finding; the source blockers above independently require changes.

Duncan and others added 2 commits August 26, 2026 11:35
Carl's re-review found three edit-loss paths remaining after the
stars/mutes rev-merge landed.

[P2] Stars, mutes, and sort stamped createdAt = lastRemoteCreatedAt + 1
uncapped, so a self-authored head accepted near the relay's +900s drift
boundary wedged every later publish until wall time caught up. Extract
the sections clamp into a shared clampPublishCreatedAt in
sidebarSyncWatermark.ts (all four surfaces already import that module)
and wire sections/stars/mutes/sort to it.

[P1] Sort preferences never got the durable lane: doPublish only logged
on failure, destroy() discarded the pending edit, and a live remote
cancelled the debounce with the edit stranded. Port the sections lane —
durable outbox + bootstrap resume, generation/CAS ownership, single-
flight + completion re-drive, 2s->30s backoff, 60s reconcile loop, and
pending-aware remote application. Sort stays whole-blob LWW; a lost head
is adopted at pre-publish rather than republished.

Tests: a clamp test per surface, and sort lifecycle coverage — outbox
resume after destroy-inside-debounce, retry without a later edit,
overlapping-generation safety, live-remote-during-debounce adopt, and a
hook-level pending-defer test.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sections, stars, mutes, and sort each persist an unpublished edit under one
localStorage outbox key per identity+relay, but generation ownership is only
in-memory per window. Without cross-window ownership, one window's completing
publish could clear a peer window's still-unpublished edit, and (on the merge
lanes) a peer's write could overwrite an edit before it published.

Every outbox write now mints an ownership token and stores a {store, token}
envelope; a completing publish compare-and-clears only when the stored token
still matches its own, so a peer's newer write survives an older window's ACK.
Stars and mutes additionally read-merge-write both the durable outbox and the
main store via their per-entry mergeStores, so two windows editing different
channels both survive; sort and sections replace whole-blob with LWW resolution
matching the relay. Sort also gains the durable outbox + bounded retry it
previously lacked, and stars/mutes/sort now clamp publish created_at inside the
relay's future-drift window like sections. The envelope reader tolerates a
legacy token-less entry so an outbox written by a prior build still resumes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes at exact head 9141fe292268e2681e65664bbeb708b732af2535.

[P1] The multi-window outbox operations are still racy

The new ownership token does not make the localStorage operations atomic. clearOutboxEntry reads and validates the stored token, then calls removeItem separately (sidebarSyncWatermark.ts:141-162). Window A can read token A, window B can write its newer {store, tokenB} envelope, and A can then remove B's value using the stale read. That still loses an unpublished edit on quit/remount.

Stars and mutes have the matching write-side race: writeChannelStarsOutbox reads the shared entry, merges in memory, then writes separately (channelStarsStorage.ts:226-237; mutes mirrors it). If two windows read before either writes, each computes a one-sided merge and the later setItem drops the other pending click. The main-store read/merge/write has the same shape (channelStarsStorage.ts:135-144).

The added multi-window tests execute whole operations sequentially, such as A write, B write, then A clear (multiWindowOutbox.test.mjs:62-167), so they cannot exercise either read/write or read/remove interleaving. localStorage provides no compare-and-delete or transactional read-modify-write primitive; the token proves what was read, not what is still stored at the destructive operation.

Please move this durability boundary to a design that does not depend on atomicity localStorage lacks, such as per-operation append-only records with owner-specific deletion, or an appropriately serialized cross-window store. Add tests that pause operations between their read and write/remove steps and verify teardown/remount preserves every unpublished intent.

The LWW comparator itself matches the relay's created_at DESC, id ASC rule; this review is blocking only on the durability race above. I reviewed read-only GitHub metadata and diff and did not check out or execute PR code.

localStorage has no atomic compare-and-delete or transactional
read-modify-write, so a single outbox key shared across every window
could not be mutated safely: one window's read-then-write or
read-then-remove races a peer's write in the gap and drops its
still-unpublished edit. A per-write ownership token narrowed that window
but could not close it — the token proves what was read, not what is
still stored at the destructive op.

Key the outbox per window instead: <prefix>:<pubkey>:<relay>:<nonce>,
where the nonce is minted once and parked in sessionStorage. Each window
is the sole writer of its own key, so a hot-path write is one
unconditional setItem — the write race is designed out, not guarded.
Resume enumerates every window's key: merge lanes (stars/mutes) fold all
records order-independently; whole-blob lanes (sort/sections) replay the
max-queuedAt record with a nonce tiebreak. Redundant foreign keys are
reclaimed at boot, gated on durable relay evidence (merge: head subsumes;
whole-blob: head created_at supersedes) and re-read immediately before
removal so a live peer's fresh write in the recheck gap survives.
Reclamation runs only on a successful head fetch, never on a failed one.
The token contract is deleted entirely — ownership is the key.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ce-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>
The legacy v1 shared outbox key is never deleted (it is mutable and a
concurrently-live old build may still rewrite it), so the whole-blob
resume path re-read it on every boot and republished the stale blob above
the current relay head forever. A found relay head never stopped it: a
fresh manager has no lastPublishedStore and queueing the replay freezes
publishBaseline to the just-fetched head.

Distinguish compatibility replay from permanently pending intent with a
durable per-value consumption marker, whole-blob lanes only. resumeWholeBlobOutbox
excludes the legacy record when its exact raw matches the stored marker;
a live old build rewriting the key stores a different raw and is replayed
again. The hook transfers the intent into its own v2 key (synchronous
publish) BEFORE writing the marker, so a crash in that gap replays the
blob once more rather than losing it.

Merge lanes (stars/mutes) need no marker but gained a head-subsumed gate
so a lingering legacy key does not re-drive an identical boot-time publish.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants