Skip to content

fix(read-state): enforce byte-budget eviction in currentContexts()#1305

Merged
wpfleger96 merged 3 commits into
mainfrom
duncan/readstate-byte-budget
Jun 26, 2026
Merged

fix(read-state): enforce byte-budget eviction in currentContexts()#1305
wpfleger96 merged 3 commits into
mainfrom
duncan/readstate-byte-budget

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes the [ReadStateManager] publish failed relay rejection that breaks cross-device read state sync.

Problem

currentContexts() guarded blob size with an entry-count threshold (8k/10k). Entry count is the wrong metric — the relay enforces a byte-size limit on event content, not an entry count. At 8k msg: entries the JSON blob is already ~544KB of keys alone, well past the relay's limit. The relay responds with OK false events too long, which relayClientSession.ts surfaces as the [ReadStateManager] publish failed warning. Because lastPublishedContexts is never updated on failure, the manager retries on every debounce cycle (~5s).

Fix

Replace the entry-count eviction block in currentContexts() with a serialized byte-budget loop via a new exported pure function trimContextsToBudget():

  1. Serialize the full {v:1, client_id, contexts} blob and check its UTF-8 byte length.
  2. If over budget, collect msg: entries and thread: entries separately, sort each oldest-first (lowest timestamp).
  3. Evict from msg: first, then thread:, one entry at a time, re-checking the serialized size after each deletion.
  4. Channel keys (channel:) are never evicted.
  5. A console.warn fires when trim occurs so the fix is visible in staging.

READ_STATE_MAX_PLAINTEXT_BYTES = 32_768 (32KB) is added to readStateFormat.ts. This produces ~45KB NIP-44 ciphertext (~1.4× overhead), well under the relay's 256KB content cap and NIP-44's 65,535-byte plaintext hard limit.

Files changed

  • desktop/src/features/channels/readState/readStateFormat.ts — add READ_STATE_MAX_PLAINTEXT_BYTES
  • desktop/src/features/channels/readState/readStateManager.ts — add trimContextsToBudget(), replace eviction block in currentContexts()
  • desktop/src/features/channels/readState/readStateManager.test.mjs — 5 new tests covering the trim path

Stack

Stack: this PR → #1309

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 3 commits June 26, 2026 13:01
The relay rejects read-state events whose NIP-44 ciphertext exceeds the
relay's content limit. The previous guard used an entry-count threshold
(8k/10k) which is the wrong metric: at 8k msg: entries the JSON blob is
already ~544KB of keys alone, well past the relay's limit.

Replace the entry-count eviction with a serialized byte-budget loop.
currentContexts() now calls trimContextsToBudget() which serializes the
full {v:1, client_id, contexts} blob, checks its byte length, and evicts
oldest msg: entries first (lowest timestamp), then oldest thread: entries,
until the JSON fits within READ_STATE_MAX_PLAINTEXT_BYTES (32KB). Channel
keys are never evicted. The 32KB plaintext budget produces ~45KB ciphertext
after NIP-44's ~1.4x overhead, well under the relay's 256KB content cap
and NIP-44's 65,535-byte plaintext hard limit.

A console.warn fires when trim occurs so the fix is visible in staging.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…h on overflow

Address two IMPORTANT findings from Thufir's review of #1305:

1. O(n²) re-encode loop — the eviction loop called encoder.encode() on
   every iteration, O(n) per step × n steps = O(n²). With 7,500 entries
   this caused a confirmed 4.7s UI freeze on first publish after upgrade.
   Replace with an O(n) pass: compute total bytes once, subtract each
   entry's per-entry delta (key.length + 4 + timestamp digits), collect
   entries to evict, delete them in a batch, then do one final encode as
   the authoritative check (handles JSON comma-accounting edge cases the
   delta estimate ignores).

2. Silent failure when channel-only blob exceeds budget — after exhausting
   all msg:/thread: entries the function returned without checking whether
   the remaining blob still exceeded the budget. The caller would then
   proceed to publish a blob the relay would reject.
   Fix: trimContextsToBudget() now returns { evicted, fitsAfterTrim }.
   currentContexts() returns null when fitsAfterTrim is false; publish()
   and initialize() both guard against null and skip the publish.

Also address two MINOR findings:
- JSDoc now documents that trimContextsToBudget mutates contexts in place
- Empty-map test replaced with a test that actually exercises the
  fitsAfterTrim=false path (channel-only blob smaller than budget)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Auto-format via biome format --write; remove two unused fullSize variables
in readStateManager.test.mjs (inlined into budget computation).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 merged commit 6b05646 into main Jun 26, 2026
25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/readstate-byte-budget branch June 26, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant