fix(desktop): cut steady-state relay traffic from polls and read-state echo (#5879)

## Problem

Desktop webview CPU stayed high after the presence-scope fix (#5830) and
the shared useNow ticker (#5861). A per-kind byte tap hot-patched into
`relayClientSession.ts` on a live desktop (~500 channels, large agent
fleet; 850 s capture correlated with CPU sampling) showed the remaining
steady-state relay traffic is mostly self-inflicted:

| kind | what | share of inbound bytes | shape |
|------|------|-----------------------|-------|
| 30078 | read-state | **34%** | our own ~44 KB nip44 blob echoed back
every ~10-30 s while reading |
| 30030 | emoji union | **33%** | 2-min poll refetching every member's
full set (~300 KB burst) |
| 30175 | persona catalog | **13%** | same 2-min backstop pattern, ~150
KB per walk |

CPU tracked the bursts directly: 3-5% in quiet 10 s buckets vs 44-54% in
buckets containing a poll burst or read-state echo. (The kind-24200
observer-frame theory was tested and disproven by the same tap: 9.7% of
bytes, steady trickle.)

## Outcome

- **Read-state echo drop.** `ReadStateManager` remembers the ids of
events it just published (FIFO set capped at 64) and drops their relay
echoes before the nip44-decrypt + `JSON.parse` step. Ids are recorded
*before* publishing so relay fan-out can't race the OK. The drop
consumes the id, so a reconnect replay of the same event still parses
normally. Events from other clients of the same pubkey are untouched.
- **Poll backstops stretched 2 min → 20 min** for the emoji union and
persona catalog queries. The live subscriptions (invalidate on any new
30030/30175) and the reconnect invalidations remain the freshness paths;
the poll only exists to cover a silently dropped live event. Behavior on
publish, focus, and reconnect is unchanged.
- Mechanical: localStorage identity helpers moved to
`readStateIdentity.ts` (no behavior change) to keep
`readStateManager.ts` under the file-size ratchet.

Expected effect on the measured profile: the poll stretch cuts the
30030/30175 bursts (46% of inbound bytes) by 10x; the echo drop removes
the recurring ~44 KB nip44-decrypt + parse per publish cycle (the echo
still arrives on the wire — nostr filters cannot exclude own-author
events — so this is a CPU/IPC saving, not a bandwidth one).

## Acceptance

- New tests: echo dropped **before** decrypt (mutation-checked:
disabling the drop fails the test), replayed duplicate of the same id
still parses, foreign-client events always parse, published-id set stays
capped when publishes fail (never-echoed ids).
- Full desktop suite **4794/4794**, `tsc --noEmit` clean, `pnpm check`
(biome + ratchets) clean at head.

## Not addressed (follow-ups)

- The 44 KB blob itself (one read-state event carries all ~500 channels;
a delta or per-channel-shard format is a protocol change).
- Duplicate delivery of the same events on concurrent `history-`
subscriptions (relay/client dedupe).
- Webview RSS of 12.5 GB observed on the same machine — retention hunt
is separate work; shrinking the heap multiplies the value of this PR
since the GC floor scales with live-heap size.

Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
Tyler
2026-08-14 14:38:33 -04:00
committed by GitHub
co-authored by Eva
parent 207154706c
commit 01f76ec971
5 changed files with 188 additions and 54 deletions
@@ -15,8 +15,12 @@ import {
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
import { KIND_PERSONA } from "@/shared/constants/kinds";
/** Keeps focused polling at the established 2-minute backstop cadence. */
export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 120_000;
/** Poll backstop cadence. The live subscription (invalidate on any new
* 30175) and the reconnect invalidation are the freshness paths; this poll
* exists only to cover a silently dropped live event, so it can be rare. At
* the previous 2-minute cadence it re-walked the entire persona catalog
* (~150 KB burst) often enough to be a top-three desktop traffic source. */
export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 20 * 60_000;
/** Suppresses the focus refetch until persona catalog data is genuinely stale.
* The live subscription (invalidateQueries) is the primary freshness path. */
export const PERSONA_CATALOG_FOCUS_STALE_TIME_MS = 5 * 60_000;
@@ -0,0 +1,58 @@
import { localExtraSlotIdsKey } from "@/features/channels/readState/readStateFormat";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
/**
* localStorage-persisted identity for the read-state manager: the stable
* client id, this client's slot id, and any extra slot ids allocated when the
* blob outgrows the single-slot budget (NIP-RS multi-slot mode).
*/
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
export function generateHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function getOrCreatePersisted(
key: string,
generator: () => string,
): string {
let value = localStorage.getItem(key);
if (!value) {
value = generator();
setLocalStorageItemWithRecovery(key, value);
}
return value;
}
export function clientIdKey(pubkey: string): string {
return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`;
}
export function slotIdKey(pubkey: string): string {
return `${SLOT_ID_KEY_PREFIX}:${pubkey}`;
}
export function loadExtraSlotIds(pubkey: string): string[] {
try {
const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(v): v is string => typeof v === "string" && v.length > 0,
);
} catch {
return [];
}
}
export function saveExtraSlotIds(pubkey: string, ids: string[]): void {
setLocalStorageItemWithRecovery(
localExtraSlotIdsKey(pubkey),
JSON.stringify(ids),
);
}
@@ -841,3 +841,71 @@ test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => {
mgr.destroy();
});
// ── ReadStateManager — self-echo drop ─────────────────────────────────────────
// The live subscription (authors=[us]) echoes back every event we publish.
// handleIncomingEvent must drop those echoes by id BEFORE the decrypt/parse
// step: with hundreds of channels a blob is tens of KB, so decrypting our own
// echo on every read was a large recurring cost. Strategy mirrors the split-
// mode test above: stub the private parseEvent seam to count decrypt attempts.
test("handleIncomingEvent_dropsSelfEchoBeforeDecrypt", async () => {
globalThis.window.localStorage = makeLocalStorage();
const pubkey = "c".repeat(64);
const mgr = new ReadStateManager(pubkey, makeFakeRelay());
let parseCount = 0;
mgr.parseEvent = async () => {
parseCount++;
return null;
};
const makeEvent = (id) => ({
id,
pubkey,
kind: 30078,
created_at: 1_000,
content: "ciphertext",
tags: [
["d", "read-state:slot"],
["t", "read-state"],
],
});
// An event we published ourselves: echo must be dropped without a parse.
const ownId = "e".repeat(64);
mgr.rememberPublishedId(ownId);
await mgr.handleIncomingEvent(makeEvent(ownId));
assert.equal(parseCount, 0, "self-echo must not reach decrypt/parse");
// The drop consumes the remembered id — a replayed duplicate (e.g. from a
// reconnect catch-up) goes through the normal parse path.
await mgr.handleIncomingEvent(makeEvent(ownId));
assert.equal(parseCount, 1, "second delivery of same id must parse");
// An event from another client of the same pubkey must always parse.
await mgr.handleIncomingEvent(makeEvent("f".repeat(64)));
assert.equal(parseCount, 2, "foreign-client event must parse");
mgr.destroy();
});
// The remembered-id set must stay bounded even if publishes fail (a failed
// publish leaves an id that is never echoed back, so nothing deletes it).
test("rememberPublishedId_evictsOldestBeyondCap", () => {
globalThis.window.localStorage = makeLocalStorage();
const mgr = new ReadStateManager("d".repeat(64), makeFakeRelay());
const total = 100; // beyond the 64-id cap
for (let i = 0; i < total; i++) {
mgr.rememberPublishedId(`id-${i}`);
}
const ids = mgr.recentlyPublishedIds;
assert.equal(ids.size, 64, "set must be capped");
assert.ok(!ids.has("id-0"), "oldest id must be evicted");
assert.ok(ids.has(`id-${total - 1}`), "newest id must be retained");
mgr.destroy();
});
@@ -10,10 +10,17 @@ import {
READ_STATE_MAX_SLOTS,
MSG_PREFIX,
THREAD_PREFIX,
localExtraSlotIdsKey,
type ReadStateBlob,
} from "@/features/channels/readState/readStateFormat";
import { parseReadStateEvent } from "@/features/channels/readState/readStateSnapshot";
import {
clientIdKey,
generateHex,
getOrCreatePersisted,
loadExtraSlotIds,
saveExtraSlotIds,
slotIdKey,
} from "@/features/channels/readState/readStateIdentity";
import {
readStoredReadState,
writeStoredReadState,
@@ -21,54 +28,12 @@ import {
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
import { truncatePubkey } from "@/shared/lib/pubkey";
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
const PUBLISH_DEBOUNCE_MS = 5_000;
const LOCAL_PERSIST_MAX_WAIT_MS = 1_000;
function generateHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}
function getOrCreatePersisted(key: string, generator: () => string): string {
let value = localStorage.getItem(key);
if (!value) {
value = generator();
setLocalStorageItemWithRecovery(key, value);
}
return value;
}
function clientIdKey(pubkey: string): string {
return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`;
}
function slotIdKey(pubkey: string): string {
return `${SLOT_ID_KEY_PREFIX}:${pubkey}`;
}
function loadExtraSlotIds(pubkey: string): string[] {
try {
const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(v): v is string => typeof v === "string" && v.length > 0,
);
} catch {
return [];
}
}
function saveExtraSlotIds(pubkey: string, ids: string[]): void {
setLocalStorageItemWithRecovery(
localExtraSlotIdsKey(pubkey),
JSON.stringify(ids),
);
}
/** How many of our own just-published event ids to remember so the live
* subscription can drop their relay echoes before the nip44 decrypt. A
* publish cycle emits at most a handful of slot events; 64 is generous. */
const PUBLISHED_ID_MEMORY = 64;
export type ApplyRemoteContextResult = "unchanged" | "advanced";
@@ -322,6 +287,8 @@ export class ReadStateManager {
private pendingSyncedAdvances = new Set<string>();
private destroyed = false;
private parentResolver: ContextParentResolver | null = null;
/** Event ids we published ourselves; used to skip decrypting their echoes. */
private recentlyPublishedIds = new Set<string>();
constructor(pubkey: string, relayClient: RelayClient) {
this.pubkey = pubkey;
@@ -496,7 +463,7 @@ export class ReadStateManager {
>();
for (const event of events) {
const parsed = await parseReadStateEvent(event, this.pubkey);
const parsed = await this.parseEvent(event);
if (this.destroyed) return;
if (!parsed) continue;
@@ -533,7 +500,7 @@ export class ReadStateManager {
// Conflict detection: check if another client_id is squatting on our
// d-tag coordinate. If so, rotate our slotId to avoid clobbering.
for (const event of events) {
const parsed = await parseReadStateEvent(event, this.pubkey);
const parsed = await this.parseEvent(event);
if (this.destroyed) return;
if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue;
if (parsed.blob.client_id !== this.clientId) {
@@ -588,11 +555,24 @@ export class ReadStateManager {
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
if (this.destroyed || event.pubkey !== this.pubkey) return;
// Echo drop: the live subscription (authors=[us]) receives every event we
// publish right back from the relay. Decrypting and re-parsing our own
// blob is pure waste — with hundreds of channels a single blob runs tens
// of KB, so on an actively-reading client the echo was a large recurring
// nip44-decrypt + JSON.parse for information we already hold.
if (this.recentlyPublishedIds.delete(event.id)) {
console.debug(
`[ReadStateManager] dropped self-echo event=${event.id.substring(0, 8)}`,
);
return;
}
console.debug(
`[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`,
);
const parsed = await parseReadStateEvent(event, this.pubkey);
const parsed = await this.parseEvent(event);
if (!parsed || this.destroyed) return;
this.maxFetchedCreatedAt = Math.max(
@@ -635,6 +615,22 @@ export class ReadStateManager {
}
}
/** Seam over `parseReadStateEvent` so tests can count/stub decrypts
* (see readStateManager.test.mjs echo-drop tests). */
private parseEvent(event: RelayEvent) {
return parseReadStateEvent(event, this.pubkey);
}
/** Record an id we just published, capped so failed publishes can't grow
* the set unboundedly. Set preserves insertion order, so eviction is FIFO. */
private rememberPublishedId(id: string): void {
this.recentlyPublishedIds.add(id);
if (this.recentlyPublishedIds.size > PUBLISHED_ID_MEMORY) {
const oldest = this.recentlyPublishedIds.values().next().value;
if (oldest !== undefined) this.recentlyPublishedIds.delete(oldest);
}
}
private schedulePublish(): void {
if (this.destroyed) return;
if (this.debounceTimer !== null) {
@@ -713,6 +709,10 @@ export class ReadStateManager {
tags,
});
// Remember the id BEFORE publishing: the relay may fan the event out to
// our own live subscription before the publish OK resolves. A failed
// publish leaves a never-echoed id in the set; the size cap evicts it.
this.rememberPublishedId(event.id);
await this.relayClient.publishEvent(
event,
"Timed out publishing read state.",
+6 -2
View File
@@ -23,8 +23,12 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
* live event is missed. Mirrors `user-status/hooks.ts`.
*/
/** Keeps focused polling at the established 2-minute backstop cadence. */
export const CUSTOM_EMOJI_REFETCH_INTERVAL_MS = 120_000;
/** Poll backstop cadence. The live subscription (invalidate on any member's
* new 30030) and the reconnect invalidation are the freshness paths; this
* poll exists only to cover a silently dropped live event, so it can be
* rare. At the previous 2-minute cadence it refetched every member's full
* set (~300 KB burst) often enough to dominate desktop relay traffic. */
export const CUSTOM_EMOJI_REFETCH_INTERVAL_MS = 20 * 60_000;
/** Suppresses the focus refetch until emoji data is genuinely stale.
* The live subscription (invalidateQueries) is the primary freshness path. */
export const CUSTOM_EMOJI_FOCUS_STALE_TIME_MS = 5 * 60_000;