fix(sidebar-sync): close three P2a queue-until-bootstrap state-machine defects

Thufir's focused pass (890de2d90) identified three executable loss/overwrite
paths and two test-gate gaps. This commit addresses all five findings.

Production fixes:

1. Absent-bootstrap seed must not clobber a pending edit.
   When bootstrap resolves absent and local state is non-empty, runBootstrap
   calls publishFn(localStore) to seed the relay. If a click was already
   pending (pendingStore !== null), that click IS the seed intent; calling
   publish(localStore) again bumps the generation and overwrites the outbox
   with the stale mount snapshot. Fix: guard the publishFn lambda so
   publish(localStore) is a no-op when a pending edit already exists.

2. releaseDeferred must use the bootstrap-result head, not mutable lastRemoteHead.
   subscribeLive can advance lastRemoteHead with a live peer head while bootstrap
   is in-flight. A live head arriving after the click but before bootstrap
   resolves must remain a genuine remote advance — if releaseDeferred re-freezes
   publishBaseline from lastRemoteHead, the live head folds into the baseline,
   the pre-publish check sees equality, and the edit publishes over the peer.
   Fix: derive bootstrapResultHead from fetchResult directly (not lastRemoteHead),
   and pass it into releaseDeferred. fetchResult carries exactly the head the
   bootstrap operation itself fetched; subscribeLive's passive observations
   are invisible to it.

3. Failed-bootstrap exception must be disarmed once any head is independently
   observed (live or reconnect/periodic fetch).
   bootstrapFailed+empty-baseline correctly identifies the "first unknown base"
   case, but after a transient pre-publish fetch failure and a subsequent live
   peer head arrival, the exception re-fires on retry and treats a genuine peer
   advance as a base-establishment, publishing over it. Fix: add
   bootstrapFailedExternalHeadObserved flag; set it from subscribeLive decode
   completion and from fetchRemoteBlob when it finds a head while
   bootstrapFailed is true. The exception checks !bootstrapFailedExternalHeadObserved.

Test-gate fixes:

- P2a cases moved to a new wholeBlobSyncP2a.shared.test.mjs (449 lines) and
  exported as runWholeBlobP2aSuite; called from both useChannelSections.test.mjs
  and useChannelSortPreference.test.mjs for two-lane coverage. Removes the P2a
  block from wholeBlobSync.shared.test.mjs (841 lines, down from 1080).

- Five causal regressions, each named by its exact production mutation:
  T1: drop bootstrapStarted guard → baseline {0,""} at debounce → adopt fires.
  T2: releaseDeferred uses mutable lastRemoteHead → live head folds into baseline
      → pre-publish sees equality → publish over peer (load-bearing for defect 2).
  T3: drop pendingStore===null guard → seed clobbers edit → stale mount published.
  T4: drop !bootstrapFailedExternalHeadObserved → exception fires after live
      observation → publish over genuine peer advance.
  T5 (Kalvin's original): remove bootstrapFailed exception block → hard-adopt
      against {0,""} → click lost (load-bearing for defect 3).

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 16:55:18 -04:00
parent 890de2d90b
commit 28707c115b
5 changed files with 588 additions and 269 deletions

View File

@@ -4,6 +4,7 @@ import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
import { makeHookStubs } from "./sidebarSyncTestHelpers.mjs";
import { runWholeBlobHookSuite } from "./wholeBlobHook.shared.test.mjs";
import { runWholeBlobP2aSuite } from "./wholeBlobSyncP2a.shared.test.mjs";
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -13,6 +14,7 @@ const {
readChannelSectionsOutbox,
} = await import("./channelSectionsStorage.ts");
const { useChannelSections } = await import("./useChannelSections.ts");
const { ChannelSectionSyncManager } = await import("./channelSectionsSync.ts");
const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl");
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
@@ -197,3 +199,30 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
restoreTauri();
}
});
runWholeBlobP2aSuite({
label: "sections",
Manager: ChannelSectionSyncManager,
publishEdit: (m, store) => m.publishSections(store),
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: {},
}),
makeMountStore: () => ({
version: 1,
sections: [{ id: "mount", name: "Mount", order: 0 }],
assignments: {},
}),
makeRemoteStore: () => ({
version: 1,
sections: [{ id: "remote", name: "Remote", order: 0 }],
assignments: {},
}),
});

View File

@@ -4,12 +4,14 @@ import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
import { makeHookStubs } from "./sidebarSyncTestHelpers.mjs";
import { runWholeBlobHookSuite } from "./wholeBlobHook.shared.test.mjs";
import { runWholeBlobP2aSuite } from "./wholeBlobSyncP2a.shared.test.mjs";
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
const { useChannelSortPreference } = await import(
"./useChannelSortPreference.ts"
);
const { ChannelSortSyncManager } = await import("./channelSortSync.ts");
const { readChannelSortOutbox, storageKey } = await import(
"./channelSortPreference.ts"
);
@@ -128,3 +130,14 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
restoreTauri();
}
});
runWholeBlobP2aSuite({
label: "sort",
Manager: ChannelSortSyncManager,
publishEdit: (m, store) => m.publishSortPrefs(store),
subscribe: (m, cb) => m.subscribeToSortPrefs(cb),
makeNonEmptyStore: () => ({ version: 1, groups: { s1: "recent" } }),
makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }),
makeMountStore: () => ({ version: 1, groups: { mount: "recent" } }),
makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }),
});

View File

@@ -839,242 +839,3 @@ test("P2b decrypt gap: watermark advances synchronously; lastRemoteHead advances
mock.reset();
}
});
// ── P2a queue-until-bootstrap regressions ────────────────────────────────────
//
// Three causal regressions covering the queue-until-bootstrap contract.
//
// Shared mutation target A (tests 1 and 2): removing the `bootstrapStarted`
// guard from `publish()` lets the debounce timer fire while bootstrap is still
// in-flight. `publishBaseline` is `{0,""}` at that point, so
// `remoteAdvancedSince(head, {0,""})` = true and `fetchOwnBlobBeforePublish`
// adopts the edit away — exactly the Kalvin P2a click-loss.
//
// Shared mutation target B (test 3): removing the `bootstrapFailed` exception
// from `fetchOwnBlobBeforePublish` causes a hard-adopt against `{0,""}` when
// bootstrap failed, dropping the queued edit even though no baseline was ever
// established.
// Test 1 (load-bearing): fresh device (watermark=0), relay HAS a pre-existing
// head, user clicks during unresolved bootstrap → bootstrap succeeds → edit
// publishes above the relay head, never adopted away.
// Failing mutation: remove `this.bootstrapStarted &&` from the publish() guard
// (i.e. revert to unconditional timer scheduling) → debounce fires against
// {0,""} baseline → remoteAdvancedSince returns true → adopt fires instead.
test("P2a queue-until-bootstrap: click during unresolved bootstrap on fresh device publishes above the relay head, never adopted", async () => {
let releaseBootstrap = null;
let publishCalls = 0;
const { fireDelay, restore } = makeTimerBed();
const remoteStore = makeStore([{ id: "remote", name: "Remote", order: 0 }]);
const tauri = installEchoTauri("pk-p2a-1");
// storedHead tracks what the relay retains. Initially the pre-existing head;
// updated to our published event once we publish successfully.
let storedHead = [tauri.mintHead(remoteStore, 200, "evt-relay-head")];
// Fetch call 1 (bootstrap): blocks until released, returns the pre-existing
// relay head. Subsequent calls (pre-publish fetch, confirmation) return
// storedHead — which after our publish will be our own event.
let fetchCalls = 0;
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
if (fetchCalls === 1)
return new Promise((res) => {
releaseBootstrap = () => res(storedHead);
});
return Promise.resolve(storedHead);
});
mock.method(relayClient, "publishEvent", (e) => {
publishCalls++;
storedHead = [e]; // relay retains our event
return Promise.resolve();
});
try {
const manager = new SyntheticWholeBlobManager("pk-p2a-1", RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
// Start bootstrap (blocked on fetch call 1).
const bootstrapPromise = manager.bootstrap(nonEmptyStore());
// User clicks during unresolved bootstrap. With the fix: no timer fires yet.
// Without the fix (mutation): timer fires immediately against {0,""} baseline.
manager.publishSections(
makeStore([{ id: "click", name: "Click", order: 0 }]),
);
// Release bootstrap → head is recorded (200,"evt-relay-head"), bootstrapResolved=true,
// releaseDeferred() re-freezes publishBaseline to {200,"evt-relay-head"} and
// schedules the 2s debounce.
while (releaseBootstrap === null) await Promise.resolve();
releaseBootstrap();
await bootstrapPromise;
for (let i = 0; i < 50; i++) await Promise.resolve();
// Fire the debounce released by bootstrap.
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
// The edit must have published. publishBaseline was {200,"evt-relay-head"};
// pre-publish fetch returns the same head → remoteAdvancedSince = false → publish.
// After our publish, storedHead = our event; confirmation fetch confirms us.
// Without the fix: baseline was {0,""} → remoteAdvancedSince = true → adopt.
assert.equal(adopted.length, 0, "click must not be adopted away");
assert.ok(publishCalls >= 1, "click must publish above the relay head");
assert.equal(
manager.getPendingStore(),
null,
"pending cleared after publish",
);
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// Test 2: click during unresolved bootstrap, bootstrap succeeds, then a peer
// posts a genuinely newer head BEFORE the debounce fires → edit adopts (normal LWW).
// Failing mutation: same as test 1 — without the guard, the debounce fires during
// bootstrap with baseline {0,""}. The pre-publish fetch returns a {0,""}-relative
// advance but for the wrong reason (before the bootstrap head was established).
// With the fix, publishBaseline = {200,"evt-boot"} and the peer head at ts=400
// is a genuine advance → adopt fires correctly.
test("P2a queue-until-bootstrap: genuinely newer peer head that arrives after bootstrap adopt via normal LWW", async () => {
let releaseBootstrap = null;
let publishCalls = 0;
const { fireDelay, restore } = makeTimerBed();
const bootstrapStore = makeStore([{ id: "boot", name: "Boot", order: 0 }]);
const peerStore = makeStore([{ id: "peer", name: "Peer", order: 0 }]);
const tauri = installEchoTauri("pk-p2a-2");
const bootHead = tauri.mintHead(bootstrapStore, 200, "evt-boot");
const peerHead = tauri.mintHead(peerStore, 400, "evt-peer");
// Bootstrap returns the boot head. Pre-publish fetch returns the peer-newer
// head (simulating a peer publishing between bootstrap and doPublish).
let fetchCalls = 0;
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
if (fetchCalls === 1)
return new Promise((res) => {
releaseBootstrap = () => res([bootHead]);
});
return Promise.resolve([peerHead]);
});
mock.method(relayClient, "publishEvent", () => {
publishCalls++;
return Promise.resolve();
});
try {
const manager = new SyntheticWholeBlobManager("pk-p2a-2", RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
const bootstrapPromise = manager.bootstrap(nonEmptyStore());
// Click during unresolved bootstrap.
manager.publishSections(
makeStore([{ id: "click-2", name: "Click", order: 0 }]),
);
while (releaseBootstrap === null) await Promise.resolve();
releaseBootstrap();
await bootstrapPromise;
// After bootstrap: publishBaseline = {200,"evt-boot"} (re-frozen by releaseDeferred).
for (let i = 0; i < 50; i++) await Promise.resolve();
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
// Pre-publish fetch returns {400,"evt-peer"}: remoteAdvancedSince({400,...},{200,...})=true
// → adopt fires correctly. Normal LWW: peer truly posted after bootstrap baseline.
assert.equal(
publishCalls,
0,
"edit must not publish over the newer peer head",
);
assert.equal(
adopted.length,
1,
"newer peer head must be adopted (normal LWW)",
);
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// Test 3 (Kalvin's original P2a sequence): bootstrap FAILS, user clicks,
// pre-publish fetch finds a pre-existing head → edit publishes above it.
// Failing mutation: remove the `this.bootstrapFailed &&` check from
// fetchOwnBlobBeforePublish — the hard-adopt against {0,""} baseline fires.
test("P2a failed bootstrap: click after failed bootstrap publishes above the relay head, never adopts against empty baseline", async () => {
let publishCalls = 0;
const { fireDelay, restore } = makeTimerBed();
const relayStore = makeStore([{ id: "relay", name: "Relay", order: 0 }]);
const tauri = installEchoTauri("pk-p2a-3");
// storedHead starts as the pre-existing relay head; updated to our event on publish.
let storedHead = [tauri.mintHead(relayStore, 100, "evt-existing")];
// Bootstrap fetch: call 1 rejects. Subsequent calls: return storedHead.
let fetchCalls = 0;
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
if (fetchCalls === 1) return Promise.reject(new Error("relay timeout"));
return Promise.resolve(storedHead);
});
mock.method(relayClient, "publishEvent", (e) => {
publishCalls++;
storedHead = [e]; // relay retains our event
return Promise.resolve();
});
try {
const manager = new SyntheticWholeBlobManager("pk-p2a-3", RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
// Bootstrap fails: bootstrapFailed=true, bootstrapResolved=true.
// No releaseDeferred edit to schedule (nothing was queued during bootstrap).
await manager.bootstrap(nonEmptyStore());
// User clicks after failed bootstrap — publish() sees bootstrapResolved=true
// so it schedules immediately with publishBaseline={0,""} (lastRemoteHead
// was never advanced because bootstrap failed).
manager.publishSections(
makeStore([{ id: "click-3", name: "Click", order: 0 }]),
);
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
// Pre-publish fetch returns {100,"evt-existing"}: remoteAdvancedSince(true).
// bootstrapFailed+empty-baseline exception: fold the head in and publish
// above it instead of adopting. After publish, storedHead = our event;
// confirmation confirms us.
// Without the fix: hard-adopt fires, publishCalls=0, adopted.length=1.
assert.equal(
adopted.length,
0,
"click must not be adopted against empty baseline",
);
assert.ok(publishCalls >= 1, "click must publish above the relay head");
assert.equal(
manager.getPendingStore(),
null,
"pending cleared after publish",
);
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});

View File

@@ -214,9 +214,16 @@ export class WholeBlobSyncManager<S> {
// fetchOwnBlobBeforePublish so an edit whose baseline is {0,""} because
// bootstrap failed still publishes ABOVE the first head it discovers rather
// than adopting it away. (For a successful bootstrap, releaseDeferred re-freezes
// publishBaseline to lastRemoteHead, so the baseline is not {0,""} at doPublish
// and this flag is never consulted.)
// publishBaseline to the bootstrap-result head, so the baseline is not {0,""}
// at doPublish and this flag is never consulted.)
private bootstrapFailed = false;
// Disarmed once any head is independently observed after a failed bootstrap
// (live relay subscription or a reconnect/periodic fetch). When disarmed, the
// failed-bootstrap exception in fetchOwnBlobBeforePublish is suppressed: the
// independently-observed head is already a genuine remote advance that the
// normal remoteAdvancedSince check correctly adopts — the exception must not
// override that by treating the head as a "first unknown base".
private bootstrapFailedExternalHeadObserved = false;
// 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
@@ -267,6 +274,13 @@ export class WholeBlobSyncManager<S> {
// decrypt it, so seed-publish is blocked even when the payload is
// unreadable (e.g. wrong key).
this.recordRemoteHead(event.created_at, event.id);
// A head was independently observed via a non-bootstrap fetch (reconnect,
// periodic refresh, etc.). Disarm the failed-bootstrap exception so this
// head is treated as a genuine remote advance rather than a "first unknown
// base" — the normal remoteAdvancedSince check handles it from here.
if (this.bootstrapFailed) {
this.bootstrapFailedExternalHeadObserved = true;
}
const result = await this.decryptAndParse(event);
if (!result) {
return { status: "failed", createdAt: event.created_at };
@@ -354,21 +368,32 @@ export class WholeBlobSyncManager<S> {
/**
* Release any edit that was deferred until bootstrap resolved. Called by
* bootstrap() after it sets bootstrapResolved=true. If a pending edit exists
* with no timer or in-flight cycle, re-freeze publishBaseline from the
* now-established lastRemoteHead and schedule the normal 2s debounce.
* 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). If a pending edit exists with no timer or in-flight cycle,
* re-freeze publishBaseline to canonicalMax(queueTimeBaseline, bootstrapResultHead)
* and schedule the normal 2s debounce.
*
* Re-freezing the baseline here (rather than leaving it at the {0,""} it had
* when publish() was called during unresolved bootstrap) is the P2a fix for
* the success path: the edit's comparison point becomes the head that
* bootstrap just established, so the pre-publish fetch for the same head
* returns "not advanced" and the edit publishes above it correctly. A peer
* head that arrives BETWEEN bootstrap resolution and doPublish is correctly
* seen as a genuine advance (its timestamp exceeds the just-frozen baseline)
* and is adopted per normal LWW. For a failed bootstrap, lastRemoteHead stays
* {0,""} and the bootstrapFailed flag enables the doPublish-time exception.
* Re-freezing the baseline here is the P2a fix for the success path: the
* edit's comparison point becomes the head bootstrap established, so the
* pre-publish fetch for the same head returns "not advanced" and the edit
* publishes above it correctly.
*
* Using the bootstrap-result snapshot (not mutable lastRemoteHead) is critical:
* subscribeLive can advance lastRemoteHead while bootstrap is unresolved. A
* live peer head arriving AFTER the click but BEFORE bootstrap resolves is
* never incorporated into the editable store while a local edit is pending;
* using mutable lastRemoteHead here would fold that live head into the baseline,
* making the pre-publish check see equality and publish the pre-head blob OVER
* the peer's write. With the bootstrap-result snapshot, that live peer head
* remains a genuine advance (its timestamp exceeds the just-frozen baseline)
* and is adopted per normal LWW when the pre-publish fetch returns it.
*
* For a failed bootstrap, bootstrapResultHead is {0,""} and the bootstrapFailed
* flag enables the doPublish-time exception.
*/
private releaseDeferred(): void {
private releaseDeferred(bootstrapResultHead: PublishBaseline): void {
if (
!this.destroyed &&
this.pendingStore !== null &&
@@ -376,11 +401,12 @@ export class WholeBlobSyncManager<S> {
this.retryTimer === null &&
!this.publishInFlight
) {
// Re-freeze the baseline to the head now known from bootstrap. For a
// successful bootstrap this is the fetched head; for a failed bootstrap
// lastRemoteHead is still {0,""} (the bootstrapFailed exception handles
// that case separately in fetchOwnBlobBeforePublish).
this.publishBaseline = { ...this.lastRemoteHead };
// Re-freeze the baseline to the head established by bootstrap. For a
// successful bootstrap this is the exact head returned by the fetch
// (snapshotted before runBootstrap ran, so subscribeLive cannot race it);
// for a failed bootstrap bootstrapResultHead is {0,""} (the bootstrapFailed
// exception handles that case in fetchOwnBlobBeforePublish).
this.publishBaseline = { ...bootstrapResultHead };
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
this.startCycle();
@@ -606,15 +632,24 @@ export class WholeBlobSyncManager<S> {
// Failed-bootstrap exception (P2a): the edit was queued while bootstrap
// was in-flight and bootstrap subsequently FAILED — no head was ever
// fetched, so publishBaseline and lastRemoteHead were never advanced.
// releaseDeferred() re-freezes publishBaseline from lastRemoteHead, but
// when bootstrap failed that is still {0,""}. Any head the relay returns
// here is the TRUE first established baseline; publish above it rather
// than adopting it away. (When bootstrap SUCCEEDS, releaseDeferred sets
// publishBaseline to the fetched head before scheduling the timer, so
// this branch is not reached for successful bootstraps whose head was
// already known when the debounce fired.)
// releaseDeferred() re-freezes publishBaseline from the bootstrap-result
// head, but when bootstrap failed that is still {0,""}. The FIRST head
// the relay returns at pre-publish time is the TRUE first established
// baseline; publish above it rather than adopting it away.
//
// Guard: this exception fires only when no external head was independently
// observed after the edit was queued (subscribeLive or a reconnect/periodic
// fetch set bootstrapFailedExternalHeadObserved). An independently-observed
// head is a genuine post-click remote advance — the normal
// remoteAdvancedSince check above correctly adopts it.
//
// (For a successful bootstrap, releaseDeferred sets publishBaseline to the
// fetched head before scheduling the timer, so this branch is not reached
// for successful bootstraps whose head was already known when the debounce
// fired.)
if (
this.bootstrapFailed &&
!this.bootstrapFailedExternalHeadObserved &&
this.publishBaseline.createdAt === 0 &&
this.publishBaseline.eventId === ""
) {
@@ -872,6 +907,16 @@ export class WholeBlobSyncManager<S> {
void this.decryptAndParse(event).then((result) => {
if (result) {
this.recordRemoteHead(result.createdAt, result.eventId);
// A live head was independently observed (not via a pre-publish
// fetch). If bootstrap previously failed and a pending edit's
// failed-bootstrap exception could still fire, disarm it: this live
// head is a genuine post-click remote advance — the normal
// remoteAdvancedSince check in fetchOwnBlobBeforePublish handles it
// correctly (adopts the advancing head rather than treating it as
// a "first unknown base" to publish above).
if (this.bootstrapFailed) {
this.bootstrapFailedExternalHeadObserved = true;
}
onUpdate(result);
}
});
@@ -899,19 +944,41 @@ export class WholeBlobSyncManager<S> {
if (fetchResult.status === "failed") {
this.bootstrapFailed = true;
}
// Snapshot the bootstrap result head as the baseline for releaseDeferred.
// We derive this from fetchResult directly — NOT from lastRemoteHead —
// because subscribeLive may have updated lastRemoteHead with a live peer
// head that arrived during the bootstrap fetch. That live head must remain
// a genuine remote advance after bootstrap resolves; folding it into the
// baseline would make the pre-publish check see equality and publish over
// it. fetchResult carries exactly the head bootstrap itself fetched (or
// nothing, if the relay was absent or the fetch failed).
const bootstrapResultHead: PublishBaseline =
fetchResult.status === "found"
? { createdAt: fetchResult.createdAt, eventId: fetchResult.eventId }
: { createdAt: 0, eventId: "" };
const result = runBootstrap({
fetchResult,
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: this.config.isLocalNonEmpty,
publishFn: (s) => this.publish(s),
// Absent-bootstrap seed-publish guard (Defect 1 / Thufir pass-4 finding 1):
// when the relay confirms absent and local state is non-empty, runBootstrap
// calls publishFn(localStore) to seed the user's local blob to the relay.
// But if a pending edit already exists (the user clicked during the async
// fetch), that edit IS the seed intent — do NOT call publish(localStore),
// which would bump the generation and overwrite the pending edit's outbox
// entry with the stale mount snapshot.
publishFn: (s) => {
if (this.pendingStore === null) this.publish(s);
},
});
// Mark bootstrap resolved BEFORE returning. The hook's .then() runs
// synchronously on the resolved promise, so any publish() it calls will
// already see bootstrapResolved=true and schedule the debounce normally.
// releaseDeferred() covers edits queued via other paths during the fetch.
// releaseDeferred() covers edits queued via other paths during the fetch,
// using the bootstrap-result snapshot so no post-click live head races in.
this.bootstrapResolved = true;
this.releaseDeferred();
this.releaseDeferred(bootstrapResultHead);
return result;
}

View File

@@ -0,0 +1,449 @@
// Shared P2a queue-until-bootstrap regression suite for WholeBlobSyncManager.
//
// Exports runWholeBlobP2aSuite({ label, Manager, publishEdit, makeNonEmptyStore,
// makeEditStore, makeRemoteStore }) and is exercised by both lane test files:
// useChannelSections.test.mjs — sections lane (ChannelSectionSyncManager)
// useChannelSortPreference.test.mjs — sort lane (ChannelSortSyncManager)
//
// Five causal regressions:
//
// T1 (load-bearing): fresh device, relay HAS a head, click during unresolved
// bootstrap → edit publishes above relay head.
// Mutation: drop `bootstrapStarted &&` guard in publish() → debounce fires
// against {0,""} baseline → remoteAdvancedSince returns true → adopt.
//
// T2: blocked bootstrap → click → live peer head B arrives → bootstrap
// resolves → B is adopted (normal LWW, not published over).
// Mutation: releaseDeferred uses mutable lastRemoteHead instead of snapshot
// → publishBaseline folds B in → pre-publish sees equality → publish over B.
//
// T3: non-empty mount, click during blocked bootstrap, relay confirms absent
// → the edit (not the mount snapshot) is the published payload.
// Mutation: drop `if (this.pendingStore === null)` guard in bootstrap's
// publishFn → seed calls publish(localStore), bumps generation, overwrites
// outbox with stale mount snapshot → edit lost.
//
// T4: failed bootstrap → click → first pre-publish fetch throws → live peer
// head arrives via subscribeToX → retry must ADOPT, never publish over it.
// Mutation: drop `!this.bootstrapFailedExternalHeadObserved` from the
// failed-bootstrap exception → exception fires, publishes over the peer head.
//
// T5 (Kalvin's original sequence): failed bootstrap → click → no live head →
// pre-publish fetch finds a relay head → edit publishes above it.
// Mutation: remove the entire bootstrapFailed exception block from
// fetchOwnBlobBeforePublish → hard-adopt against {0,""} fires, click lost.
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import {
makeHookTimerBed,
installEchoTauri,
} from "./sidebarSyncTestHelpers.mjs";
const RELAY = "wss://r.p2a";
/**
* Register P2a queue-until-bootstrap causal regressions for a single lane.
*
* @param {object} opts
* @param {string} opts.label — Lane label used in test names ("sections"|"sort")
* @param {Function} opts.Manager — Concrete manager class (ChannelSectionSyncManager|ChannelSortSyncManager)
* @param {Function} opts.publishEdit — (manager, store) => void — calls the lane publish method
* @param {Function} opts.subscribe — (manager, cb) => Promise<unsubscribe> — calls subscribeToX
* @param {Function} opts.makeNonEmptyStore — () => non-empty store (for mount / localStore arg)
* @param {Function} opts.makeEditStore — () => store representing the user's click
* @param {Function} opts.makeMountStore — () => stale mount snapshot (distinct from edit)
* @param {Function} opts.makeRemoteStore — () => remote head store (for relay mock)
*/
export function runWholeBlobP2aSuite({
label,
Manager,
publishEdit,
subscribe,
makeNonEmptyStore,
makeEditStore,
makeMountStore,
makeRemoteStore,
}) {
// T1: load-bearing fresh-device case.
// Mutation: revert publish() guard to unconditional timer scheduling.
// Expected: edit publishes above relay head (publishCalls >= 1, adopted = 0).
test(`P2a ${label}: click during unresolved bootstrap on fresh device publishes above the relay head`, async () => {
let releaseBootstrap = null;
let publishCalls = 0;
const { fireDelay, restore } = makeHookTimerBed();
const tauri = installEchoTauri(`pk-p2a-t1-${label}`);
let storedHead = [
tauri.mintHead(makeRemoteStore(), 200, `evt-relay-${label}`),
];
let fetchCalls = 0;
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
if (fetchCalls === 1)
return new Promise((res) => {
releaseBootstrap = () => res(storedHead);
});
return Promise.resolve(storedHead);
});
mock.method(relayClient, "publishEvent", (e) => {
publishCalls++;
storedHead = [e];
return Promise.resolve();
});
try {
const manager = new Manager(`pk-p2a-t1-${label}`, RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
// Bootstrap blocked; click during unresolved bootstrap.
const bootstrapPromise = manager.bootstrap(makeNonEmptyStore());
publishEdit(manager, makeEditStore());
// Release bootstrap → bootstrapResolved=true, releaseDeferred snaps
// publishBaseline to {200,"evt-relay-<label>"}, schedules debounce.
while (releaseBootstrap === null) await Promise.resolve();
releaseBootstrap();
await bootstrapPromise;
for (let i = 0; i < 50; i++) await Promise.resolve();
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
// Pre-publish fetch returns the relay head → baseline matches → publish.
// Mutation: baseline was {0,""} → remoteAdvancedSince = true → adopt.
assert.equal(adopted.length, 0, "edit must not be adopted away");
assert.ok(publishCalls >= 1, "edit must publish above the relay head");
assert.equal(
manager.getPendingStore(),
null,
"pending cleared after publish",
);
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// T2: blocked bootstrap → click → live peer head B → bootstrap resolves → adopt B.
// Mutation: releaseDeferred uses mutable lastRemoteHead (live B folds in) →
// pre-publish sees equality → publish over B instead of adopting.
test(`P2a ${label}: post-click live peer head arriving before bootstrap resolves is adopted after bootstrap, not published over`, async () => {
let releaseBootstrap = null;
let publishCalls = 0;
let liveCallback = null;
const { fireDelay, restore } = makeHookTimerBed();
const tauri = installEchoTauri(`pk-p2a-t2-${label}`);
const bootHead = tauri.mintHead(
makeRemoteStore(),
200,
`evt-boot-${label}`,
);
const peerHead = tauri.mintHead(
makeRemoteStore(),
400,
`evt-peer-${label}`,
);
// subscribeLive: capture the event callback so we can deliver peerHead.
mock.method(relayClient, "subscribeLive", (_f, cb) => {
liveCallback = cb;
return Promise.resolve(async () => {});
});
let fetchCalls = 0;
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
// Call 1 = bootstrap (blocked), returns bootHead.
// Subsequent calls (pre-publish fetch) return peerHead, simulating a
// genuine peer publish that arrived AFTER the click.
if (fetchCalls === 1)
return new Promise((res) => {
releaseBootstrap = () => res([bootHead]);
});
return Promise.resolve([peerHead]);
});
mock.method(relayClient, "publishEvent", () => {
publishCalls++;
return Promise.resolve();
});
try {
const manager = new Manager(`pk-p2a-t2-${label}`, RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
// Subscribe so subscribeLive callback is captured.
await subscribe(manager, () => {});
// Bootstrap blocked; click during unresolved bootstrap.
const bootstrapPromise = manager.bootstrap(makeNonEmptyStore());
publishEdit(manager, makeEditStore());
// Deliver live peer head B BEFORE bootstrap resolves.
// With the fix: releaseDeferred will use bootstrapResultHead snapshot
// ({200,"evt-boot-<label>"}), not the mutable lastRemoteHead (which is
// now {400,"evt-peer-<label>"}). So publishBaseline = {200,...} and B
// remains a genuine advance at pre-publish time.
while (liveCallback === null) await Promise.resolve();
liveCallback(peerHead);
// Give the async decrypt a tick to complete.
for (let i = 0; i < 20; i++) await Promise.resolve();
// Release bootstrap.
while (releaseBootstrap === null) await Promise.resolve();
releaseBootstrap();
await bootstrapPromise;
for (let i = 0; i < 50; i++) await Promise.resolve();
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
// Pre-publish returns {400,"evt-peer"}: remoteAdvancedSince({400},{200}) = true
// → adopt fires. Mutation (mutable lastRemoteHead): publishBaseline = {400,...},
// pre-publish sees equality → publish over B.
assert.equal(
publishCalls,
0,
"edit must not publish over the live peer head",
);
assert.equal(
adopted.length,
1,
"live peer head B must be adopted (normal LWW)",
);
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// T3: non-empty mount, click during blocked bootstrap, relay confirms absent
// → the edit (not the mount snapshot) is the published payload.
// Mutation: drop `if (this.pendingStore === null)` guard in bootstrap's
// publishFn → seed calls publish(localStore), bumps generation, overwrites
// outbox with stale mount snapshot → stale mount is published, click lost.
test(`P2a ${label}: relay-absent bootstrap does not clobber an already-pending edit with the stale mount snapshot`, async () => {
let releaseBootstrap = null;
let publishCalls = 0;
const publishedPayloads = [];
const { fireDelay, restore } = makeHookTimerBed();
const tauri = installEchoTauri(`pk-p2a-t3-${label}`);
let fetchCalls = 0;
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
// Call 1 = bootstrap (blocked), resolves absent.
// Subsequent calls (pre-publish fetch, confirmation) return storedHead
// so confirmation can confirm our event.
if (fetchCalls === 1)
return new Promise((res) => {
releaseBootstrap = () => res([]);
});
return Promise.resolve(storedHead);
});
let storedHead = [];
mock.method(relayClient, "publishEvent", (e) => {
publishCalls++;
publishedPayloads.push(e.content);
storedHead = [e]; // relay retains our event
return Promise.resolve();
});
try {
const manager = new Manager(`pk-p2a-t3-${label}`, RELAY);
manager.setOnRemoteAdopted(() => {});
// Mount store is the stale mount snapshot.
const mountStore = makeMountStore();
// Edit store is the click that happened DURING bootstrap.
const editStore = makeEditStore();
// Bootstrap blocked; user clicks BEFORE bootstrap resolves.
const bootstrapPromise = manager.bootstrap(mountStore);
publishEdit(manager, editStore);
// Release bootstrap → relay absent, lastHead=0 → runBootstrap would
// call publishFn(mountStore). With the fix: pendingStore !== null, so
// the guard skips the seed call; the pending edit survives.
while (releaseBootstrap === null) await Promise.resolve();
releaseBootstrap();
await bootstrapPromise;
for (let i = 0; i < 50; i++) await Promise.resolve();
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
// The edit must have published. We verify the pending store was cleared
// (not adopted away) and the publish call happened.
// Mutation: two publishes fire (one from editStore, one seed from mountStore
// — in practice the seed clobbers the outbox and only mountStore is published).
assert.ok(publishCalls >= 1, "the pending edit must publish");
assert.equal(
manager.getPendingStore(),
null,
"pending cleared after the edit publishes",
);
// The published plaintext must encode the clicked edit, not the stale mount snapshot.
// With the mutation, bootstrap's seed-publish clobbers the edit in pendingStore
// and the stale mountStore is published instead.
const published = tauri.capturedPlaintext();
assert.ok(published !== null, "a plaintext was captured");
const parsed = JSON.parse(published);
assert.ok(
!JSON.stringify(parsed).includes('"mount"'),
"published payload must not be the stale mount snapshot",
);
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// T4: failed bootstrap → click → first pre-publish fetch THROWS → live peer
// head arrives via subscribeToX → retry must adopt, never publish over it.
// Mutation: drop `!this.bootstrapFailedExternalHeadObserved` from the
// failed-bootstrap exception in fetchOwnBlobBeforePublish → exception fires
// even though an external head was observed → publishes over peer.
test(`P2a ${label}: live peer head observed after failed bootstrap disarms exception; retry adopts the peer head`, async () => {
let publishCalls = 0;
let liveCallback = null;
const { fireDelay, restore } = makeHookTimerBed();
const tauri = installEchoTauri(`pk-p2a-t4-${label}`);
const peerHead = tauri.mintHead(
makeRemoteStore(),
300,
`evt-t4-peer-${label}`,
);
mock.method(relayClient, "subscribeLive", (_f, cb) => {
liveCallback = cb;
return Promise.resolve(async () => {});
});
let fetchCalls = 0;
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
if (fetchCalls === 1) return Promise.reject(new Error("bootstrap fail"));
// Call 2 = first pre-publish fetch: THROW (network error).
if (fetchCalls === 2)
return Promise.reject(new Error("pre-publish fail"));
// Subsequent calls: return peerHead (retry pre-publish fetch).
return Promise.resolve([peerHead]);
});
mock.method(relayClient, "publishEvent", () => {
publishCalls++;
return Promise.resolve();
});
try {
const manager = new Manager(`pk-p2a-t4-${label}`, RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
// Subscribe to capture live callback.
await subscribe(manager, () => {});
// Bootstrap fails: bootstrapFailed=true, bootstrapResolved=true.
await manager.bootstrap(makeNonEmptyStore());
// Click after failed bootstrap: publish() schedules immediately
// (bootstrapResolved=true), publishBaseline={0,""}.
publishEdit(manager, makeEditStore());
// First debounce fires; pre-publish fetch throws → scheduleRetry.
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
// Live peer head arrives AFTER the first pre-publish cycle threw.
// This disarms the failed-bootstrap exception.
while (liveCallback === null) await Promise.resolve();
liveCallback(peerHead);
for (let i = 0; i < 20; i++) await Promise.resolve();
// Retry fires: pre-publish fetch returns peerHead.
// bootstrapFailedExternalHeadObserved=true → exception suppressed.
// remoteAdvancedSince(peerHead, {0,""}) = true → ADOPT.
// Mutation: exception still fires → fold + publish over peerHead.
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
assert.equal(publishCalls, 0, "must not publish over the peer head");
assert.equal(adopted.length, 1, "peer head must be adopted");
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// T5 (Kalvin's original P2a sequence): failed bootstrap → click → no live
// head → pre-publish fetch finds a relay head → edit publishes above it.
// Mutation: remove the entire bootstrapFailed exception block →
// hard-adopt against {0,""} baseline fires, edit lost.
test(`P2a ${label}: failed bootstrap with no external observation publishes the edit above the relay head`, async () => {
let publishCalls = 0;
const { fireDelay, restore } = makeHookTimerBed();
const tauri = installEchoTauri(`pk-p2a-t5-${label}`);
let storedHead = [
tauri.mintHead(makeRemoteStore(), 100, `evt-t5-${label}`),
];
let fetchCalls = 0;
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
if (fetchCalls === 1) return Promise.reject(new Error("bootstrap fail"));
return Promise.resolve(storedHead);
});
mock.method(relayClient, "publishEvent", (e) => {
publishCalls++;
storedHead = [e];
return Promise.resolve();
});
try {
const manager = new Manager(`pk-p2a-t5-${label}`, RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
// Bootstrap fails.
await manager.bootstrap(makeNonEmptyStore());
// Click after failed bootstrap.
publishEdit(manager, makeEditStore());
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
// Pre-publish fetch returns the relay head: bootstrapFailed exception fires
// (no external head observed), folds head in, publishes above it.
// Mutation: exception removed → remoteAdvancedSince(head, {0,""}) = true
// → adopt fires, publishCalls=0, adopted.length=1.
assert.equal(adopted.length, 0, "edit must not be adopted");
assert.ok(publishCalls >= 1, "edit must publish above the relay head");
assert.equal(
manager.getPendingStore(),
null,
"pending cleared after publish",
);
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
}