mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
fix(nip-rs): apply max() for v2 frontier in hydration; add stale-ancillary witness
Hydration was skipping v2 frontier application when an ordinary ancillary
frontier already existed for the context. After a v2-success/ancillary-failure
commit, the stale ancillary value suppressed the authoritative v2 frontier —
the register appeared active when it should have been inactive (F > B).
Fix: apply effectiveState[ctx] = max(existing ?? 0, e.f) for every v2/migrated
override entry, unconditionally replacing the stale ancillary value with the
authoritative committed frontier when it is higher.
Test 22b: hydrateFromLocalStorage_staleAncillaryFrontier_v2FrontierWins —
Thufir's exact policy-edge witness. Seeds stale ancillary frontier=50 and v2
{s:5,c:0,b:100,f:101}. Asserts effectiveState=101 after hydration, register
inactive (101>B=100), and currentContexts() serializes tombstone (ov_c: only,
no ov_s:).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
dd848a61cc
commit
bb0c3e1729
@@ -2328,6 +2328,74 @@ test("markChannelUnread_v2WriteFails_slotIdsUnchanged", () => {
|
|||||||
mgr.destroy();
|
mgr.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Test 22b: stale ancillary frontier suppressed by authoritative v2 frontier ─
|
||||||
|
test("hydrateFromLocalStorage_staleAncillaryFrontier_v2FrontierWins", () => {
|
||||||
|
// Thufir's exact policy-edge witness: when a v2-success/ancillary-failure
|
||||||
|
// commit leaves the ordinary frontier key stale, hydration must apply
|
||||||
|
// max(existing, e.f) — not skip the update when the key is present.
|
||||||
|
//
|
||||||
|
// Setup: ordinary frontier key = 50 (stale)
|
||||||
|
// v2 override entry = {s:5, c:0, b:100, f:101}
|
||||||
|
// isOverrideActive(S=5,C=0,B=100, frontier=50) = 50<=100 → ACTIVE (wrong)
|
||||||
|
// isOverrideActive(S=5,C=0,B=100, frontier=101) = 101>100 → INACTIVE (correct)
|
||||||
|
// The test asserts the reconstructed manager uses frontier=101 so the
|
||||||
|
// register is inactive and serializes as a tombstone (ov_c: only, no ov_s:).
|
||||||
|
const ls = makeLocalStorage();
|
||||||
|
globalThis.window.localStorage = ls;
|
||||||
|
|
||||||
|
const pubkey = "ec".repeat(32);
|
||||||
|
const ctx = "stale-frontier-ch";
|
||||||
|
|
||||||
|
// Seed the stale ancillary frontier (epoch-seconds → ISO timestamp).
|
||||||
|
const staleFrontierKey = `buzz.channel-read-state.v2:${pubkey}`;
|
||||||
|
ls.setItem(
|
||||||
|
staleFrontierKey,
|
||||||
|
JSON.stringify({ [ctx]: new Date(50 * 1_000).toISOString() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed the v2 override key with the authoritative frontier f=101.
|
||||||
|
const v2Key = `buzz.nip-rs.override-state.v2:${pubkey}`;
|
||||||
|
ls.setItem(v2Key, JSON.stringify({ [ctx]: { s: 5, c: 0, b: 100, f: 101 } }));
|
||||||
|
|
||||||
|
const fakeRelay = {
|
||||||
|
fetchEvents: async () => [],
|
||||||
|
publishEvent: async () => {},
|
||||||
|
subscribeToReconnects: () => () => {},
|
||||||
|
getConnectionGeneration: () => 0,
|
||||||
|
subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }),
|
||||||
|
subscribeLive: async (_f, _h) => () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mgr = new ReadStateManager(pubkey, fakeRelay);
|
||||||
|
mgr.hydrateFromLocalStorage();
|
||||||
|
|
||||||
|
// v2 is authoritative: effectiveState must hold 101, not the stale 50.
|
||||||
|
assert.equal(
|
||||||
|
mgr.effectiveState.get(ctx),
|
||||||
|
101,
|
||||||
|
"hydration must apply max(staleAncillary=50, v2.f=101) → 101",
|
||||||
|
);
|
||||||
|
|
||||||
|
// With frontier=101 > B=100, the register is INACTIVE (tombstone).
|
||||||
|
// currentContexts() must serialize only ov_c: for this channel — no ov_s:.
|
||||||
|
mgr.isLoadComplete = true;
|
||||||
|
const contexts = mgr.currentContexts();
|
||||||
|
assert.ok(contexts !== null, "currentContexts must not be null");
|
||||||
|
const ovSKey = `ov_s:${ctx}`;
|
||||||
|
const ovCKey = `ov_c:${ctx}`;
|
||||||
|
assert.equal(
|
||||||
|
ovSKey in contexts,
|
||||||
|
false,
|
||||||
|
"inactive override must NOT serialize ov_s: (would mark channel active/unread)",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
ovCKey in contexts,
|
||||||
|
"inactive override must serialize ov_c: tombstone floor",
|
||||||
|
);
|
||||||
|
|
||||||
|
mgr.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Test 22: fetchOwnBlobBeforePublish processes foreign client_id ────────────
|
// ── Test 22: fetchOwnBlobBeforePublish processes foreign client_id ────────────
|
||||||
test("fetchOwnBlobBeforePublish_foreignClientId_rotatesSlotAndUpdatesMetadata", async () => {
|
test("fetchOwnBlobBeforePublish_foreignClientId_rotatesSlotAndUpdatesMetadata", async () => {
|
||||||
// Thufir's mandated witness: fetchOwnBlobBeforePublish must run the same
|
// Thufir's mandated witness: fetchOwnBlobBeforePublish must run the same
|
||||||
|
|||||||
@@ -447,7 +447,6 @@ export class ReadStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Override register merge ───────────────────────────────────────────
|
|
||||||
for (const [rawCtx, reg] of merged.overrides) {
|
for (const [rawCtx, reg] of merged.overrides) {
|
||||||
const ex = this.overrideRegisters.get(rawCtx);
|
const ex = this.overrideRegisters.get(rawCtx);
|
||||||
const m: OverrideRegister = ex
|
const m: OverrideRegister = ex
|
||||||
@@ -522,7 +521,6 @@ export class ReadStateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startLiveSubscription(): Promise<void> {
|
private async startLiveSubscription(): Promise<void> {
|
||||||
// Wire retryLoad on reconnects so incomplete init doesn't brick the manager.
|
|
||||||
const unsubReconnect = this.relayClient.subscribeToReconnects(() => {
|
const unsubReconnect = this.relayClient.subscribeToReconnects(() => {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
void this.retryLoad();
|
void this.retryLoad();
|
||||||
@@ -555,7 +553,6 @@ export class ReadStateManager {
|
|||||||
|
|
||||||
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
|
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
|
||||||
if (event.pubkey !== this.pubkey || this.destroyed) return;
|
if (event.pubkey !== this.pubkey || this.destroyed) return;
|
||||||
// Parse once: use the result for both ingest and the convergence client_id check.
|
|
||||||
const parsed = await parseReadStateEvent(event, this.pubkey);
|
const parsed = await parseReadStateEvent(event, this.pubkey);
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
const delta = await this.ingestParsedEvents([parsed]);
|
const delta = await this.ingestParsedEvents([parsed]);
|
||||||
@@ -935,8 +932,11 @@ export class ReadStateManager {
|
|||||||
}
|
}
|
||||||
: { s: e.s, c: e.c, b: e.b };
|
: { s: e.s, c: e.c, b: e.b };
|
||||||
this.overrideRegisters.set(ctx, merged);
|
this.overrideRegisters.set(ctx, merged);
|
||||||
if (e.f > 0 && !this.effectiveState.has(ctx))
|
if (e.f > 0)
|
||||||
this.effectiveState.set(ctx, e.f);
|
this.effectiveState.set(
|
||||||
|
ctx,
|
||||||
|
Math.max(this.effectiveState.get(ctx) ?? 0, e.f),
|
||||||
|
);
|
||||||
this.publishableContextIds.add(ctx); // v2 entry is inherently publishable
|
this.publishableContextIds.add(ctx); // v2 entry is inherently publishable
|
||||||
}
|
}
|
||||||
this.persistLocalState();
|
this.persistLocalState();
|
||||||
|
|||||||
Reference in New Issue
Block a user