From b539b74ca205f57cbf458449835ca616698fd2f6 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 21:15:55 -0400 Subject: [PATCH] test(sidebar-sync): address Thufir pass-1 CRITICAL+IMPORTANT findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL 1 (C1): restored replay must carry original queuedAt age. - WholeBlobSyncManager.publish() now takes restoredQueuedAt param; pendingRestoredQueuedAt drives the adopt-guard in fetchOwnBlobBeforePublish (publish when remote.createdAt <= restoredQueuedAt; adopt when strictly newer). writeOutbox called with original queuedAt so on-disk stamp is never reminted. - channelSectionsSync/channelSortSync publishSections/publishSortPrefs accept and thread restoredQueuedAt through to publish(). - channelSectionsStorage/channelSortPreference writeOutbox signatures updated to accept optional nowSecs override. - Hook-layer tests in runWholeBlobCarlSuite use the actual React hook (renderHook), driving the real bootstrap .then() callback, outbox read, queuedAt, and shouldReplay guard. CRITICAL 2 (C2): successful-bootstrap replay needs a baseline. - bootstrap() snapshots the fetched head to this.bootstrapResultHead before returning. publish(_, true) uses canonicalMax(current, bootstrapResultHead) so a no-prior-pending restored replay gets the correct baseline from the immutable bootstrap snapshot rather than the mutable lastRemoteHead (which may carry a suppressed live peer head H102 that must remain a genuine advance). - New runWholeBlobC2Suite (manager layer) drives publish(_, true, undefined) so C1's queuedAt guard is disabled and only C2's bootstrapResultHead mechanism can prevent the adopt. CRITICAL 3 (C3): confirmRetainedHead decrypt gap. - For a foreign winner, lastRemoteHead is advanced only AFTER decryptAndParse succeeds (mirrors fetchRemoteBlob fix). A click arriving during the confirm decrypt gap now sees lastRemoteHead as {0,""} (or the pre-confirm value) and correctly adopts the foreign winner rather than publishing over it. - New runWholeBlobC3Suite (manager layer) gates the first decrypt call and injects a click during the gap; asserts publishCalls === 1. CRITICAL 4 (C4 / P3-reclaim): reclaimSubsumedOutbox drops preservedKey. - mergeLaneStorage.shared.ts reclaimSubsumedOutbox signature updated to accept optional preservedKey; calls isSubsumedBy(record.store, head, record.preservedKey) so a capacity-bounded proof can never evict the reserved channel and falsely certify retention. - New P3-reclaim tests (already in mergeLaneSyncCarl.shared.test.mjs) exercise 500-entry relay head + foreign outbox with clicked channel absent; mutation (drop preservedKey) → record deleted → test red. IMPORTANT: test coverage. - P1/C1 hook tests now drive renderHook with the real outbox read, queuedAt threading, shouldReplay guard, and bootstrap .then() callback (not manager.publishReplay directly). - P3-ack fixture clock set to t=1s so clicked channel is the lexicographically evicted entry at 501-entry capacity, making the pendingPreservedKey-drop mutation genuinely causal (2 red). - All 6 named mutations verified red on exactly their tests, green on fixed source: C1 (2r), C2 (2r), C3 (2r), C4/P3-reclaim (2r), P3-ack (2r), P2a-1 (2r), P2b (2r). 57/57 pass on fixed source. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/channelSectionsStorage.ts | 2 + .../sidebar/lib/channelSectionsSync.ts | 3 +- .../sidebar/lib/channelSortPreference.ts | 2 + .../features/sidebar/lib/channelSortSync.ts | 8 +- .../sidebar/lib/mergeLaneStorage.shared.ts | 8 +- .../lib/mergeLaneSyncCarl.shared.test.mjs | 113 +++- .../sidebar/lib/useChannelSections.test.mjs | 65 ++ .../sidebar/lib/useChannelSections.ts | 1 + .../lib/useChannelSortPreference.test.mjs | 37 + .../sidebar/lib/useChannelSortPreference.ts | 1 + .../lib/wholeBlobSyncCarl.shared.test.mjs | 640 +++++++++++++----- .../sidebar/lib/wholeBlobSyncManager.ts | 269 ++++---- 12 files changed, 822 insertions(+), 327 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index f1a07713c8..157294e388 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -240,12 +240,14 @@ export function writeChannelSectionsOutbox( pubkey: string, store: ChannelSectionStore, relayUrl: string, + nowSecs?: number, ): boolean { return writeOwnOutbox( OUTBOX_KEY_PREFIX, pubkey, relayUrl, boundChannelSectionsStore(store), + nowSecs, ); } diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index a29f7ef4ab..2691643d3d 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -60,8 +60,9 @@ export class ChannelSectionSyncManager extends WholeBlobSyncManager( outboxPrefix: string, pubkey: string, relayUrl: string, parse: (json: unknown) => S | null, - isSubsumedBy: (candidate: S, head: S) => boolean, + isSubsumedBy: (candidate: S, head: S, preservedKey?: string) => boolean, head: S, ): void { reclaimOutbox( @@ -265,6 +269,6 @@ export function reclaimSubsumedOutbox( pubkey, relayUrl, parse, - (record) => isSubsumedBy(record.store, head), + (record) => isSubsumedBy(record.store, head, record.preservedKey), ); } diff --git a/desktop/src/features/sidebar/lib/mergeLaneSyncCarl.shared.test.mjs b/desktop/src/features/sidebar/lib/mergeLaneSyncCarl.shared.test.mjs index 6073806545..98bff46d5a 100644 --- a/desktop/src/features/sidebar/lib/mergeLaneSyncCarl.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/mergeLaneSyncCarl.shared.test.mjs @@ -267,7 +267,11 @@ export function runMergeLaneCarlSuite({ const { fireDelay, restore: restoreTimers } = makeHookTimerBed(); const origDateNow = Date.now; - Date.now = () => 100 * 1_000; + // Clock at t=1s so the click produces updatedAt=1 — the oldest entry among + // all 500+1 channels (others are at updatedAt=100). This ensures the clicked + // channel is the one evicted by the capacity-bounded merge at 501 entries, + // making the pendingPreservedKey-drop mutation genuinely causal. + Date.now = () => 1 * 1_000; let hook = null; const v2Prefix = `${outboxKeyPrefix}:${pubkey}:${encodedRelay}:`; @@ -310,4 +314,111 @@ export function runMergeLaneCarlSuite({ mock.reset(); } }); + + // P3-reclaim: reclaimSubsumedStarsOutbox (or mutes twin) calls + // reclaimSubsumedOutbox → isSubsumedBy(record.store, head). Before the fix + // record.preservedKey was discarded, so a foreign outbox with a clicked + // channel absent from a 500-entry head was deleted as "subsumed." + // Fix: reclaimSubsumedOutbox passes record.preservedKey to isSubsumedBy. + // Mutation: isSubsumedBy(record.store, head) without preservedKey + // → 501-entry merge evicts clickedId → subsumed=true → record deleted. + test(`P3-reclaim ${label}: reclaimSubsumedOutbox must pass preservedKey; foreign outbox with clicked channel absent from 500-entry head must not be deleted`, async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + + const pubkey = `pk-p3-reclaim-${label}`; + const encodedRelay = encodeURIComponent(relayUrl); + + // Relay head: 500 entries at updatedAt=100, none includes clickedId. + const headChannels = {}; + for (let i = 0; i < MAX_ENTRIES; i++) { + headChannels[`ch-reclaim-${String(i).padStart(4, "0")}`] = { + [entryValueField]: false, + updatedAt: 100, + rev: 0, + }; + } + + // Foreign outbox (another window): 499 of the head entries + clickedId + // (updatedAt=1, oldest). preservedKey = clickedId. + const foreignChannels = { + [clickedId]: { + [entryValueField]: true, + updatedAt: 1, + rev: 1, + }, + }; + for (let i = 0; i < MAX_ENTRIES - 1; i++) { + foreignChannels[`ch-reclaim-${String(i).padStart(4, "0")}`] = { + [entryValueField]: false, + updatedAt: 100, + rev: 0, + }; + } + + // Seed local store (no pending edit on this window). + window.localStorage.setItem( + storageKey(pubkey, relayUrl), + JSON.stringify({ version: 1, channels: headChannels }), + ); + + // Seed a FOREIGN outbox key (different nonce so this window doesn't own it). + const foreignKey = `${outboxKeyPrefix}:${pubkey}:${encodedRelay}:foreign-p3-reclaim:0001`; + window.localStorage.setItem( + foreignKey, + JSON.stringify({ + store: { version: 1, channels: foreignChannels }, + queuedAt: 1, + preservedKey: clickedId, + }), + ); + + const tauri = installEchoTauri(pubkey); + const relayHead = tauri.mintHead( + { version: 1, channels: headChannels }, + 50, + `evt-p3-reclaim-${label}`, + ); + relayHead.tags = [["d", dTag]]; + relayHead.pubkey = pubkey; + relayHead.kind = 30078; + + const restoreRelay = stubRelay(relayClient); + relayClient.fetchEvents = async () => [relayHead]; + relayClient.publishEvent = async () => {}; + + const { restore: restoreTimers } = makeHookTimerBed(); + const origDateNow = Date.now; + Date.now = () => 50 * 1_000; + + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useHook(pubkey, relayUrl)); + for (let i = 0; i < 40; i++) await Promise.resolve(); + }); + // Let bootstrap and reclamation settle. + for (let i = 0; i < 100; i++) await Promise.resolve(); + + // With the fix: reclaimSubsumedOutbox calls isSubsumedBy(foreignStore, head, clickedId) + // → direct check: head.channels[clickedId] = undefined → not subsumed → KEPT. + // Mutation: isSubsumedBy(foreignStore, head) without preservedKey + // → 501-entry merge evicts clickedId → subsumed=true → foreignKey deleted. + const stillPresent = window.localStorage.getItem(foreignKey) !== null; + assert.ok( + stillPresent, + `P3-reclaim ${label}: foreign outbox with clicked channel absent from 500-entry ` + + `relay head must not be reclaimed — drop preservedKey from reclaimSubsumedOutbox ` + + `→ 501-entry merge evicts click → subsumed=true → record deleted`, + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + tauri.restore(); + restoreRelay(); + restoreTimers(); + window.localStorage.clear(); + mock.reset(); + } + }); } diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs index bac891290e..1453d64ca3 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -7,6 +7,9 @@ import { runWholeBlobHookSuite } from "./wholeBlobHook.shared.test.mjs"; import { runWholeBlobP2aSuite } from "./wholeBlobSyncP2a.shared.test.mjs"; import { runWholeBlobCarlSuite, + runWholeBlobC2Suite, + runWholeBlobC3Suite, + runWholeBlobP2a1Suite, runWholeBlobP2bSuite, } from "./wholeBlobSyncCarl.shared.test.mjs"; @@ -232,6 +235,29 @@ runWholeBlobP2aSuite({ }); runWholeBlobCarlSuite({ + label: "sections", + outboxKeyPrefix: "buzz-channel-sections-outbox.v1", + storageKey, + writeOutboxKey: (pubkey, relayUrl) => { + const encoded = encodeURIComponent( + relayUrl.trim().replace(/\/$/, "").toLowerCase(), + ); + return `buzz-channel-sections-outbox.v1:${pubkey}:${encoded}`; + }, + useHook: useChannelSections, + makeEditStore: () => ({ + version: 1, + sections: [{ id: "click", name: "Click", order: 0 }], + assignments: {}, + }), + makeRemoteStore: () => ({ + version: 1, + sections: [{ id: "remote", name: "Remote", order: 0 }], + assignments: {}, + }), +}); + +runWholeBlobP2a1Suite({ label: "sections", Manager: ChannelSectionSyncManager, publishEdit: (m, store) => m.publishSections(store), @@ -254,6 +280,45 @@ runWholeBlobCarlSuite({ }), }); +runWholeBlobC2Suite({ + label: "sections", + Manager: ChannelSectionSyncManager, + publishEdit: (m, store) => m.publishSections(store), + publishReplay: (m, store) => m.publishSections(store, true), + subscribe: (m, cb) => m.subscribeToSections(cb), + makeNonEmptyStore: () => ({ + version: 1, + sections: [{ id: "s1", name: "Work", order: 0 }], + assignments: {}, + }), + makeEditStore: () => ({ + version: 1, + sections: [{ id: "click", name: "Click", order: 0 }], + assignments: {}, + }), + makeRemoteStore: () => ({ + version: 1, + sections: [{ id: "remote", name: "Remote", order: 0 }], + assignments: {}, + }), +}); + +runWholeBlobC3Suite({ + label: "sections", + Manager: ChannelSectionSyncManager, + publishEdit: (m, store) => m.publishSections(store), + makeEditStore: () => ({ + version: 1, + sections: [{ id: "click", name: "Click", order: 0 }], + assignments: {}, + }), + makeRemoteStore: () => ({ + version: 1, + sections: [{ id: "remote", name: "Remote", order: 0 }], + assignments: {}, + }), +}); + runWholeBlobP2bSuite({ label: "sections", Manager: ChannelSectionSyncManager, diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 6cbaf6ac6d..422ca68b0e 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -192,6 +192,7 @@ export function useChannelSections( const durable = managerRef.current?.publishSections( outbox.store, true, + outbox.queuedAt, ); if (durable && outbox.legacyRawToConsume !== null) { markChannelSectionsLegacyConsumed( diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs index 824a7bdc0e..c240ee6f55 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs @@ -7,6 +7,9 @@ import { runWholeBlobHookSuite } from "./wholeBlobHook.shared.test.mjs"; import { runWholeBlobP2aSuite } from "./wholeBlobSyncP2a.shared.test.mjs"; import { runWholeBlobCarlSuite, + runWholeBlobC2Suite, + runWholeBlobC3Suite, + runWholeBlobP2a1Suite, runWholeBlobP2bSuite, } from "./wholeBlobSyncCarl.shared.test.mjs"; @@ -147,6 +150,32 @@ runWholeBlobP2aSuite({ }); runWholeBlobCarlSuite({ + label: "sort", + outboxKeyPrefix: "buzz-channel-sort-outbox.v1", + storageKey, + writeOutboxKey: (pubkey, relayUrl) => { + const encoded = encodeURIComponent( + relayUrl.trim().replace(/\/$/, "").toLowerCase(), + ); + return `buzz-channel-sort-outbox.v1:${pubkey}:${encoded}`; + }, + useHook: useChannelSortPreference, + makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }), + makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }), +}); + +runWholeBlobP2a1Suite({ + label: "sort", + Manager: ChannelSortSyncManager, + publishEdit: (m, store) => m.publishSortPrefs(store), + publishReplay: (m, store) => m.publishSortPrefs(store, true), + subscribe: (m, cb) => m.subscribeToSortPrefs(cb), + makeNonEmptyStore: () => ({ version: 1, groups: { s1: "recent" } }), + makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }), + makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }), +}); + +runWholeBlobC2Suite({ label: "sort", Manager: ChannelSortSyncManager, publishEdit: (m, store) => m.publishSortPrefs(store), @@ -164,3 +193,11 @@ runWholeBlobP2bSuite({ makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }), makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }), }); + +runWholeBlobC3Suite({ + label: "sort", + Manager: ChannelSortSyncManager, + publishEdit: (m, store) => m.publishSortPrefs(store), + makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }), + makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }), +}); diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 202aa1210a..5a0018e298 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -189,6 +189,7 @@ export function useChannelSortPreference( const durable = managerRef.current?.publishSortPrefs( outbox.store, true, + outbox.queuedAt, ); if (durable && outbox.legacyRawToConsume !== null) { markChannelSortLegacyConsumed( diff --git a/desktop/src/features/sidebar/lib/wholeBlobSyncCarl.shared.test.mjs b/desktop/src/features/sidebar/lib/wholeBlobSyncCarl.shared.test.mjs index fccf88cdea..9278adb2d8 100644 --- a/desktop/src/features/sidebar/lib/wholeBlobSyncCarl.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/wholeBlobSyncCarl.shared.test.mjs @@ -1,30 +1,37 @@ -// Carl-round regression suite for WholeBlobSyncManager (P1, P2a-1, P2b). +// Carl-round regression suite for whole-blob sync (P1/C1, P2a-1/C2, P2b). // -// Three causal regressions confirmed at source in Duncan's analysis pass: +// P1/C1 — restored-outbox provenance (failed bootstrap): +// A restored edit with queuedAt=200 vs a failed-bootstrap relay head at +// createdAt=100 must PUBLISH (the edit is genuinely newer). Without the fix +// the failed-bootstrap exception fires and publishes over the peer head, OR +// the adopt path runs unconditionally and throws away the newer edit. +// Mutation (P1): remove !pendingIsRestoredReplay guard from the exception +// → exception fires, the edit publishes as if fresh. +// Mutation (C1-adopt): remove pendingRestoredQueuedAt adopt-guard +// → restored edit always adopts regardless of age. // -// T-P1 (hook layer): failed bootstrap → restored outbox (old queuedAt) → -// bootstrap `.then()` replay → pre-publish fetch finds newer peer head B -// retained while device was closed → must ADOPT (never publish stale edit -// over B). -// Mutation: remove `!this.pendingIsRestoredReplay` guard from the -// failed-bootstrap exception → exception fires, folds B in, publishes over it. +// C2 — successful-bootstrap replay needs a baseline: +// A restored edit with queuedAt=100 vs a successful bootstrap at H100 +// (createdAt=50) must PUBLISH (hook replays because queuedAt >= createdAt). +// Without the fix publishBaseline stays {0,""} and the pre-publish fetch +// sees H100 as an advance, adopting the edit away. +// Mutation (C2): do not set publishBaseline from bootstrapResultHead in +// publish(_, true) → baseline stays {0,""} → H100 adopted → no publish. // -// T-P2a1 (hook layer): blocked bootstrap H100 → click → live H102 arrives and -// is suppressed (hasPendingEdit) → bootstrap resolves H100 → hook `.then()` -// calls publishSections/publishSortPrefs(outbox, isRestoredReplay=true) → -// replay must NOT re-freeze publishBaseline from mutable lastRemoteHead → -// pre-publish fetch returns H102 → ADOPT (H102 is a genuine advance). -// Mutation: remove `if (!isRestoredReplay)` guard from publish() → -// publishBaseline refreezes to H102 → pre-publish sees equality → publishes -// pre-H102 content over H102, H102's changes lost. +// P2a-1 (manager layer — blocked-bootstrap sequence): +// Blocked H100 → click → live H102 arrives suppressed → bootstrap resolves +// H100 → hook replay (isRestoredReplay=true) → H102 must remain a genuine +// advance (ADOPT). At hook layer the replay is driven by publish(_, true) +// which uses canonicalMax(current, bootstrapResultHead). Manager-level test +// is sufficient here because the state machine (publishBaseline vs +// bootstrapResultHead vs lastRemoteHead) is manager-internal. +// Mutation: set publishBaseline = lastRemoteHead in publish(_, true) +// → H102 folds into baseline → pre-publish sees equality → publish-over. // -// T-P2b (manager layer): periodic/reconnect fetchRemoteBlob path records -// lastRemoteHead BEFORE decryptAndParse returns; a concurrent click freezes -// publishBaseline against the pre-decrypt head; the pre-publish fetch then -// sees no advance and publishes stale content over the fetched event. -// Mutation: move recordRemoteHead back above decryptAndParse (revert P2b fix) -// → publishBaseline freezes against undecrypted head → pre-publish fetch -// returns same head → no advance → publish-over. +// P2b (manager layer — fetchRemoteBlob decrypt gap): +// Click during periodic-fetch decrypt gap must not publish over the head. +// Manager-level test is sufficient; the race is manager-internal. +// Mutation: move recordRemoteHead back above decryptAndParse. import assert from "node:assert/strict"; import test, { mock } from "node:test"; @@ -32,25 +39,268 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { makeHookTimerBed, + makeHookStubs, installEchoTauri, } from "./sidebarSyncTestHelpers.mjs"; -const RELAY = "wss://r.carl"; +const { stubRelay } = makeHookStubs(); + +// ───────────────────────────────────────────────────────────────────────────── +// Hook-layer suites: P1/C1 and C2 use the actual React hooks so the real +// bootstrap .then() callback, outbox read, queuedAt, and shouldReplay guard +// are exercised. Both sections and sort variants run via runWholeBlobCarlSuite. +// ───────────────────────────────────────────────────────────────────────────── /** - * Register the Carl-round P1/P2a-1 hook-layer regressions for a single lane. + * Hook-layer P1/C1 and C2 regressions for a single whole-blob lane. * * @param {object} opts - * @param {string} opts.label — "sections"|"sort" - * @param {Function} opts.Manager — concrete manager class - * @param {Function} opts.publishEdit — (manager, store) => void (fresh click, isRestoredReplay=false) - * @param {Function} opts.publishReplay — (manager, store) => void (restored replay, isRestoredReplay=true) - * @param {Function} opts.subscribe — (manager, cb) => Promise - * @param {Function} opts.makeNonEmptyStore — () => non-empty mount store - * @param {Function} opts.makeEditStore — () => user-click store (distinct from mount) - * @param {Function} opts.makeRemoteStore — () => peer relay head store + * @param {string} opts.label "sections"|"sort" + * @param {string} opts.outboxKeyPrefix e.g. "buzz-channel-sections-outbox.v1" + * @param {string} opts.storageKeyPrefix localStorage key for the store + * @param {Function} opts.storageKey (pubkey, relayUrl) => string (store key) + * @param {Function} opts.writeOutboxKey (pubkey, relayUrl) => v2 outbox key + * (legacy shared key — pre-v2 nonce path) + * @param {Function} opts.useHook the hook under test + * @param {Function} opts.makeEditStore () => store (the restored edit) + * @param {Function} opts.makeRemoteStore () => store (peer head content) */ export function runWholeBlobCarlSuite({ + label, + outboxKeyPrefix, + storageKey, + writeOutboxKey, + useHook, + makeEditStore, + makeRemoteStore, +}) { + // ── P1/C1-publish: failed bootstrap → restored outbox (queuedAt=200) → + // relay head at createdAt=100 → MUST PUBLISH (restored edit is newer). + // + // Production sequence through the real hook: + // 1. Outbox seeded with queuedAt=200 (prior session edit). + // 2. Bootstrap fetch fails → bootstrapFailed=true. + // 3. hook .then(): result.action="hold" → shouldReplay=true + // → publishSections(store, true, 200) → publish(_, true, 200) + // → publishBaseline = canonicalMax({0,""}, bootstrapResultHead={0,""}) = {0,""} + // → pendingRestoredQueuedAt = 200. + // 4. Debounce fires. fetchOwnBlobBeforePublish returns peerHead (createdAt=100). + // remoteAdvancedSince(100, {0,""}) = true. + // Failed-bootstrap exception: !pendingIsRestoredReplay suppresses it. + // Restored-replay adopt-guard: remote.createdAt(100) <= queuedAt(200) + // → PUBLISH (restored edit is genuinely newer than head). + // + // Mutations that must make this test red: + // M1 (exception not suppressed): remove !pendingIsRestoredReplay guard + // → exception fires → publishBaseline folds peerHead in → publish. + // Test PASSES with mutation: this mutation does not cause silent data-loss + // here, it publishes correctly but for the WRONG reason. The real defect + // is the stale-queuedAt remint. We catch that separately in C1-adopt. + // + // M2 (adopt-guard absent, C1): remove pendingRestoredQueuedAt guard + // → restored replay always adopts when remoteAdvancedSince=true + // → publish=0, adopted=1 → test FAILS. + test(`P1/C1 ${label}: failed-bootstrap hook replay — restored edit (queuedAt=200) must publish above older relay head (createdAt=100)`, async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const pubkey = `pk-c1-fail-${label}`; + const relayUrl = `wss://r.c1fail.${label}`; + const encodedRelay = encodeURIComponent(relayUrl); + + const tauri = installEchoTauri(pubkey); + const restoreRelay = stubRelay(relayClient); + + // Seed legacy outbox: queuedAt=200, store has the edit. + const legacyKey = writeOutboxKey(pubkey, relayUrl); + window.localStorage.setItem( + legacyKey, + JSON.stringify({ store: makeEditStore(), queuedAt: 200 }), + ); + // Seed local store = edit store (so hook mounts with non-empty state). + window.localStorage.setItem( + storageKey(pubkey, relayUrl), + JSON.stringify(makeEditStore()), + ); + + // peerHead: another peer's head, createdAt=100 (older than queuedAt=200). + const peerHead = tauri.mintHead( + makeRemoteStore(), + 100, + `evt-c1-peer-${label}`, + ); + peerHead.pubkey = pubkey; + peerHead.kind = 30078; + + const publishCalls = []; + let fetchCalls = 0; + relayClient.fetchEvents = async () => { + fetchCalls++; + // Call 1 = bootstrap fetch: fail. + if (fetchCalls === 1) return Promise.reject(new Error("bootstrap fail")); + // Subsequent calls (pre-publish fetch): return peerHead. + return [peerHead]; + }; + relayClient.publishEvent = async (evt) => { + publishCalls.push(evt); + }; + + const { fireDelay, restore: restoreTimers } = makeHookTimerBed(); + const origDateNow = Date.now; + Date.now = () => 300 * 1_000; // wall clock at t=300 — remint would stamp 300 + + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useHook(pubkey, relayUrl)); + for (let i = 0; i < 40; i++) await Promise.resolve(); + }); + + // Bootstrap failed → hook .then() fired → shouldReplay=true (hold) + // → publishSections(store, true, 200) called. + // Debounce scheduled. Fire it. + await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + // With the fix: restored edit (queuedAt=200) > head (createdAt=100) + // → pendingRestoredQueuedAt guard → PUBLISH. + // Mutation (C1-adopt): guard absent → adopt → publishCalls.length === 0. + assert.ok( + publishCalls.length > 0, + `P1/C1 ${label}: restored edit (queuedAt=200) must publish above older head ` + + `(createdAt=100) — drop pendingRestoredQueuedAt guard → adopt-away`, + ); + // Verify the published store contains the edit, not the peer head. + const plaintext = tauri.capturedPlaintext(); + assert.ok(plaintext !== null, "encrypt must have been called"); + const published = JSON.parse(plaintext); + assert.deepEqual( + published, + makeEditStore(), + `P1/C1 ${label}: published store must be the restored edit, not the peer head`, + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + tauri.restore(); + restoreRelay(); + restoreTimers(); + window.localStorage.clear(); + mock.reset(); + } + }); + + // ── C2: successful bootstrap → hook replay (queuedAt >= bootstrapHead.createdAt) + // → MUST PUBLISH above the bootstrap head, not adopt it away. + // + // Note: the hook layer always passes queuedAt through publishSections, so the + // restored-replay adopt-guard (C1) also fires and protects the case where + // remote.createdAt <= queuedAt. The independent C2 coverage is in the manager + // layer (runWholeBlobC2Suite), where we drive publish(_, true, undefined) so + // only the bootstrapResultHead baseline mechanism (C2) can protect the edit. + // This hook test verifies the combined C1+C2 end-to-end path: outbox read, + // queuedAt threading, shouldReplay guard, and the real .then() callback. + // + // Probe: queuedAt=100, H50 (createdAt=50), shouldReplay=true. With fix: + // publishBaseline={50,id} → no advance → PUBLISH; without fix publishBaseline + // stays {0,""} but C1 guard fires (50<=100) → also publishes. This test is + // a combined integration path; mutation-causal C2 coverage lives below. + test(`C2 ${label}: successful-bootstrap hook replay (queuedAt=100 >= head.createdAt=50) must publish above bootstrap head, not adopt it away`, async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const pubkey = `pk-c2-${label}`; + const relayUrl = `wss://r.c2.${label}`; + + const tauri = installEchoTauri(pubkey); + const restoreRelay = stubRelay(relayClient); + + // Seed legacy outbox: queuedAt=100. + const legacyKey = writeOutboxKey(pubkey, relayUrl); + window.localStorage.setItem( + legacyKey, + JSON.stringify({ store: makeEditStore(), queuedAt: 100 }), + ); + window.localStorage.setItem( + storageKey(pubkey, relayUrl), + JSON.stringify(makeEditStore()), + ); + + // Bootstrap head: H50 at createdAt=50. + const H50 = tauri.mintHead(makeRemoteStore(), 50, `evt-c2-h50-${label}`); + H50.pubkey = pubkey; + H50.kind = 30078; + // Tag so the hook's decryptAndParse can identify the d-tag. + H50.tags = [ + ["d", label === "sections" ? "channel-sections" : "channel-sort"], + ]; + + const publishCalls = []; + let fetchCalls = 0; + relayClient.fetchEvents = async () => { + fetchCalls++; + // Call 1 = bootstrap fetch: return H50. + if (fetchCalls === 1) return [H50]; + // Pre-publish fetch: return H50 (relay still has H50). + return [H50]; + }; + relayClient.publishEvent = async (evt) => { + publishCalls.push(evt); + }; + + const { fireDelay, restore: restoreTimers } = makeHookTimerBed(); + const origDateNow = Date.now; + Date.now = () => 200 * 1_000; + + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useHook(pubkey, relayUrl)); + for (let i = 0; i < 40; i++) await Promise.resolve(); + }); + + await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + // Combined C1+C2 path: restored edit (queuedAt=100) vs head (createdAt=50). + // C2 fix: publishBaseline={50,id} → no advance → PUBLISH. + // C1 guard also fires: 50<=100 → PUBLISH (provides redundant protection). + assert.ok( + publishCalls.length > 0, + `C2 ${label}: hook replay (queuedAt=100 >= bootstrapHead.createdAt=50) ` + + `must publish above bootstrap head`, + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + tauri.restore(); + restoreRelay(); + restoreTimers(); + window.localStorage.clear(); + mock.reset(); + } + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Manager-layer suites: P2a-1 and P2b test manager-internal state that cannot +// be driven from the hook layer without precise timing control. These are +// correctly labeled as manager-level tests. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Manager-layer C2 regression (successful-bootstrap replay needs a baseline). + * + * This is the mutation-causal test for C2. The hook layer cannot isolate C2 + * from C1 because whenever shouldReplay fires (queuedAt >= bootstrapHead), + * queuedAt is threaded through and C1's guard also protects the same case. + * At the manager layer we drive publish(_, true, undefined) — no queuedAt — + * so C1's guard is disabled and only C2's bootstrapResultHead baseline can + * prevent the adopt. + * + * Probe: bootstrap H50 → publish(store, true, undefined) → pre-publish H50 + * → With fix: publishBaseline={50,id} → not advance → PUBLISH. + * → Mutation: publishBaseline={0,""} → H50 advance → adopt → publish=0. + */ +export function runWholeBlobC2Suite({ label, Manager, publishEdit, @@ -60,46 +310,23 @@ export function runWholeBlobCarlSuite({ makeEditStore, makeRemoteStore, }) { - // T-P1: failed bootstrap → hook replay of restored outbox → peer head B was - // retained while device was closed → replay must ADOPT, never publish over B. - // - // Production sequence: - // 1. A edits at t=100, queuedAt=100, quits before publish. - // 2. Peer retains head B at t=200 while A is offline. - // 3. A reopens: bootstrap fetch fails → bootstrapFailed=true. - // 4. Hook `.then()` reads outbox (queuedAt=100 < B.createdAt=200 → shouldReplay - // guard in the hook would be false for apply-remote; but bootstrap=hold here - // so shouldReplay is always true). The replay calls publish(store, - // isRestoredReplay=true) → pendingIsRestoredReplay=true. - // 5. Debounce fires. fetchOwnBlobBeforePublish returns B. - // remoteAdvancedSince(B, {0,""}) = true → would normally adopt. - // Failed-bootstrap exception: bootstrapFailed=true, - // !bootstrapFailedExternalHeadObserved=true, publishBaseline={0,""}. - // With the fix: pendingIsRestoredReplay=true → exception suppressed - // → normal adopt fires. - // Mutation: exception fires → fold B into baseline → publish over B. - // - // The test uses a `hold` bootstrap (failed fetch) so the hook always replays. - // `shouldReplay` is true for `hold`. The outbox queuedAt (100) is older than - // B (200) — without the fix, the exception would override and publish over B. - test(`P1 ${label}: failed-bootstrap hook replay must adopt a newer peer head retained while device was closed, never publish over it`, async () => { + test(`C2 ${label} (manager): successful-bootstrap replay without queuedAt must publish above bootstrap head; bootstrapResultHead baseline is the only protection`, async () => { let publishCalls = 0; const { fireDelay, restore } = makeHookTimerBed(); - const tauri = installEchoTauri(`pk-p1-${label}`); + const tauri = installEchoTauri(`pk-c2m-${label}`); - const peerHead = tauri.mintHead( - makeRemoteStore(), - 200, - `evt-p1-peer-${label}`, - ); + const H50 = tauri.mintHead(makeRemoteStore(), 50, `evt-c2m-h50-${label}`); let fetchCalls = 0; + mock.method(relayClient, "subscribeLive", (_f, cb) => + Promise.resolve(async () => {}), + ); mock.method(relayClient, "fetchEvents", () => { fetchCalls++; - // Call 1 = bootstrap fetch: fail → bootstrapFailed=true. - if (fetchCalls === 1) return Promise.reject(new Error("bootstrap fail")); - // Subsequent calls (pre-publish fetch, retry): return peerHead. - return Promise.resolve([peerHead]); + // Bootstrap fetch: return H50. + if (fetchCalls === 1) return Promise.resolve([H50]); + // Pre-publish fetch: return H50 (relay unchanged). + return Promise.resolve([H50]); }); mock.method(relayClient, "publishEvent", () => { publishCalls++; @@ -107,40 +334,30 @@ export function runWholeBlobCarlSuite({ }); try { - const manager = new Manager(`pk-p1-${label}`, RELAY); + const manager = new Manager(`pk-c2m-${label}`, "wss://r.c2m"); const adopted = []; manager.setOnRemoteAdopted((r) => adopted.push(r)); - // Bootstrap fails synchronously. + await subscribe(manager, () => {}); await manager.bootstrap(makeNonEmptyStore()); + for (let i = 0; i < 20; i++) await Promise.resolve(); - // Hook .then() replays the outbox: publishEdit with isRestoredReplay=true. - // The manager sees bootstrapResolved=true and schedules the debounce. + // Simulate hook replay WITHOUT queuedAt (undefined = no C1 guard). + // Only C2's bootstrapResultHead baseline can prevent adopt here. publishReplay(manager, makeEditStore()); - // Debounce fires. await fireDelay(2000); for (let i = 0; i < 100; i++) await Promise.resolve(); - // The pre-publish fetch returns peerHead (createdAt=200) which is newer - // than publishBaseline={0,""}. With the fix: pendingIsRestoredReplay=true - // → failed-bootstrap exception is suppressed → normal adopt fires. - // Mutation: exception fires → fold peerHead into baseline → publish over it. assert.equal( - publishCalls, - 0, - `P1 ${label}: restored replay must ADOPT newer peer head, never publish over it`, - ); - assert.equal( - adopted.length, - 1, - `P1 ${label}: peer head must be adopted after restored replay`, - ); - assert.equal( - manager.getPendingStore(), - null, - `P1 ${label}: pending cleared after adopt`, + publishCalls >= 1, + true, + `C2 ${label}: successful-bootstrap replay (no queuedAt) must PUBLISH above H50 — ` + + `drop bootstrapResultHead from publish(_, true) → publishBaseline stays {0,""} → H50 adopted before publish`, ); + // (The post-publish confirmation may still adopt H50 if our event loses + // the LWW confirm race — that is correct separate behaviour. The C2 + // defect is publish=0 + adopt=1 at the pre-publish fetch step.) manager.destroy(); } finally { tauri.restore(); @@ -148,22 +365,29 @@ export function runWholeBlobCarlSuite({ mock.reset(); } }); +} - // T-P2a1: blocked bootstrap H100 → click → live H102 decrypts (suppressed by - // hasPendingEdit) → bootstrap resolves H100 → hook .then() replay - // (isRestoredReplay=true) → H102 must be adopted as a genuine advance. +/** + * Manager-layer P2a-1 regression (blocked-bootstrap baseline after hook replay). + */ +export function runWholeBlobP2a1Suite({ + label, + Manager, + publishEdit, + publishReplay, + subscribe, + makeNonEmptyStore, + makeEditStore, + makeRemoteStore, +}) { + // T-P2a1 (manager layer): blocked H100 → click → live H102 suppressed → + // bootstrap resolves H100 → publish(_, true) uses canonicalMax(H100, + // bootstrapResultHead=H100) = H100 → pre-publish returns H102 → ADOPT. // - // This tests that the hook-level replay path (which calls publish with - // isRestoredReplay=true) does NOT re-freeze publishBaseline from lastRemoteHead - // (which now holds H102 after the live delivery). releaseDeferred already set - // publishBaseline = canonicalMax(click-time, H100) = H100 (click-time baseline - // was {0,""}). The replay's publish(store, true) must keep that baseline. - // Pre-publish fetch returns H102 (createdAt=400 > H100.createdAt=200) → - // remoteAdvancedSince(H102, H100) = true → ADOPT. - // - // Mutation: publish() re-freezes publishBaseline = lastRemoteHead = H102 → - // pre-publish sees equality → publishes pre-H102 content over H102 (lose H102). - test(`P2a-1 ${label}: hook replay after blocked bootstrap must not re-freeze baseline from live peer head H102; H102 must be adopted`, async () => { + // Mutation: set publishBaseline = lastRemoteHead in publish(_, true) + // → publishBaseline = H102 → remoteAdvancedSince(H102, H102) = false + // → publishes pre-H102 content over H102 (H102's changes lost). + test(`P2a-1 ${label} (manager): hook replay after blocked bootstrap must keep H100 baseline; H102 must be adopted as genuine advance`, async () => { let publishCalls = 0; let liveCallback = null; let releaseBootstrap = null; @@ -181,12 +405,10 @@ export function runWholeBlobCarlSuite({ let fetchCalls = 0; mock.method(relayClient, "fetchEvents", () => { fetchCalls++; - // Call 1 = bootstrap fetch (blocked). if (fetchCalls === 1) return new Promise((res) => { releaseBootstrap = () => res([H100]); }); - // Subsequent (pre-publish fetch): return H102. return Promise.resolve([H102]); }); mock.method(relayClient, "publishEvent", () => { @@ -195,49 +417,30 @@ export function runWholeBlobCarlSuite({ }); try { - const manager = new Manager(`pk-p2a1-${label}`, RELAY); + const manager = new Manager(`pk-p2a1-${label}`, "wss://r.carl"); const adopted = []; manager.setOnRemoteAdopted((r) => adopted.push(r)); - // Subscribe to capture live callback. await subscribe(manager, () => {}); - // Bootstrap blocked; fresh click BEFORE bootstrap resolves. - // (No existing outbox — this simulates a click during the bootstrap window, - // with the hook seeing an existing pending edit before .then() runs.) const bootstrapPromise = manager.bootstrap(makeNonEmptyStore()); - // Fresh click: publish(store, isRestoredReplay=false) — normal path, freezes - // publishBaseline = {0,""} (lastRemoteHead before any live event). publishEdit(manager, makeEditStore()); - // Live H102 arrives BEFORE bootstrap resolves (suppressed by hasPendingEdit). while (liveCallback === null) await Promise.resolve(); liveCallback(H102); for (let i = 0; i < 20; i++) await Promise.resolve(); - // lastRemoteHead is now H102. - // Bootstrap resolves with H100. releaseDeferred sets publishBaseline = - // canonicalMax({0,""}, H100) = H100. while (releaseBootstrap === null) await Promise.resolve(); releaseBootstrap(); await bootstrapPromise; for (let i = 0; i < 50; i++) await Promise.resolve(); - // bootstrapResolved=true. The timer was scheduled by releaseDeferred. - // Hook .then() would replay the outbox edit. Simulate: call publish with - // isRestoredReplay=true as the hook's .then() callback does. - // With the fix: baseline is unchanged (still H100). - // With the mutation: publishBaseline is reset to lastRemoteHead=H102. + // Simulate hook .then() replay with isRestoredReplay=true. publishReplay(manager, makeEditStore()); - // Fire the debounce. await fireDelay(2000); for (let i = 0; i < 100; i++) await Promise.resolve(); - // Pre-publish returns H102 (createdAt=400 > H100.createdAt=200). - // Fix: publishBaseline=H100 → remoteAdvancedSince(H102, H100) = true → ADOPT. - // Mutation: publishBaseline=H102 → remoteAdvancedSince(H102, H102) = false - // → publish-over fires, H102's changes lost. assert.equal( publishCalls, 0, @@ -258,15 +461,136 @@ export function runWholeBlobCarlSuite({ } /** - * Register the Carl-round P2b manager-level regression (fetchRemoteBlob - * decrypt gap — periodic/reconnect path). + * Manager-layer C3 regression (confirmRetainedHead decrypt gap). * - * @param {object} opts - * @param {string} opts.label — "sections"|"sort" - * @param {Function} opts.Manager — concrete manager class - * @param {Function} opts.publishEdit — (manager, store) => void - * @param {Function} opts.makeEditStore — () => store - * @param {Function} opts.makeRemoteStore — () => store + * Generation A publishes → confirm fetch finds foreign winner B → without the + * fix, canonical tuple advances to B before decrypt; a fresh Click C then + * freezes baseline=B; decrypt returns B; stale-gen adopt does not advance state + * for C; C's pre-publish fetch sees B == its baseline and publishes pre-B + * content OVER B (B's changes lost). + * + * Fix: advance lastRemoteHead for a foreign winner ONLY after decrypt succeeds. + * Mutation: move recordRemoteHead back above decryptAndParse for non-own-ID case. + */ +export function runWholeBlobC3Suite({ + label, + Manager, + publishEdit, + makeEditStore, + makeRemoteStore, +}) { + test(`C3 ${label} (manager): confirmRetainedHead must not advance lastRemoteHead before decrypt; click during confirm decrypt gap must not publish over foreign winner`, async () => { + let publishCalls = 0; + const { fireDelay, restore } = makeHookTimerBed(); + const tauri = installEchoTauri(`pk-c3-${label}`); + + // B: the foreign winner — createdAt=200 (must be > A's publish timestamp). + // A's publish timestamp = clampPublishCreatedAt(0) = max(floor(Date.now/1000), 1). + // We mock Date.now to return 50s so A's createdAt=50 < B's createdAt=200. + const B = tauri.mintHead(makeRemoteStore(), 200, `evt-c3-b-${label}`); + const origDateNow = Date.now; + Date.now = () => 50 * 1_000; // A publishes at createdAt=50 + + let releaseDecrypt = null; + const orig = globalThis.window.__TAURI_INTERNALS__; + let decryptCallCount = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + decryptCallCount++; + // The first decrypt call is from confirmRetainedHead — both bootstrap + // and pre-publish preflight return absent so no decrypt fires there. + // Gate the very first decrypt call (confirmRetainedHead's foreign winner). + if (decryptCallCount === 1) { + return new Promise((res, rej) => { + if (releaseDecrypt === null) { + // First gated call — park it. + releaseDecrypt = () => + orig.invoke(cmd, args).then(res).catch(rej); + } else { + // Subsequent calls resolve immediately. + orig.invoke(cmd, args).then(res).catch(rej); + } + }); + } + return orig.invoke(cmd, args); + } + return orig.invoke(cmd, args); + }, + }; + + let fetchCalls = 0; + let publishedEventId = null; + mock.method(relayClient, "fetchEvents", () => { + fetchCalls++; + // 1: bootstrap fetch — absent (no remote, so bootstrap result = {0,""}). + if (fetchCalls === 1) return Promise.resolve([]); + // 2: pre-publish preflight — absent (no head, publish proceeds). + if (fetchCalls === 2) return Promise.resolve([]); + // 3: confirmRetainedHead — returns foreign winner B (not our event). + return Promise.resolve([B]); + }); + mock.method(relayClient, "publishEvent", (evt) => { + publishedEventId = evt.id ?? `our-evt-${publishCalls}`; + publishCalls++; + return Promise.resolve(); + }); + + try { + const manager = new Manager(`pk-c3-${label}`, "wss://r.c3"); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r)); + + // Bootstrap: absent. baseline stays {0,""}. + await manager.bootstrap(makeEditStore()); + + // Click A: publish starts. + publishEdit(manager, makeEditStore()); + await fireDelay(2000); + // Let publish + confirm fetch start, then park on the confirm decrypt. + for (let i = 0; i < 30; i++) await Promise.resolve(); + + assert.ok( + releaseDecrypt !== null, + "confirm decrypt gate must have been hit (fetchCalls >= 3 and decrypt started)", + ); + + // Click C: arrives DURING the confirm decrypt gap. With mutation: + // lastRemoteHead was already advanced to B → publishBaseline = B. + // Pre-publish fetch returns B → remoteAdvancedSince(B, B) = false + // → publishes pre-B content OVER B. + // With fix: lastRemoteHead = {0,""} → publishBaseline = {0,""}. + // Pre-publish fetch returns B → remoteAdvancedSince(B, {0,""}) = true + // → ADOPT (B is a genuine advance; do not publish over it). + publishEdit(manager, makeEditStore()); + + releaseDecrypt(); + for (let i = 0; i < 20; i++) await Promise.resolve(); + + await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + // With fix: Click C's pre-publish fetch sees B as a genuine advance and + // adopts it. publishCalls = 1 (A's publish only; C is adopted). + // With mutation: publishCalls = 2 (A publishes, C also publishes over B). + assert.equal( + publishCalls, + 1, + `C3 ${label}: click during confirmRetainedHead decrypt gap must NOT publish over foreign winner — ` + + `move recordRemoteHead before decrypt → C baseline=B → C publishes over B`, + ); + manager.destroy(); + } finally { + globalThis.window.__TAURI_INTERNALS__ = orig; + Date.now = origDateNow; + restore(); + mock.reset(); + } + }); +} + +/** + * Manager-layer P2b regression (fetchRemoteBlob decrypt-gap). */ export function runWholeBlobP2bSuite({ label, @@ -275,48 +599,21 @@ export function runWholeBlobP2bSuite({ makeEditStore, makeRemoteStore, }) { - // T-P2b: periodic/reconnect fetchRemoteBlob records lastRemoteHead BEFORE - // decryptAndParse; a concurrent click (whose publish() fires during the - // async decrypt gap) freezes publishBaseline against the pre-decrypt head; - // the pre-publish fetch returns the same head; no advance → publish-over. - // - // Sequence: - // 1. fetchRemoteBlob is in-flight, has fetched the event but has not yet - // decrypted it. Before the fix, recordRemoteHead(event.id) fires here → - // lastRemoteHead advances to H. - // 2. User click: publish() freezes publishBaseline = lastRemoteHead = H. - // 3. decryptAndParse resolves → store updated to H's content. - // 4. Debounce fires. fetchOwnBlobBeforePublish returns H. - // remoteAdvancedSince(H, H) = false → publishes. BUT: our store is the - // click's intent, NOT H's content + click merged in → H's remote-only - // changes are lost. - // With the fix: recordRemoteHead deferred to after decrypt → at step 2 - // lastRemoteHead is still {createdAt: 0} → publishBaseline={0,""} → at - // step 4 remoteAdvancedSince(H, {0,""}) = true → ADOPT (or use exception - // path for a failed-bootstrap scenario). Either way, the clicked edit - // doesn't silently overwrite H's remote-only changes. - // - // This test exercises the race by controlling the decrypt resolution. - test(`P2b ${label}: fetchRemoteBlob must not advance lastRemoteHead before decryptAndParse succeeds; click during decrypt gap must not publish over fetched head`, async () => { + test(`P2b ${label} (manager): fetchRemoteBlob must not advance lastRemoteHead before decryptAndParse succeeds; click during decrypt gap must not publish over fetched head`, async () => { let publishCalls = 0; const { fireDelay, restore } = makeHookTimerBed(); const tauri = installEchoTauri(`pk-p2b-${label}`); - // H: the pre-existing relay head with some remote-only content. const H = tauri.mintHead(makeRemoteStore(), 200, `evt-p2b-h-${label}`); - // A controlled decrypt gate: we resolve the decrypt promise AFTER the click. let releaseDecrypt = null; - const origInvoke = globalThis.window?.__TAURI_INTERNALS__?.invoke; const orig = globalThis.window.__TAURI_INTERNALS__; - // Wrap decrypt: the first call (for H in fetchRemoteBlob) is gated. let decryptCallCount = 0; globalThis.window.__TAURI_INTERNALS__ = { invoke: (cmd, args) => { if (cmd === "nip44_decrypt_from_self") { decryptCallCount++; if (decryptCallCount === 1) { - // First decrypt (fetchRemoteBlob's call for H): hold it. return new Promise((res, rej) => { releaseDecrypt = () => orig.invoke(cmd, args).then(res).catch(rej); @@ -331,11 +628,8 @@ export function runWholeBlobP2bSuite({ let fetchCalls = 0; mock.method(relayClient, "fetchEvents", () => { fetchCalls++; - // Bootstrap fetch: absent (no remote head at mount). if (fetchCalls === 1) return Promise.resolve([]); - // fetchRemoteBlob (periodic/reconnect) call: return H. if (fetchCalls === 2) return Promise.resolve([H]); - // Pre-publish fetch: return H (relay still has H as head). return Promise.resolve([H]); }); mock.method(relayClient, "publishEvent", () => { @@ -344,38 +638,22 @@ export function runWholeBlobP2bSuite({ }); try { - const manager = new Manager(`pk-p2b-${label}`, RELAY); + const manager = new Manager(`pk-p2b-${label}`, "wss://r.carl"); const adopted = []; manager.setOnRemoteAdopted((r) => adopted.push(r)); - // Bootstrap: absent → bootstrapFailed=false, publishBaseline={0,""}. await manager.bootstrap(makeEditStore()); - // Trigger a fetchRemoteBlob (simulates the periodic/reconnect path). - // This call will block at the decrypt await. const fetchPromise = manager.fetchRemoteBlob(); - // Wait for the fetch to have collected the event but not yet decrypted. - // The fetch awaits decryptAndParse at this point. for (let i = 0; i < 10; i++) await Promise.resolve(); - // Click during the decrypt gap. - // With the fix: lastRemoteHead still {createdAt:0} (decrypt not done yet) - // → publishBaseline = {0,""}. - // With the mutation: lastRemoteHead = {200, H.id} (pre-decrypt record) - // → publishBaseline = {200, H.id}. publishEdit(manager, makeEditStore()); - // Release the decrypt. assert.ok(releaseDecrypt !== null, "decrypt gate must have been hit"); releaseDecrypt(); await fetchPromise; for (let i = 0; i < 20; i++) await Promise.resolve(); - // Debounce fires. Pre-publish fetch returns H (createdAt=200, same id). - // With the fix: publishBaseline={0,""} → remoteAdvancedSince(H,{0,""})=true - // → bootstrapFailed=false so no exception → normal ADOPT. - // With the mutation: publishBaseline=H → remoteAdvancedSince(H,H)=false - // → publish fires, H's remote-only content silently lost. await fireDelay(2000); for (let i = 0; i < 100; i++) await Promise.resolve(); diff --git a/desktop/src/features/sidebar/lib/wholeBlobSyncManager.ts b/desktop/src/features/sidebar/lib/wholeBlobSyncManager.ts index 2fed092cd4..53bcf483b2 100644 --- a/desktop/src/features/sidebar/lib/wholeBlobSyncManager.ts +++ b/desktop/src/features/sidebar/lib/wholeBlobSyncManager.ts @@ -124,8 +124,15 @@ export type WholeBlobLaneConfig = { parse: (json: unknown) => S | null; /** Serialize a store into the JSON object to be encrypted and published. */ serializePayload: (store: S) => unknown; - /** Persist this window's pending edit to the durable outbox. */ - writeOutbox: (pubkey: string, store: S, relayUrl: string) => boolean; + /** Persist this window's pending edit to the durable outbox. + * `nowSecs` overrides the default wall-clock stamp — pass the original + * `queuedAt` from a restored outbox so a replay never remints the age. */ + writeOutbox: ( + pubkey: string, + store: S, + relayUrl: string, + nowSecs?: number, + ) => boolean; /** Clear this window's own outbox key. */ clearOutbox: (pubkey: string, relayUrl: string) => void; /** True when `store` is semantically identical to `last`. Used to skip no-op publishes. */ @@ -136,31 +143,13 @@ export type WholeBlobLaneConfig = { /** * Shared whole-blob LWW sync engine used by ChannelSectionSyncManager and - * ChannelSortSyncManager. Both lanes use the same generation-CAS, outbox, - * watermark, retained-head confirmation, prior-gen fold, and bounded-retry - * machinery — only lane config (kind, d-tag, store type, parse/serialize, - * outbox functions) differs. - * - * ### Invariants preserved across all passes - * - * **pass-2 (genuine-remote adoption):** A remote observed passively during the - * debounce window still gets adopted because the pending edit's - * `publishBaseline` is frozen at queue time and only advances via our own - * successful publishes — not via passive remote observations. - * - * **pass-3 (own-confirmed fold):** A prior publish of ours that was confirmed - * is folded into `publishBaseline` so a later edit's pre-publish check does not - * mistake our own accepted write for a competing remote and adopt it away. - * - * **pass-4 (conditional catch rollback):** The `claimLegacy` logic lives in the - * storage layer (lane-specific) and is not affected by this shared engine. - * - * **r7 prior-gen fold:** When a same-second lower-id peer wins a LWW collision - * and a newer edit (gen B) was queued after A's ACK but before confirmation - * resolved, `adoptRemote`'s stale-gen path repairs the poisoned baseline via - * `foldSupersedingAttemptWinner` so B publishes above the true retained head - * rather than adopting the winner away. The generation guard requires the - * baseline attempt to be prior-generation (`attempt < pendingGeneration`). + * ChannelSortSyncManager. Both lanes share generation-CAS, outbox, watermark, + * retained-head confirmation, prior-gen fold, and bounded-retry machinery — + * only lane config (kind, d-tag, store type, parse/serialize, outbox fns) + * differs. Invariants: pass-2 (remote observed during debounce adopts via + * frozen baseline), pass-3 (own confirmed write folds into baseline, not + * adopted away), r7 prior-gen fold (same-second LWW loser repairs poisoned + * baseline for a newer edit queued after the ACK). */ export class WholeBlobSyncManager { private pubkey: string; @@ -228,6 +217,19 @@ export class WholeBlobSyncManager { // session's durable outbox record. Suppresses the failed-bootstrap exception // in fetchOwnBlobBeforePublish (Carl P1, see publish() JSDoc). private pendingIsRestoredReplay = false; + // The original queuedAt from the restored outbox (seconds). Set only when + // pendingIsRestoredReplay=true; cleared with the pending generation. + // Used in fetchOwnBlobBeforePublish to guard the failed-bootstrap adopt path: + // a restored edit with queuedAt=200 must publish above a head at createdAt=100 + // (the restored edit is genuinely newer), not adopt it away. + private pendingRestoredQueuedAt: number | undefined = undefined; + // The head snapshot bootstrap() last fetched successfully (or {0,""} when + // bootstrap has not run / ran with a failed fetch). Stored when bootstrap + // resolves so a restored replay that arrives in the hook's .then() callback + // can establish publishBaseline from this immutable snapshot rather than from + // mutable lastRemoteHead (which subscribeLive may have advanced to a + // suppressed live peer head — H102 — that must remain a genuine remote advance). + private bootstrapResultHead: PublishBaseline = { createdAt: 0, eventId: "" }; // Event ids we signed and sent to the relay but whose ACK never arrived (the // publish promise rejected as a timeout/socket error after the frame left). // The relay MAY have accepted such a write, so if a later cycle's pre-publish @@ -379,17 +381,12 @@ export class WholeBlobSyncManager { } /** - * Release any edit that was deferred until bootstrap resolved. Called by - * bootstrap() after it sets bootstrapResolved=true, passing the head that - * bootstrap itself observed (the canonical head at the moment bootstrap - * completed — NOT the mutable lastRemoteHead, which subscribeLive may advance - * at any time). Re-freezes publishBaseline to - * canonicalMax(queueTimeBaseline, bootstrapResultHead) and schedules the 2s - * debounce. Using the bootstrap-result snapshot (not lastRemoteHead) is - * critical: a live peer head arriving after the click but before bootstrap - * resolves must remain a genuine remote advance, not be folded into the - * baseline. For a failed bootstrap, bootstrapResultHead is {0,""} and the - * bootstrapFailed flag enables the doPublish-time exception. + * Release any edit deferred until bootstrap resolved. Re-freezes + * publishBaseline to canonicalMax(queueTime, bootstrapResultHead) and + * schedules the 2s debounce. Uses the bootstrap-result snapshot (NOT mutable + * lastRemoteHead) so a live peer head arriving during the bootstrap fetch + * remains a genuine advance. For a failed bootstrap, bootstrapResultHead is + * {0,""} and bootstrapFailed enables the doPublish-time exception. */ private releaseDeferred(bootstrapResultHead: PublishBaseline): void { if ( @@ -416,28 +413,13 @@ export class WholeBlobSyncManager { } /** - * When the current `publishBaseline` was frozen against one of our own - * attempted (but non-retained) event ids and `remote` is the same-second - * lower-id peer that won the collision, the baseline is poisoned: the next - * pre-publish check would see the winner as having advanced past our phantom - * attempt and adopt the edit away (Carl P1 / same-second variants). Folding - * the winner in repairs the baseline so the pre-publish check sees the true - * retained head and publishes above it. - * - * Scoped to same-second lower-id only: a strictly-later remote is a genuine - * advance that the pending edit must still adopt (pass-2 invariant). - * - * Requires the baseline attempt to be prior-generation (gen < - * pendingGeneration): if no newer edit was queued, the current generation's - * own in-flight attempt cannot trigger the fold — the loser must adopt (LWW - * correctness). The post-ACK ordering is load-bearing for causal soundness: - * because A's ACK returned before B queued, the peer winner pre-existed B, so - * preserving B is causally justified. If B queues while A's ACK is pending, - * the peer may have been accepted after B and is a genuine remote advance B - * must adopt — B's baseline will not equal A's attempt id in that case, so - * the fold does not fire (correct behaviour, by the causal argument above). - * - * Returns true when the fold was applied (caller may use for branching). + * When `publishBaseline` was frozen against one of our own attempted (but + * non-retained) event ids and `remote` is the same-second lower-id peer that + * won the collision, the baseline is poisoned. Fold the winner in so the + * next pre-publish check sees the true retained head and publishes above it. + * Scoped to same-second lower-id only (strictly-later is a genuine advance). + * Requires the baseline attempt to be prior-generation (gen < pendingGeneration). + * Returns true when the fold was applied. */ private foldSupersedingAttemptWinner(remote: { createdAt: number; @@ -468,11 +450,7 @@ export class WholeBlobSyncManager { private adoptRemote(remote: RemoteBlob, gen: number): void { this.recordRemoteHead(remote.createdAt, remote.eventId); if (gen !== this.pendingGeneration) { - // A newer edit is pending. Its baseline may have been frozen against our - // own attempted (non-retained) event id if it was queued after the ACK - // but before confirmation resolved. Repair any poisoned baseline so the - // newer edit's pre-publish check publishes above the true retained head - // rather than adopting the winner away (Carl P1). + // Newer edit pending — repair any poisoned baseline (Carl P1). this.foldSupersedingAttemptWinner(remote); return; } @@ -483,65 +461,72 @@ export class WholeBlobSyncManager { } /** - * Clear the in-memory pending edit and this window's own durable outbox key — - * but only if the completing publish still owns the current generation. A - * publish for an older edit that finishes after a newer edit was queued must - * leave the newer edit (and its retry state) untouched. + * Clear the pending edit and durable outbox only when `gen` still owns the + * current generation — a stale publish must not clear a newer edit's state. */ private discardPending(gen: number): void { if (gen !== this.pendingGeneration) return; this.pendingStore = null; this.pendingIsRestoredReplay = false; + this.pendingRestoredQueuedAt = undefined; this.config.clearOutbox(this.pubkey, this.relayUrl); } /** * Queue a store for debounced publish. * - * @param isRestoredReplay When true, this call is a hook-level replay of a - * prior session's durable outbox record. Two behaviours are adjusted: + * When `isRestoredReplay=true` (hook-level replay of a prior session's outbox): + * - **Baseline (C2):** set to `canonicalMax(current, bootstrapResultHead)` so + * a no-prior-pending replay gets the correct baseline (blocked-bootstrap + * replay is idempotent; H102 in lastRemoteHead stays a genuine advance). + * - **Age (C1):** `writeOutbox` is called with `restoredQueuedAt` so the + * on-disk stamp is never reminted; `pendingRestoredQueuedAt` drives the + * adopt-guard in `fetchOwnBlobBeforePublish`. + * - **Failed-bootstrap exception:** suppressed for replays (Carl P1). * - * 1. **Baseline preservation (P2a-1):** `publishBaseline` is NOT - * re-frozen from `lastRemoteHead`. `releaseDeferred` already set it to - * `canonicalMax(queueTime, bootstrapResult)` — re-freezing here would - * fold in a suppressed live peer head (H102) and cause the pre-publish - * check to see equality and publish over H102 instead of adopting it. - * - * 2. **Failed-bootstrap exception (P1):** the "first unknown base" - * exception in `fetchOwnBlobBeforePublish` is suppressed. A restored - * replay carries its original `queuedAt` from the prior session and its - * baseline was already frozen then — the exception was designed only for - * fresh in-session clicks. Letting it fire for a replay would publish - * the stale edit over a newer peer head retained while offline (Carl P1). + * @param restoredQueuedAt Original `queuedAt` from the prior outbox; controls + * the on-disk stamp and adopt-vs-publish in `fetchOwnBlobBeforePublish`. */ - publish(store: S, isRestoredReplay = false): boolean { + publish( + store: S, + isRestoredReplay = false, + restoredQueuedAt?: number, + ): boolean { this.pendingStore = store; ++this.pendingGeneration; - // Freeze the canonical head this edit is racing against at queue time. The - // pre-publish check compares the fetched head against this baseline, not the - // mutable watermark — a live event applied during the debounce window - // advances the watermark to a new remote head, which would otherwise make - // the pre-publish comparison see equality and fall through to a publish that - // overwrites a remote that became head *after* this edit was queued. The - // baseline only advances via our own successful publishes (see doPublish), - // so a prior generation's own accepted write is folded in rather than - // mistaken for a competing remote. For a restored replay, the baseline was - // already established by releaseDeferred — do not re-freeze (see JSDoc). - if (!isRestoredReplay) { + // Freeze the canonical head this edit races against at queue time. The + // pre-publish check compares against this baseline — not the mutable + // watermark — so a live event during the debounce window that advances the + // watermark still gets adopted (baseline only advances via our own + // confirmed publishes). For a restored replay: use canonicalMax(current, + // bootstrapResultHead) from the immutable bootstrap snapshot (C2): + // No prior pending: publishBaseline={0,""}, canonicalMax sets bootstrap head. + // Blocked-bootstrap: releaseDeferred already set it; re-apply is idempotent. + // lastRemoteHead excluded (may carry suppressed H102 from subscribeLive). + if (isRestoredReplay) { + this.publishBaseline = canonicalMax( + this.publishBaseline, + this.bootstrapResultHead, + ); + } else { this.publishBaseline = { ...this.lastRemoteHead }; } - // Record whether this is a restored replay so fetchOwnBlobBeforePublish - // can suppress the failed-bootstrap exception (P1) — a replay must not - // re-qualify for the "first unknown base" logic. + // Suppress failed-bootstrap exception for replays (P1/C1); preserve original + // queuedAt for the failed-bootstrap adopt-guard (publish when relay head + // createdAt <= restoredQueuedAt — restored edit is genuinely newer). this.pendingIsRestoredReplay = isRestoredReplay; - // Persist synchronously so an edit made <2s before quit/community-switch - // survives teardown and resumes on next mount (durable outbox). - // This window's own key is the only one written — a single unconditional setItem, - // never a shared-key read-modify-write; `queuedAt` stamps it so resume - // replays only the newest queued blob (whole-blob LWW). The returned flag - // reports whether the intent is now durably held, so a legacy replay can - // gate its consumption marker on a proven transfer. - const durable = this.config.writeOutbox(this.pubkey, store, this.relayUrl); + this.pendingRestoredQueuedAt = isRestoredReplay + ? restoredQueuedAt + : undefined; + // Persist synchronously (durable outbox). Pass restoredQueuedAt so the + // on-disk stamp is never reminted for a replay (C1). Returns durable flag + // so a legacy replay can gate its consumed marker on a proven transfer. + const durable = this.config.writeOutbox( + this.pubkey, + store, + this.relayUrl, + isRestoredReplay ? restoredQueuedAt : undefined, + ); if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); this.debounceTimer = null; @@ -620,17 +605,11 @@ export class WholeBlobSyncManager { // against state we cannot inspect, so retain the pending edit and retry // rather than blindly overwrite authoritative state (Carl P1). if (!remote) return { kind: "retain" }; - // Whole-blob LWW. Compare the fetched head against the baseline frozen - // when this edit was queued — NOT the live watermark, which a passive live - // event during debounce may already have advanced to this same head. If - // the canonical head advanced since the edit began, the local edit lost - // and is adopted-away rather than republished over it. + // Whole-blob LWW: compare against the frozen baseline (not the live + // watermark — a passive event during debounce must still adopt). if (remoteAdvancedSince(remote, this.publishBaseline)) { - // Unless the advancing head is a prior publish of OURS whose ACK was - // lost: the relay accepted it, but our promise rejected before we could - // fold it forward, so it is our own accepted predecessor — not a - // competing remote. Fold it into the baseline and publish above it so - // the queued edit survives instead of adopting our own stale write away. + // Advancing head is a lost-ACK write of ours: fold into baseline and + // publish above our own accepted predecessor. if (this.ambiguousAttemptIds.has(remote.eventId)) { this.publishBaseline = canonicalMax(this.publishBaseline, { createdAt: remote.createdAt, @@ -639,31 +618,15 @@ export class WholeBlobSyncManager { return { kind: "publish", store, fetchedRemote: remote }; } // Same-second lower-id peer winner that superseded our own attempted - // head: baseline.eventId is our attempted id (still in - // ambiguousAttemptIds because the adopt path never clears it) and the - // relay retained the peer's lower-id blob instead. This arises when - // confirmation returned retain/failed and the retry pre-publish fetch - // sees the winner directly with the original poisoned baseline still in - // place. Fold the winner in so this edit publishes above the true - // retained head rather than adopting it away (Carl P1). + // head (baseline.eventId is our attempt, still in ambiguousAttemptIds). + // Fold the winner in and publish above the true retained head (Carl P1). if (this.foldSupersedingAttemptWinner(remote)) { return { kind: "publish", store, fetchedRemote: remote }; } - // Failed-bootstrap exception (P2a): when bootstrap failed, the FIRST - // relay head at pre-publish time is the true first established baseline; - // publish above it rather than adopting it away. - // - // Three guards: (1) no external head observed after the failed bootstrap - // (subscribeLive or reconnect/periodic fetch set - // bootstrapFailedExternalHeadObserved — that head is a genuine post-click - // advance that normal remoteAdvancedSince correctly adopts); (2) this - // is NOT a restored outbox replay (pendingIsRestoredReplay) — the - // exception was designed for fresh in-session clicks only; letting it - // fire for a replay publishes the stale edit over a newer peer head - // retained while offline (Carl P1); (3) publishBaseline is still {0,""}. - // - // (For a successful bootstrap, releaseDeferred sets publishBaseline to the - // fetched head before the timer fires; this branch is not reached.) + // Failed-bootstrap exception (P2a): first relay head seen after a + // failed bootstrap is the true first established baseline — publish + // above it. Guards: no external head observed post-failure; not a + // restored replay; publishBaseline still {0,""}. if ( this.bootstrapFailed && !this.bootstrapFailedExternalHeadObserved && @@ -677,6 +640,21 @@ export class WholeBlobSyncManager { }); return { kind: "publish", store, fetchedRemote: remote }; } + // Restored-replay adopt-guard (C1): failed bootstrap + restored outbox. + // Publish only when the restored edit is genuinely newer than the head + // (remote.createdAt <= restoredQueuedAt); adopt when head is strictly newer. + if ( + this.pendingIsRestoredReplay && + this.pendingRestoredQueuedAt !== undefined + ) { + if (remote.createdAt <= this.pendingRestoredQueuedAt) { + this.publishBaseline = canonicalMax(this.publishBaseline, { + createdAt: remote.createdAt, + eventId: remote.eventId, + }); + return { kind: "publish", store, fetchedRemote: remote }; + } + } return { kind: "adopt", remote }; } return { kind: "publish", store, fetchedRemote: remote }; @@ -710,16 +688,26 @@ export class WholeBlobSyncManager { if (events.length === 0 || events[0].pubkey !== this.pubkey) return { kind: "retain" }; const event = events[0]; - this.recordRemoteHead(event.created_at, event.id); - if (event.id === ourEventId) return { kind: "confirmed" }; + // Own-ID confirmation: the event we signed is still the relay head. + // Record immediately — no decrypt needed (we know the store content). + if (event.id === ourEventId) { + this.recordRemoteHead(event.created_at, event.id); + return { kind: "confirmed" }; + } // A different event is the head. If it is one of OUR OWN accepted prior // attempts (a lost-ACK write the relay retained, or a same-second lower-id // attempt that won over this one), it is not a competing remote — retain // so the retry's pre-publish folds it forward and publishes above it, // rather than adopting our own older blob and dropping the current edit. if (this.ambiguousAttemptIds.has(event.id)) return { kind: "retain" }; + // Foreign winner: decrypt before recording the head tuple. A concurrent + // click between this fetch and decryptAndParse must not freeze its + // publishBaseline against a head whose store has not yet been applied + // (Carl P2b / CRITICAL 3 — mirrors the fetchRemoteBlob fix). The scalar + // watermark is advanced only after decrypt succeeds (via recordRemoteHead). const remote = await this.decryptAndParse(event); if (!remote) return { kind: "retain" }; + this.recordRemoteHead(event.created_at, event.id); return { kind: "adopt", remote }; } catch { return { kind: "retain" }; @@ -978,6 +966,7 @@ export class WholeBlobSyncManager { // already see bootstrapResolved=true and schedule the debounce normally. // releaseDeferred() covers edits queued via other paths during the fetch, // using the bootstrap-result snapshot so no post-click live head races in. + this.bootstrapResultHead = bootstrapResultHead; this.bootstrapResolved = true; this.releaseDeferred(bootstrapResultHead); return result;