mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
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:
@@ -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: {},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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" } }),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user