Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ const overrides = new Map([
// A small overage from load-bearing security plumbing on a file already at
// 893 lines, not generic debt growth. Approved override; still queued to split.
["src-tauri/src/app_state.rs", 1012],
// multi-slot splitting + no-op suppression (#1309): the ReadStateManager
// class grew from ~700 lines to ~1019 with the addition of
// splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots,
// publishOneSlot, deleteExtraSlots, and the no-op suppression integration
// test. Load-bearing feature growth, queued to split publishSplitSlots path
// into readStateManagerSplit.ts.
["src/features/channels/readState/readStateManager.ts", 1030],
]);

await runFileSizeCheck({
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/channels/readState/readStateFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export const MAX_CONTEXTS = 10_000;
// expansion to ~45 KB ciphertext) while keeping the blob well under both caps.
export const READ_STATE_MAX_PLAINTEXT_BYTES = 32_768;

// Maximum number of slots a client may publish. Each slot is a separate
// kind:30078 event. Splitting across slots is the fallback when channel keys
// alone exceed READ_STATE_MAX_PLAINTEXT_BYTES. 8 slots × ~650 channel keys per
// slot = ~5,200 channels — well beyond any realistic user.
export const READ_STATE_MAX_SLOTS = 8;

// Context-key prefix for a per-MESSAGE read marker (LP4 v3). One grow-only
// marker per reply id; the badge predicate reads effective("msg:<id>") live so
// reading an ancestor never covers a descendant (Issue 2 by construction).
Expand Down Expand Up @@ -104,6 +110,10 @@ export function isValidReadStateDTag(
return slotId.length > 0 && slotId.length <= 64 && isAscii(slotId);
}

export function localExtraSlotIdsKey(pubkey: string): string {
return `buzz.nip-rs.extra-slot-ids:${pubkey}`;
}

export function localIsoToUnixSeconds(value: unknown): number | null {
if (typeof value !== "string" || value.length === 0) {
return null;
Expand Down
277 changes: 277 additions & 0 deletions desktop/src/features/channels/readState/readStateManager.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,51 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
ReadStateManager,
applyRemoteContextTimestamp,
resolveEffectiveTimestamp,
splitContextsIntoBudgetedSlots,
trimContextsToBudget,
} from "./readStateManager.ts";

// ── ReadStateManager integration helpers ─────────────────────────────────────
// Provide browser globals required by ReadStateManager (localStorage,
// window.setTimeout/clearTimeout). Each test that uses ReadStateManager
// constructs a fresh in-memory store so tests are isolated.

function makeLocalStorage() {
const store = new Map();
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
};
}

// Install browser globals required by ReadStateManager. window.localStorage is
// replaced per-test for isolation; the bare `localStorage` global proxies to it.
{
const ls = makeLocalStorage();
if (typeof globalThis.window === "undefined") {
globalThis.window = {
localStorage: ls,
clearTimeout: (id) => clearTimeout(id),
setTimeout: (fn, ms) => setTimeout(fn, ms),
};
} else {
globalThis.window.localStorage = ls;
if (!globalThis.window.clearTimeout) {
globalThis.window.clearTimeout = (id) => clearTimeout(id);
globalThis.window.setTimeout = (fn, ms) => setTimeout(fn, ms);
}
}
// Ensure bare `localStorage` always proxies to window.localStorage.
Object.defineProperty(globalThis, "localStorage", {
get: () => globalThis.window.localStorage,
configurable: true,
});
}

const threadKey = `thread:${"a".repeat(64)}`;
const channelKey = "channel-1";
const channelResolver = (ctx) =>
Expand Down Expand Up @@ -300,3 +340,240 @@ test("trimContextsToBudget_channelOnlyBlobExceedsBudget_fitsAfterTrimFalse", ()
// Channel key must still be present.
assert.ok("channel:some-channel-id" in contexts);
});

// ── splitContextsIntoBudgetedSlots ────────────────────────────────────────────

// Build a channel key that is ~70 bytes in the JSON blob:
// `"channel-<64-hex>":1` ≈ 70 bytes including quotes, colon, comma.
const makeChannelKey = (n) => `channel-${n.toString().padStart(64, "0")}`;
const makeThreadKey = (n) => `thread:${n.toString().padStart(64, "0")}`;
const makeMsgKey = (n) => `msg:${n.toString().padStart(64, "0")}`;

// Compute the byte size of a single-slot blob with the given contexts.
const blobSize = (clientId, contexts) => {
const encoder = new TextEncoder();
return encoder.encode(JSON.stringify({ v: 1, client_id: clientId, contexts }))
.length;
};

let slotCounter = 0;
const deterministicSlotId = () =>
`slot-${(++slotCounter).toString().padStart(4, "0")}`;

test("splitContextsIntoBudgetedSlots_fitsInOneSlot_returnsSingleSlot", () => {
// 3 channel keys — easily fits in one slot with a generous budget.
const channelEntries = [
[makeChannelKey(1), 100],
[makeChannelKey(2), 200],
[makeChannelKey(3), 300],
];
const result = splitContextsIntoBudgetedSlots({
channelEntries,
threadMsgEntries: [],
clientId: CLIENT_ID,
initialSlotCount: 1,
maxSlots: 8,
maxBytes: 1_000_000,
slotIdGenerator: deterministicSlotId,
});

assert.ok(result !== null, "should succeed");
assert.equal(result.slots.length, 1, "single slot");
assert.equal(result.extraSlotIds.length, 0, "no extra slots allocated");
// All channel keys present in slot 0.
for (const [key] of channelEntries) {
assert.ok(key in result.slots[0], `${key} should be in slot 0`);
}
});

test("splitContextsIntoBudgetedSlots_requiresGrowth_allocatesExtraSlot", () => {
// Build enough channel keys that a single slot overflows a tight budget
// but two slots fit.
const channelEntries = [];
for (let i = 0; i < 20; i++) {
channelEntries.push([makeChannelKey(i), i + 1]);
}
const encoder = new TextEncoder();
// Budget that fits ~10 channel keys but not 20.
const tenKeyContexts = Object.fromEntries(channelEntries.slice(0, 10));
const tenKeySize = encoder.encode(
JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts: tenKeyContexts }),
).length;
const budget = tenKeySize + 50; // fits 10 but not 20

const result = splitContextsIntoBudgetedSlots({
channelEntries,
threadMsgEntries: [],
clientId: CLIENT_ID,
initialSlotCount: 1,
maxSlots: 8,
maxBytes: budget,
slotIdGenerator: deterministicSlotId,
});

assert.ok(result !== null, "should succeed with 2 slots");
assert.equal(result.slots.length, 2, "two slots");
assert.equal(result.extraSlotIds.length, 1, "one extra slot allocated");
// All 20 keys present across both slots.
const allKeys = new Set([
...Object.keys(result.slots[0]),
...Object.keys(result.slots[1]),
]);
for (const [key] of channelEntries) {
assert.ok(allKeys.has(key), `${key} should appear in some slot`);
}
// Each slot fits within budget.
for (const slotContexts of result.slots) {
const size = encoder.encode(
JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts: slotContexts }),
).length;
assert.ok(size <= budget, `slot size ${size} exceeds budget ${budget}`);
}
});

test("splitContextsIntoBudgetedSlots_exceedsMaxSlots_returnsNull", () => {
// Build enough channel keys that even maxSlots=2 can't fit them with a
// very tight budget (1 byte — nothing can fit).
const channelEntries = [[makeChannelKey(1), 1]];
const result = splitContextsIntoBudgetedSlots({
channelEntries,
threadMsgEntries: [],
clientId: CLIENT_ID,
initialSlotCount: 1,
maxSlots: 2,
maxBytes: 1, // impossibly small
slotIdGenerator: deterministicSlotId,
});

assert.equal(result, null, "should return null when max slots exceeded");
});

test("splitContextsIntoBudgetedSlots_includesThreadMsgInPrimarySlot", () => {
// Channel key in slot 0; thread and msg entries should also land in slot 0.
const channelEntries = [[makeChannelKey(1), 100]];
const threadMsgEntries = [
[makeThreadKey(1), 200],
[makeMsgKey(1), 300],
];

const result = splitContextsIntoBudgetedSlots({
channelEntries,
threadMsgEntries,
clientId: CLIENT_ID,
initialSlotCount: 1,
maxSlots: 8,
maxBytes: 1_000_000,
slotIdGenerator: deterministicSlotId,
});

assert.ok(result !== null, "should succeed");
assert.equal(result.slots.length, 1);
// Channel key in slot 0.
assert.ok(makeChannelKey(1) in result.slots[0], "channel key in slot 0");
// Thread and msg entries in slot 0.
assert.ok(makeThreadKey(1) in result.slots[0], "thread key in slot 0");
assert.ok(makeMsgKey(1) in result.slots[0], "msg key in slot 0");
});

test("splitContextsIntoBudgetedSlots_threadMsgTrimmedWhenPrimarySlotOverBudget", () => {
// Channel key fills the primary slot to near-budget. Thread/msg entries
// added to slot 0 would overflow — trimContextsToBudget must evict them.
const channelEntries = [[makeChannelKey(1), 100]];
// Compute the size of a blob with just the channel key.
const channelOnlyContexts = { [makeChannelKey(1)]: 100 };
const channelOnlySize = blobSize(CLIENT_ID, channelOnlyContexts);
// Budget = channel-only size + 5 bytes: fits the channel key but not
// an additional thread/msg entry (~70+ bytes each).
const budget = channelOnlySize + 5;

const threadMsgEntries = [[makeThreadKey(1), 200]];

const result = splitContextsIntoBudgetedSlots({
channelEntries,
threadMsgEntries,
clientId: CLIENT_ID,
initialSlotCount: 1,
maxSlots: 8,
maxBytes: budget,
slotIdGenerator: deterministicSlotId,
});

assert.ok(result !== null, "should succeed");
// Channel key must survive (never evicted by trimContextsToBudget).
assert.ok(makeChannelKey(1) in result.slots[0], "channel key survives");
// Thread entry must be evicted (doesn't fit within budget).
assert.ok(
!(makeThreadKey(1) in result.slots[0]),
"thread key evicted to fit budget",
);
// Slot 0 must fit within budget.
const size = blobSize(CLIENT_ID, result.slots[0]);
assert.ok(size <= budget, `slot 0 size ${size} exceeds budget ${budget}`);
});

// ── ReadStateManager.publish — no-op suppression in split mode ────────────────

// Verify that publishSplitSlots returns early (no relay writes) when the
// union of all slot contexts is identical to lastPublishedContexts.
//
// Strategy: construct a ReadStateManager with enough channel keys to force
// split mode, then mock publishOneSlot (private, accessed via bracket notation)
// to avoid tauri calls while still simulating its effect on lastPublishedContexts.
// Call publish() twice with the same effectiveState and assert that
// publishOneSlot is called only on the first publish (no-op on the second).
test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => {
// Isolate localStorage so slot IDs don't leak between tests.
globalThis.window.localStorage = makeLocalStorage();

const fakeRelay = {
fetchEvents: async () => [],
publishEvent: async () => {},
subscribeLive: () => () => {},
};

const pubkey = "b".repeat(64);
const mgr = new ReadStateManager(pubkey, fakeRelay);

// Add enough channel keys to exceed the 32KB single-slot budget.
// Each key is ~70 bytes in the blob; 700 keys ≈ 49KB > 32KB.
const ts = 1_000_000;
for (let i = 0; i < 700; i++) {
const channelId = `channel-${i.toString().padStart(64, "0")}`;
mgr.markContextRead(channelId, ts);
}

// Confirm split mode: currentContexts() must return null.
assert.equal(
mgr.currentContexts(),
null,
"precondition: 700 channel keys must exceed single-slot budget",
);

// Replace publishOneSlot with a stub that records calls and simulates the
// lastPublishedContexts merge (the only side-effect the no-op check depends
// on). This avoids tauri (nip44EncryptToSelf / signRelayEvent) while keeping
// the suppression logic under test.
let publishOneSlotCallCount = 0;
mgr.publishOneSlot = async (_slotId, contexts) => {
publishOneSlotCallCount++;
for (const [key, tsVal] of Object.entries(contexts)) {
mgr.lastPublishedContexts[key] = tsVal;
}
};

// First publish: contexts differ from lastPublishedContexts ({}) → must publish.
await mgr.publish();
const callsAfterFirst = publishOneSlotCallCount;
assert.ok(callsAfterFirst > 0, "first publish must call publishOneSlot");

// Second publish with identical effectiveState: union equals lastPublishedContexts
// → no-op suppression must fire → publishOneSlot must NOT be called again.
await mgr.publish();
assert.equal(
publishOneSlotCallCount,
callsAfterFirst,
"second publish with unchanged state must not call publishOneSlot (no-op suppression)",
);

mgr.destroy();
});
Loading
Loading