mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -192,6 +192,7 @@ export function useChannelSections(
|
||||
const durable = managerRef.current?.publishSections(
|
||||
outbox.store,
|
||||
true,
|
||||
outbox.queuedAt,
|
||||
);
|
||||
if (durable && outbox.legacyRawToConsume !== null) {
|
||||
markChannelSectionsLegacyConsumed(
|
||||
|
||||
@@ -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" } }),
|
||||
});
|
||||
|
||||
@@ -189,6 +189,7 @@ export function useChannelSortPreference(
|
||||
const durable = managerRef.current?.publishSortPrefs(
|
||||
outbox.store,
|
||||
true,
|
||||
outbox.queuedAt,
|
||||
);
|
||||
if (durable && outbox.legacyRawToConsume !== null) {
|
||||
markChannelSortLegacyConsumed(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user