From bb0c3e1729dce1055921de5135b91bc267d72f51 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 1 Aug 2026 12:33:58 -0400 Subject: [PATCH] fix(nip-rs): apply max() for v2 frontier in hydration; add stale-ancillary witness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hydration was skipping v2 frontier application when an ordinary ancillary frontier already existed for the context. After a v2-success/ancillary-failure commit, the stale ancillary value suppressed the authoritative v2 frontier — the register appeared active when it should have been inactive (F > B). Fix: apply effectiveState[ctx] = max(existing ?? 0, e.f) for every v2/migrated override entry, unconditionally replacing the stale ancillary value with the authoritative committed frontier when it is higher. Test 22b: hydrateFromLocalStorage_staleAncillaryFrontier_v2FrontierWins — Thufir's exact policy-edge witness. Seeds stale ancillary frontier=50 and v2 {s:5,c:0,b:100,f:101}. Asserts effectiveState=101 after hydration, register inactive (101>B=100), and currentContexts() serializes tombstone (ov_c: only, no ov_s:). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../readState/readStateManager.test.mjs | 68 +++++++++++++++++++ .../channels/readState/readStateManager.ts | 10 +-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index 47d6e256d3..6bf8194a1a 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -2328,6 +2328,74 @@ test("markChannelUnread_v2WriteFails_slotIdsUnchanged", () => { mgr.destroy(); }); +// ── Test 22b: stale ancillary frontier suppressed by authoritative v2 frontier ─ +test("hydrateFromLocalStorage_staleAncillaryFrontier_v2FrontierWins", () => { + // Thufir's exact policy-edge witness: when a v2-success/ancillary-failure + // commit leaves the ordinary frontier key stale, hydration must apply + // max(existing, e.f) — not skip the update when the key is present. + // + // Setup: ordinary frontier key = 50 (stale) + // v2 override entry = {s:5, c:0, b:100, f:101} + // isOverrideActive(S=5,C=0,B=100, frontier=50) = 50<=100 → ACTIVE (wrong) + // isOverrideActive(S=5,C=0,B=100, frontier=101) = 101>100 → INACTIVE (correct) + // The test asserts the reconstructed manager uses frontier=101 so the + // register is inactive and serializes as a tombstone (ov_c: only, no ov_s:). + const ls = makeLocalStorage(); + globalThis.window.localStorage = ls; + + const pubkey = "ec".repeat(32); + const ctx = "stale-frontier-ch"; + + // Seed the stale ancillary frontier (epoch-seconds → ISO timestamp). + const staleFrontierKey = `buzz.channel-read-state.v2:${pubkey}`; + ls.setItem( + staleFrontierKey, + JSON.stringify({ [ctx]: new Date(50 * 1_000).toISOString() }), + ); + + // Seed the v2 override key with the authoritative frontier f=101. + const v2Key = `buzz.nip-rs.override-state.v2:${pubkey}`; + ls.setItem(v2Key, JSON.stringify({ [ctx]: { s: 5, c: 0, b: 100, f: 101 } })); + + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + + const mgr = new ReadStateManager(pubkey, fakeRelay); + mgr.hydrateFromLocalStorage(); + + // v2 is authoritative: effectiveState must hold 101, not the stale 50. + assert.equal( + mgr.effectiveState.get(ctx), + 101, + "hydration must apply max(staleAncillary=50, v2.f=101) → 101", + ); + + // With frontier=101 > B=100, the register is INACTIVE (tombstone). + // currentContexts() must serialize only ov_c: for this channel — no ov_s:. + mgr.isLoadComplete = true; + const contexts = mgr.currentContexts(); + assert.ok(contexts !== null, "currentContexts must not be null"); + const ovSKey = `ov_s:${ctx}`; + const ovCKey = `ov_c:${ctx}`; + assert.equal( + ovSKey in contexts, + false, + "inactive override must NOT serialize ov_s: (would mark channel active/unread)", + ); + assert.ok( + ovCKey in contexts, + "inactive override must serialize ov_c: tombstone floor", + ); + + mgr.destroy(); +}); + // ── Test 22: fetchOwnBlobBeforePublish processes foreign client_id ──────────── test("fetchOwnBlobBeforePublish_foreignClientId_rotatesSlotAndUpdatesMetadata", async () => { // Thufir's mandated witness: fetchOwnBlobBeforePublish must run the same diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 45110400ad..1c0dc47c35 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -447,7 +447,6 @@ export class ReadStateManager { } } - // ── Override register merge ─────────────────────────────────────────── for (const [rawCtx, reg] of merged.overrides) { const ex = this.overrideRegisters.get(rawCtx); const m: OverrideRegister = ex @@ -522,7 +521,6 @@ export class ReadStateManager { } private async startLiveSubscription(): Promise { - // Wire retryLoad on reconnects so incomplete init doesn't brick the manager. const unsubReconnect = this.relayClient.subscribeToReconnects(() => { if (this.destroyed) return; void this.retryLoad(); @@ -555,7 +553,6 @@ export class ReadStateManager { private async handleIncomingEvent(event: RelayEvent): Promise { if (event.pubkey !== this.pubkey || this.destroyed) return; - // Parse once: use the result for both ingest and the convergence client_id check. const parsed = await parseReadStateEvent(event, this.pubkey); if (!parsed) return; const delta = await this.ingestParsedEvents([parsed]); @@ -935,8 +932,11 @@ export class ReadStateManager { } : { s: e.s, c: e.c, b: e.b }; this.overrideRegisters.set(ctx, merged); - if (e.f > 0 && !this.effectiveState.has(ctx)) - this.effectiveState.set(ctx, e.f); + if (e.f > 0) + this.effectiveState.set( + ctx, + Math.max(this.effectiveState.get(ctx) ?? 0, e.f), + ); this.publishableContextIds.add(ctx); // v2 entry is inherently publishable } this.persistLocalState();