test(sidebar-sync): address Thufir pass-1 CRITICAL+IMPORTANT findings

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-09-02 21:15:55 -04:00
parent 31510eabb1
commit b539b74ca2
12 changed files with 822 additions and 327 deletions

View File

@@ -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,
);
}

View File

@@ -60,8 +60,9 @@ export class ChannelSectionSyncManager extends WholeBlobSyncManager<ChannelSecti
publishSections(
store: ChannelSectionStore,
isRestoredReplay = false,
restoredQueuedAt?: number,
): boolean {
return this.publish(store, isRestoredReplay);
return this.publish(store, isRestoredReplay, restoredQueuedAt);
}
/** Fetch the current remote head for this pubkey's sections blob. */

View File

@@ -160,12 +160,14 @@ export function writeChannelSortOutbox(
pubkey: string,
store: ChannelSortStore,
relayUrl: string,
nowSecs?: number,
): boolean {
return writeOwnOutbox(
OUTBOX_KEY_PREFIX,
pubkey,
relayUrl,
boundChannelSortStore(store),
nowSecs,
);
}

View File

@@ -38,8 +38,12 @@ export class ChannelSortSyncManager extends WholeBlobSyncManager<ChannelSortStor
}
/** Publish a sort prefs store, debounced to 2s. Returns whether the intent is durably held. */
publishSortPrefs(store: ChannelSortStore, isRestoredReplay = false): boolean {
return this.publish(store, isRestoredReplay);
publishSortPrefs(
store: ChannelSortStore,
isRestoredReplay = false,
restoredQueuedAt?: number,
): boolean {
return this.publish(store, isRestoredReplay, restoredQueuedAt);
}
/** Fetch the current remote head for this pubkey's sort-prefs blob. */

View File

@@ -250,13 +250,17 @@ export function clearOutbox(
* Reclaim foreign outbox keys the fetched relay head already subsumes: a
* record is redundant when merging it into `head` yields `head` unchanged.
* Never touches this window's own key. Call only after a successful head fetch.
*
* The `isSubsumedBy` probe receives each record's `preservedKey` so a
* capacity-bounded proof can never evict the reserved channel and falsely
* certify retention of a click the relay has not yet kept (Carl P3 / C4).
*/
export function reclaimSubsumedOutbox<S>(
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<S>(
pubkey,
relayUrl,
parse,
(record) => isSubsumedBy(record.store, head),
(record) => isSubsumedBy(record.store, head, record.preservedKey),
);
}

View File

@@ -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();
}
});
}

View File

@@ -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,

View File

@@ -192,6 +192,7 @@ export function useChannelSections(
const durable = managerRef.current?.publishSections(
outbox.store,
true,
outbox.queuedAt,
);
if (durable && outbox.legacyRawToConsume !== null) {
markChannelSectionsLegacyConsumed(

View File

@@ -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" } }),
});

View File

@@ -189,6 +189,7 @@ export function useChannelSortPreference(
const durable = managerRef.current?.publishSortPrefs(
outbox.store,
true,
outbox.queuedAt,
);
if (durable && outbox.legacyRawToConsume !== null) {
markChannelSortLegacyConsumed(

View File

@@ -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<unsubscribe>
* @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();

View File

@@ -124,8 +124,15 @@ export type WholeBlobLaneConfig<S> = {
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<S> = {
/**
* 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<S> {
private pubkey: string;
@@ -228,6 +217,19 @@ export class WholeBlobSyncManager<S> {
// 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<S> {
}
/**
* 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<S> {
}
/**
* 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<S> {
private adoptRemote(remote: RemoteBlob<S>, 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<S> {
}
/**
* 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<S> {
// 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<S> {
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<S> {
});
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<S> {
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<S> {
// 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;