Skip to content

Commit 6b33140

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
fix(readState): move lastPublishedContexts reset inside split→single guard
The reset was placed outside the extraSlotIds.length > 0 guard, causing it to fire on every debounce cycle in steady-state single-slot mode. This cleared lastPublishedContexts before isIdenticalToLastPublished, making it always return false and reintroducing the original retry storm. Move the reset inside the guard so it only fires on the split→single transition. Also adds publishSplitSlots_noopSuppression_skipsWhenUnchanged — a ReadStateManager integration test that stubs publishOneSlot to avoid tauri and verifies the no-op suppression path in publishSplitSlots. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent 62e9ee7 commit 6b33140

2 files changed

Lines changed: 112 additions & 5 deletions

File tree

desktop/src/features/channels/readState/readStateManager.test.mjs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,52 @@ import assert from "node:assert/strict";
22
import test from "node:test";
33

44
import {
5+
ReadStateManager,
56
applyRemoteContextTimestamp,
67
evictDominatedEntries,
78
resolveEffectiveTimestamp,
89
splitContextsIntoBudgetedSlots,
910
trimContextsToBudget,
1011
} from "./readStateManager.ts";
1112

13+
// ── ReadStateManager integration helpers ─────────────────────────────────────
14+
// Provide browser globals required by ReadStateManager (localStorage,
15+
// window.setTimeout/clearTimeout). Each test that uses ReadStateManager
16+
// constructs a fresh in-memory store so tests are isolated.
17+
18+
function makeLocalStorage() {
19+
const store = new Map();
20+
return {
21+
getItem: (key) => store.get(key) ?? null,
22+
setItem: (key, value) => store.set(key, value),
23+
removeItem: (key) => store.delete(key),
24+
};
25+
}
26+
27+
// Install browser globals required by ReadStateManager. window.localStorage is
28+
// replaced per-test for isolation; the bare `localStorage` global proxies to it.
29+
{
30+
const ls = makeLocalStorage();
31+
if (typeof globalThis.window === "undefined") {
32+
globalThis.window = {
33+
localStorage: ls,
34+
clearTimeout: (id) => clearTimeout(id),
35+
setTimeout: (fn, ms) => setTimeout(fn, ms),
36+
};
37+
} else {
38+
globalThis.window.localStorage = ls;
39+
if (!globalThis.window.clearTimeout) {
40+
globalThis.window.clearTimeout = (id) => clearTimeout(id);
41+
globalThis.window.setTimeout = (fn, ms) => setTimeout(fn, ms);
42+
}
43+
}
44+
// Ensure bare `localStorage` always proxies to window.localStorage.
45+
Object.defineProperty(globalThis, "localStorage", {
46+
get: () => globalThis.window.localStorage,
47+
configurable: true,
48+
});
49+
}
50+
1251
const threadKey = `thread:${"a".repeat(64)}`;
1352
const channelKey = "channel-1";
1453
const channelResolver = (ctx) =>
@@ -572,3 +611,70 @@ test("splitContextsIntoBudgetedSlots_threadMsgTrimmedWhenPrimarySlotOverBudget",
572611
const size = blobSize(CLIENT_ID, result.slots[0]);
573612
assert.ok(size <= budget, `slot 0 size ${size} exceeds budget ${budget}`);
574613
});
614+
615+
// ── ReadStateManager.publish — no-op suppression in split mode ────────────────
616+
617+
// Verify that publishSplitSlots returns early (no relay writes) when the
618+
// union of all slot contexts is identical to lastPublishedContexts.
619+
//
620+
// Strategy: construct a ReadStateManager with enough channel keys to force
621+
// split mode, then mock publishOneSlot (private, accessed via bracket notation)
622+
// to avoid tauri calls while still simulating its effect on lastPublishedContexts.
623+
// Call publish() twice with the same effectiveState and assert that
624+
// publishOneSlot is called only on the first publish (no-op on the second).
625+
test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => {
626+
// Isolate localStorage so slot IDs don't leak between tests.
627+
globalThis.window.localStorage = makeLocalStorage();
628+
629+
const fakeRelay = {
630+
fetchEvents: async () => [],
631+
publishEvent: async () => {},
632+
subscribeLive: () => () => {},
633+
};
634+
635+
const pubkey = "b".repeat(64);
636+
const mgr = new ReadStateManager(pubkey, fakeRelay);
637+
638+
// Add enough channel keys to exceed the 32KB single-slot budget.
639+
// Each key is ~70 bytes in the blob; 700 keys ≈ 49KB > 32KB.
640+
const ts = 1_000_000;
641+
for (let i = 0; i < 700; i++) {
642+
const channelId = `channel-${i.toString().padStart(64, "0")}`;
643+
mgr.markContextRead(channelId, ts);
644+
}
645+
646+
// Confirm split mode: currentContexts() must return null.
647+
assert.equal(
648+
mgr.currentContexts(),
649+
null,
650+
"precondition: 700 channel keys must exceed single-slot budget",
651+
);
652+
653+
// Replace publishOneSlot with a stub that records calls and simulates the
654+
// lastPublishedContexts merge (the only side-effect the no-op check depends
655+
// on). This avoids tauri (nip44EncryptToSelf / signRelayEvent) while keeping
656+
// the suppression logic under test.
657+
let publishOneSlotCallCount = 0;
658+
mgr.publishOneSlot = async (_slotId, contexts) => {
659+
publishOneSlotCallCount++;
660+
for (const [key, tsVal] of Object.entries(contexts)) {
661+
mgr.lastPublishedContexts[key] = tsVal;
662+
}
663+
};
664+
665+
// First publish: contexts differ from lastPublishedContexts ({}) → must publish.
666+
await mgr.publish();
667+
const callsAfterFirst = publishOneSlotCallCount;
668+
assert.ok(callsAfterFirst > 0, "first publish must call publishOneSlot");
669+
670+
// Second publish with identical effectiveState: union equals lastPublishedContexts
671+
// → no-op suppression must fire → publishOneSlot must NOT be called again.
672+
await mgr.publish();
673+
assert.equal(
674+
publishOneSlotCallCount,
675+
callsAfterFirst,
676+
"second publish with unchanged state must not call publishOneSlot (no-op suppression)",
677+
);
678+
679+
mgr.destroy();
680+
});

desktop/src/features/channels/readState/readStateManager.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -741,15 +741,16 @@ export class ReadStateManager {
741741

742742
// Transitioning from split to single mode: delete stale extra-slot blobs
743743
// from the relay so fetchOwnBlobBeforePublish stops re-inflating
744-
// lastPublishedContexts from them.
744+
// lastPublishedContexts from them. Reset lastPublishedContexts here (inside
745+
// the guard) so stale keys from the previous split don't cause
746+
// isIdenticalToLastPublished to return false forever. The reset must stay
747+
// inside the guard — resetting unconditionally would clear the relay-fetched
748+
// state on every debounce cycle and reintroduce the retry storm.
745749
if (this.extraSlotIds.length > 0) {
746750
await this.deleteExtraSlots();
751+
this.lastPublishedContexts = {};
747752
}
748753

749-
// Suppress no-op publishes. Reset lastPublishedContexts first so stale
750-
// keys from a previous split don't cause isIdenticalToLastPublished to
751-
// return false forever.
752-
this.lastPublishedContexts = {};
753754
if (this.isIdenticalToLastPublished(contexts)) return;
754755

755756
await this.publishOneSlot(this.slotId, contexts);

0 commit comments

Comments
 (0)