Repository navigation
Closed
Conversation
matt2e
marked this pull request as ready for review
November 2, 2026 03:36
kalvinnchau
reviewed
Nov 2, 2026
kalvinnchau
left a comment
Contributor
There was a problem hiding this comment.
🤖 Four actionable findings: the browser relay model rejects the new overlay query, restrictive metadata eviction permits stale public replay, partial profile loading can hide author ambiguity, and the large-roster test is flaky. Fix these before merging.
matt2e
added a commit
that referenced
this pull request
Nov 2, 2026
Fixes the two CI failures on PR #2114 (run 41377657528). Browser journeys: 2d4007a split the exact-target overlay read into a safety-kind filter and a separate `[7]` reaction filter. The policy relay stub answers only the multi-filter batches it recognises, so the new batch fell through to the sidebar-preferences branch and failed `toHaveLength(5)` in every journey that opens a thread (118 tests). The stub now recognises the batch: two filters on the same single `#e` target, each with `limit: 500`, the first carrying exactly kinds 5, 9005, 39005 and 40003 and the second only kind 7. The fixture's `answer()` serves each filter as before. Vitest flake check: "names the viewer and the members that fit a roster larger than the profile budget" took 5.2-5.45 s in 7 of 10 flake runs. About 2 s of it is opening the dialog and rendering 1,100 member rows, a fixed cost of the scale contract (the roster must pass the 1,024-id cap), not a race. It now has an explicit 15 s timeout, as App.profile.test.tsx and MessageMarkdown.test.tsx do for their own large renders. Tests (local): - Chromium and WebKit: right-pane-dismissal, message-navigation, inbox, navigation-thread-history, nested-replies, buzz-links, typing, message-actions, message-management, inbox-tabs, inbox-edit and presence specs, 207 of 208 passed in 8.4 min. The one failure is WebKit message-management.spec.mjs:361 (Delete dialog Close not focused), which also fails locally on main. - channel-opening.spec.mjs on chromium-measurements and webkit-measurements: passed. - The 1,100-member Vitest test passes alone. Not run: the full browser suite and the flake check's 10x repetition. Hosted CI will cover both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e
added a commit
that referenced
this pull request
Nov 2, 2026
Fixes the two P2 findings in kalvinnchau's review of PR #2114 (2026-11-02 03:58 UTC, comments 4438794439 and 4438794444). Review: discovery.ts preview eviction (b764b1b) - Real. `preview()` let eviction drop any nonmember metadata, including a private or hidden 39000. For a channel the viewer never joined, that version can be the only evidence of the restriction: there is no roster and no denied entry. Once it was evicted after a complete roster read, an older signed public 39000 passed `newer()` against missing metadata and `canAccess` turned true again. The client listed a now-private channel as a read-only public preview under its old name. The relay still enforces reads, so no private content showed. This broke relay-queries.md's "An older metadata replay cannot undo a newer private event". - Fix: `preview()` also requires `open(id)`, so only explicitly public nonmember metadata can give way. `headroom()` calls `preview()` and no longer counts restrictive entries as free. - Cost at capacity: restrictive previews are kept for the session. The Buzz relay hides private metadata from non-members, so in practice there are none. Against a relay that serves them, about 1,024 distinct restrictive previews would bring back main's refusal, which is never worse than main. - Docs: relay-queries.md says only a preview's public metadata gives way, and why private or hidden metadata is kept. channels.md lists private or hidden previews among what fills the metadata cap. Review: useSearchMessages.ts from:name namesakes (1608c3e48) - Real, and a regression of this branch. `scoped` matched only loaded names. When it found one member, `resolve` treated that member as unique, and the relay profile search ran only when `scoped` was empty. With more than 1,024 members (names load for the lowest 1,024 keys) or a failed batch (now caught), a namesake among the unloaded members was missed, and the search silently read the loaded member's messages. On main both cases threw, so the user saw an error, not a wrong answer. - Fix: the member names are partial when `ensure` rejects or a member is neither in the snapshot nor in `unnamed()`. `unnamed()` records members the relay answered without a kind 0, so profile-less bots and viewers do not count. This test also catches main's existing case, where the 2 MiB profile cache evicts early names during the same `ensure`. When partial, a loaded match no longer settles it: the existing relay name search runs too. Beside a loaded member match, its results count only if they are channel members, and they are merged with `scoped` before `resolve`. Two matches open the existing author picker, which already merges loaded members with the relay search. A full page of 40 still counts as ambiguous. A healthy roster takes the old path unchanged. - Residuals: in a large channel, a name with 40 or more relay matches always opens the picker. The relay does not fold accents, so an accented namesake past the cap is still missed, as on the existing relay branch. Performance: - Discovery: no change in reads. `preview()` adds one tag scan of the entry's metadata per check, in `headroom()` and eviction scans. - from:name with 1,024 or fewer members whose names all load: no added request. One `some` over the roster with two Set lookups per member. - Otherwise: one `searchProfiles` (limit 40) per debounced query, the same cost the empty-`scoped` path already paid. Membership is checked with `includes` for at most 40 candidates. Tests: - discovery-capacity.test.ts, private and hidden (12-14 ms each): with capacity 3 and a complete roster read, X goes public v10 to restrictive v20. `headroom()` excludes X, a new preview evicts the oldest public preview instead of X, a v10 replay leaves X inaccessible, and a newer public v30 regrants reading. Both fail on the previous code at `headroom()` (3, expected 2). Without that assertion they fail at eviction, because X is the entry that goes. - useSearchMessages.test.tsx, 1,100 members with one Alice key starting with `1` (named) and another starting with `f` (past the cap): - Namesakes on both sides: the far one is never requested, one profile search, `ambiguousAuthor`, no message read. Fails on the previous code, which resolves to the loaded Alice. - A named member and a namesake outside the channel: one search, resolves to the member. Fails on the previous code (no search), and fails if the member restriction is dropped (ambiguous). - Only the member past the cap is named: resolves to her after one search. This guard passes before and after, as it should: the fix must keep this path. - Small fully read roster with a profile-less member: no profile search. A control that passes before and after. It fails if partial detection ignores `unnamed()`. - "resolves from:name on a roster larger than the profile budget when some names fail": its mock now answers the `search` filter, because the failed batches send it to the relay search. - Summed time for useSearchMessages.test.tsx: 2.04 -> 3.09 s, 6 -> 10 tests, about 0.27 s per new test. Also run: tsc, lint, and the discovery-capacity, store-discovery, useSearchMessages, SearchResults and channel-references suites (5 files, 195 passed). Not exercised in the running app. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
kalvinnchau
reviewed
Nov 2, 2026
kalvinnchau
left a comment
Contributor
There was a problem hiding this comment.
🤖 Both previously reported P2s are repaired. One non-blocking common-name search regression remains.
matt2e
marked this pull request as draft
November 2, 2026 08:03
matt2e
added a commit
that referenced
this pull request
Nov 2, 2026
Fixes the P3 finding in kalvinnchau's review of PR #2114 (2026-11-02 06:26 UTC, comment 4439478824). Review: useSearchMessages.ts full-page check (c6bc521) - Real, reproduced. When member names were partial, a loaded match sent the search to the relay-wide name search, which returns at most 40 profiles from the whole relay. Matches were then limited to channel members, but the full-page check still counted all 40. With 1,100 members, one loaded Alice and 39 nonmember Alices, the search returned `ambiguousAuthor` and read no messages. Any common name opened the picker in a channel past the profile budget, even with one matching member. - Fix: beside a loaded match, the name search covers only the members whose names did not load, as a kind 0 NIP-50 search with `authors` set to those keys. The block/buzz relay passes `authors` into its full-text query, so other users' profiles never fill the page. The keys go in parts of 900, which fit the reader's 64 KiB filter budget (about 975 keys), as the tombstone read in threads.ts does. A member who matches the name makes the author ambiguous. A part that returns a full page of 40 still counts as ambiguous, because another match could be on the next page. - Not chosen: reading every unnamed member's kind 0. That brings back the >1,024-id cliff and the 2 MiB cache churn for very large channels. The search returns at most 40 profiles per part. - With no loaded match, the relay-wide search is main's again: matches are not limited to members, and `scoped` is not merged in. That undoes c6bc521's member filter there. `ensure`'s failure is caught as in CommunityAdmin, since the unnamed-member set already includes failed batches. - Residuals: the full-text search matches whole profile JSON, so 40 unnamed members whose `about` or `nip05` contains the name still open the picker. The relay does not fold accents, as on the relay-wide path. A typed name over about 5 KB would push a 900-key part past the filter budget, and the search would fail with an error. Request cost per debounced from:name query in a channel: - All member names loaded, or no loaded match: unchanged (no added read, or one relay-wide `searchProfiles` with limit 40, as on main). - Loaded match with U unnamed members: ceil(U / 900) kind 0 search reads with limit 40, sent in parallel, instead of one relay-wide search. For 1,100 members (76 unnamed) that is one read. A 10,000-member channel takes 10 reads. Each response holds at most 40 profiles. Tests (useSearchMessages.test.tsx, summed test time 3.21 -> 4.15 s, 10 -> 13 tests, local): - The crew mock now limits a search's results to its `authors`. - New row with the reviewer's probe: 1,100 members, one loaded Alice, 39 nonmember Alices (a full relay-wide page). It resolves to the member after one search over unnamed members only (0.33 s). On c6bc521 it returns `ambiguousAuthor` without reading messages. - Each row now asserts the search's scope: unnamed members, never the loaded match, beside a loaded match, and relay-wide otherwise. The namesakes-on-both-sides and outside-namesake rows fail on c6bc521 on this assertion. - "treats a full page of member name matches past the budget as ambiguous": 40 members past the budget named "Alice Smith" fill the page, so the author is ambiguous and no message is read (0.42 s). c6bc521 was also ambiguous here, but through the relay-wide search, so it fails only on the search-scope assertion. - "searches the members whose names did not load in parts that fit the filter budget": 1,925 roster keys leave 901 unnamed, searched in parts of 900 and 1. The namesake in either part makes it ambiguous (0.27 s). Fails on c6bc521, which sent one relay-wide search. Also run: tsc, lint, and the useSearchMessages and SearchResults suites (2 files, 108 passed). Not exercised in the running app or against a live relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e
added a commit
that referenced
this pull request
Nov 4, 2026
Fixes the browser-journey CI failure on PR #2114 (run 41377657528). 2d4007a split the exact-target overlay read into a safety-kind filter and a separate `[7]` reaction filter. The policy relay stub answers only the multi-filter batches it recognises, so the new batch fell through to the sidebar-preferences branch and failed `toHaveLength(5)` in every journey that opens a thread (118 tests). The stub now recognises the batch: two filters on the same single `#e` target, each with `limit: 500`, the first carrying exactly kinds 5, 9005, 39005 and 40003 and the second only kind 7. The fixture's `answer()` serves each filter as before. The 1,100-member Members dialog test this commit also timed was dropped in the rebase onto main (#2028 pages the dialog's name reads). Tests (local): - Chromium and WebKit: right-pane-dismissal, message-navigation, inbox, navigation-thread-history, nested-replies, buzz-links, typing, message-actions, message-management, inbox-tabs, inbox-edit and presence specs, 207 of 208 passed in 8.4 min. The one failure is WebKit message-management.spec.mjs:361 (Delete dialog Close not focused), which also fails locally on main. - channel-opening.spec.mjs on chromium-measurements and webkit-measurements: passed. Not run: the full browser suite. Hosted CI will cover it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e
added a commit
that referenced
this pull request
Nov 4, 2026
Fixes the second P2 finding in kalvinnchau's review of PR #2114 (2026-11-02 03:58 UTC, comment 4438794444). The first (discovery preview eviction keeping restrictive metadata) was dropped in the rebase onto main: #1574 replaced the 1,024-entry discovery cap with a 256 MiB byte budget, so there is no preview eviction to fix. Review: useSearchMessages.ts from:name namesakes (1608c3e48) - Real, and a regression of this branch. `scoped` matched only loaded names. When it found one member, `resolve` treated that member as unique, and the relay profile search ran only when `scoped` was empty. With more than 1,024 members (names load for the lowest 1,024 keys) or a failed batch (now caught), a namesake among the unloaded members was missed, and the search silently read the loaded member's messages. On main both cases threw, so the user saw an error, not a wrong answer. - Fix: the member names are partial when `ensure` rejects or a member is neither in the snapshot nor in `unnamed()`. `unnamed()` records members the relay answered without a kind 0, so profile-less bots and viewers do not count. This test also catches main's existing case, where the 2 MiB profile cache evicts early names during the same `ensure`. When partial, a loaded match no longer settles it: the existing relay name search runs too. Beside a loaded member match, its results count only if they are channel members, and they are merged with `scoped` before `resolve`. Two matches open the existing author picker, which already merges loaded members with the relay search. A full page of 40 still counts as ambiguous. A healthy roster takes the old path unchanged. - Residuals: in a large channel, a name with 40 or more relay matches always opens the picker. The relay does not fold accents, so an accented namesake past the cap is still missed, as on the existing relay branch. Performance: - from:name with 1,024 or fewer members whose names all load: no added request. One `some` over the roster with two Set lookups per member. - Otherwise: one `searchProfiles` (limit 40) per debounced query, the same cost the empty-`scoped` path already paid. Membership is checked with `includes` for at most 40 candidates. Tests: - useSearchMessages.test.tsx, 1,100 members with one Alice key starting with `1` (named) and another starting with `f` (past the cap): - Namesakes on both sides: the far one is never requested, one profile search, `ambiguousAuthor`, no message read. Fails on the previous code, which resolves to the loaded Alice. - A named member and a namesake outside the channel: one search, resolves to the member. Fails on the previous code (no search), and fails if the member restriction is dropped (ambiguous). - Only the member past the cap is named: resolves to her after one search. This guard passes before and after, as it should: the fix must keep this path. - Small fully read roster with a profile-less member: no profile search. A control that passes before and after. It fails if partial detection ignores `unnamed()`. - "resolves from:name on a roster larger than the profile budget when some names fail": its mock now answers the `search` filter, because the failed batches send it to the relay search. - Summed time for useSearchMessages.test.tsx: 2.04 -> 3.09 s, 6 -> 10 tests, about 0.27 s per new test. Also run: tsc, lint, and the useSearchMessages suite (10 passed). Not exercised in the running app. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e
added a commit
that referenced
this pull request
Nov 4, 2026
Fixes the P3 finding in kalvinnchau's review of PR #2114 (2026-11-02 06:26 UTC, comment 4439478824). Review: useSearchMessages.ts full-page check (c6bc521) - Real, reproduced. When member names were partial, a loaded match sent the search to the relay-wide name search, which returns at most 40 profiles from the whole relay. Matches were then limited to channel members, but the full-page check still counted all 40. With 1,100 members, one loaded Alice and 39 nonmember Alices, the search returned `ambiguousAuthor` and read no messages. Any common name opened the picker in a channel past the profile budget, even with one matching member. - Fix: beside a loaded match, the name search covers only the members whose names did not load, as a kind 0 NIP-50 search with `authors` set to those keys. The block/buzz relay passes `authors` into its full-text query, so other users' profiles never fill the page. The keys go in parts of 900, which fit the reader's 64 KiB filter budget (about 975 keys), as the tombstone read in threads.ts does. A member who matches the name makes the author ambiguous. A part that returns a full page of 40 still counts as ambiguous, because another match could be on the next page. - Not chosen: reading every unnamed member's kind 0. That brings back the >1,024-id cliff and the 2 MiB cache churn for very large channels. The search returns at most 40 profiles per part. - With no loaded match, the relay-wide search is main's again: matches are not limited to members, and `scoped` is not merged in. That undoes c6bc521's member filter there. `ensure`'s failure is caught as in CommunityAdmin, since the unnamed-member set already includes failed batches. - Residuals: the full-text search matches whole profile JSON, so 40 unnamed members whose `about` or `nip05` contains the name still open the picker. The relay does not fold accents, as on the relay-wide path. A typed name over about 5 KB would push a 900-key part past the filter budget, and the search would fail with an error. Request cost per debounced from:name query in a channel: - All member names loaded, or no loaded match: unchanged (no added read, or one relay-wide `searchProfiles` with limit 40, as on main). - Loaded match with U unnamed members: ceil(U / 900) kind 0 search reads with limit 40, sent in parallel, instead of one relay-wide search. For 1,100 members (76 unnamed) that is one read. A 10,000-member channel takes 10 reads. Each response holds at most 40 profiles. Tests (useSearchMessages.test.tsx, summed test time 3.21 -> 4.15 s, 10 -> 13 tests, local): - The crew mock now limits a search's results to its `authors`. - New row with the reviewer's probe: 1,100 members, one loaded Alice, 39 nonmember Alices (a full relay-wide page). It resolves to the member after one search over unnamed members only (0.33 s). On c6bc521 it returns `ambiguousAuthor` without reading messages. - Each row now asserts the search's scope: unnamed members, never the loaded match, beside a loaded match, and relay-wide otherwise. The namesakes-on-both-sides and outside-namesake rows fail on c6bc521 on this assertion. - "treats a full page of member name matches past the budget as ambiguous": 40 members past the budget named "Alice Smith" fill the page, so the author is ambiguous and no message is read (0.42 s). c6bc521 was also ambiguous here, but through the relay-wide search, so it fails only on the search-scope assertion. - "searches the members whose names did not load in parts that fit the filter budget": 1,925 roster keys leave 901 unnamed, searched in parts of 900 and 1. The namesake in either part makes it ambiguous (0.27 s). Fails on c6bc521, which sent one relay-wide search. Also run: tsc, lint, and the useSearchMessages and SearchResults suites (2 files, 108 passed). Not exercised in the running app or against a live relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Opening a message by exact id (Inbox row, search hit, reminder, deep link, message-link preview, media viewer) failed with "Selected message exceeded its evidence limit" once it had 500 reactions. Kind 7 shared the single safety-overlay filter, and the session's exact target reader rejects any capped raw response because a truncated read cannot prove the message was not edited or deleted. Reactions cannot hide or change the target, so the overlay request now reads them through a separate `[7]` filter, and the guard counts only non-reaction events. Safety kinds (5, 9005, 40003, 39005) and tombstone responses still fail closed at 500. A full reaction page is kept as a bounded supplement and marks the thread view `limited` instead of failing the target. Older reactions beyond that page are not read. The loadMore guard now relies on canLoadMore alone (limited already implied no continuation), so a capped reaction page does not block older replies. Request count and latency: unchanged for every message. Reactions ride as a second filter in the existing overlay request; the relay applies `limit` per filter. Messages with few overlays still cost the same target, overlay and (when overlays exist) tombstone round trips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…sters `profiles.ensure` threw "Profile request exceeds the directory budget" for more than 1,024 ids before reading anything. Community admin raised an unhandled rejection and its name search found nobody, and `from:name` search in a channel failed with the same message. (The Members dialog already pages its name reads, #2028.) The directory now names the first 1,024 ids of a larger request (its cache's entry capacity) and leaves the rest unrequested. Fetching more would only evict names the same request had just loaded. Members beyond the cap keep their public-key label, and no notice is shown, because Try again cannot name them. The over-budget request still reaches verified-profile lookup only for the ids that fit (#2220). Two related partial-failure fixes in the same path: - A batch whose response exceeds the reader's 8 MiB budget retries from the same author in halves, like AgentBrowse's catalog pages, instead of loading zero names. A single author still over the budget fails after one read per halving (at most 10 reads for 500 authors). A foreground promotion rejoins whichever part is in flight. - `ensure` reports a failed batch only after its other batches settle, so a caller that tolerates the failure reads every name that loaded. Callers: Community admin catches the optional name read. Message search `from:` proceeds on a partial directory, as the author picker in SearchResults already does. An unnamed member falls through to the relay name search. Not changed: touch-on-read (`get()` for `event()`/`owner()`). Displayed names come from the folded snapshot, not the LRU, so promoting there would not make eviction follow what is on screen. Both functions are also `useSyncExternalStore` snapshot getters (useAgentOwnerEvidence, useProfileOwner) and run inside projection folds, so `get()` would make pure reads reorder the cache. A use-based policy belongs in ensure() with its own eviction tests. Performance: unchanged for requests of up to 1,024 ids whose batches fit the read budget. Request count, sizes and success latency are the same, plus one array slice. A request above 1,024 ids now costs at most three reads (500/500/24) instead of failing, and asking again with those names cached reads nothing. When a batch fails, ensure now rejects once its slowest batch settles rather than at the first failure. The large-roster `from:` test takes about 1 s locally, which this scale contract needs. Docs: docs/channels.md now says a request above the 1,024-entry budget names those that fit instead of failing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to the capacity-cliff review of 2026-10-29 12:02 UTC. The discovery eviction and Members dialog parts of that review were dropped in the rebase onto main: #1574 replaced discovery's 1,024-entry cap with a 256 MiB byte budget and pins the shortened-id fallback, and #2028 pages the dialog's name reads. Review: threads.ts `reactionsCapped` - Dropped it. A full 500-reaction page forced `limited: true`, which ThreadPanel and MediaReviewViewer show as "Thread history limit reached." even while older replies still load. The target still opens, its 500 reactions are a lower bound, and live reactions keep arriving. relay-queries.md no longer says the view is marked `limited`. Review: halving gaps (a) and (b) - Each part is accepted as it lands. A transient failure on part k retries from part k at the halved size instead of from author 0. Kept because the code is no larger than before (the result array and `.then` go away) and is safe: - retryRead runs attempts one at a time with the caller's signal, so two attempts never share the hoisted cursor. - A part that lands after an abort is dropped, as before. - A promotion still rejoins `batch.reading`. Deliberately not changed: - Warm-cache eviction. ByteLru evicts in insertion order and nothing touches an entry on use. A request that loads enough new names therefore evicts the viewer's own profile and on-screen names, and so does a long session once it has seen 1,024 profiles. This predates the branch at every request size. The fix is a use-based policy in ensure(), or reading only the rows that render, as a follow-up. - Halving is still unmeasured against a real 8 MiB response. Performance: - Profile batches that fit the read budget are unchanged: one accept per batch. A halved batch publishes once per part rather than once per batch, so earlier parts' names show one read sooner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up covering the review of 2026-10-29 12:53 UTC. The discovery eviction, channel-link pin and Members dialog parts were dropped in the rebase onto main (#1574 replaced the 1,024-entry discovery cap with a 256 MiB byte budget; #2028 pages the dialog's name reads). Review: profile halving - Dropped. The 2 MiB profile cache keeps only about a quarter of an 8 MiB response, so the parts that landed evicted each other, and the case was never measured. Removed the hoisted cursor, the `ReadError` match and their two tests. The batch read is main's again. - Kept: the 1,024-id cap, the Community admin and `from:` search catches, and settling every batch before a failure is reported. - Docs: channels.md says a batch whose response exceeds the 8 MiB read budget (for example heavy legacy inline avatars) still loads no names, and that reading only rendered rows is a follow-up. Review: threads.ts loadMore guard - Reverted to `!snapshot.canLoadMore || snapshot.limited`. With `reactionsCapped` gone it is equivalent, and the thread diff is now only the separate reaction filter. Tombstone read over the request budget: fixed in code - The target's tombstone read sent every overlay id in one `#e` list. The reader rejects filters over 64 KiB (about 975 ids) before sending, so a target with more overlay ids failed to open. One target read can return 499 safety overlays plus 500 reactions, and `remote` can hold more. Carrying only this response's ids would still exceed the budget. It would also stop re-reading tombstones for overlays already in `remote`. So the read now goes in parts of 900 ids: a `for` loop and a slice in place of the `if`, with no new state. Each part keeps the 500-event fail-closed check. Up to 900 ids it is one request, as before. - Test: the full-reaction-page target test now also carries 480 edits (980 overlay ids). It asserts two tombstone requests of 900 and 80 ids covering every overlay, and the newest edit as the content. Before, the reader rejected the request locally and no tombstone query was sent. Performance: - No read is added anywhere that worked before. Tombstone parts only apply above 900 overlay ids, which used to fail. - Profile batches are back to one accept per batch. - thread-target.test.ts signs its 500 reactions and 480 edits once in beforeAll (about 1.45 s); before, 500 were signed inside the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to 042c06e06, covering the review of 2026-10-29 13:23 UTC.
Review: useSearchMessages.ts rethrow after an abort
- Applied. `ensure(...).catch(() => {})`, as in CommunityAdmin. The
next line's `throwIfAborted()` already throws once the search is
aborted, and the outer `.catch` ignores errors after an abort.
Review: BuzzLinkPreview.test.tsx 500-reaction test
- Dropped. thread-target.test.ts's "opens a target with a full reaction
page and hundreds of edits" covers the same real-session reader guard
and split reaction filter. The mocked-session test covers
BuzzLinkPreview's `session.thread(channel, id, { exact: true })`
wiring. The file is back to main's.
Review: channel-reference.ts `reference.state` dependency
- Kept. The reviewer's trace holds for revocation and cache clear:
leaving a private channel bumps `accessRevision`, `applyDiscovery`
revokes access, and `purgeAccess` calls `resetReferences`, which
looks up every demanded link again. The requested test (a mounted,
found link whose private channel the viewer leaves gets looked up
again) passes with and without the dependency.
- A found answer can still be lost without a reset. A public channel
the viewer has left still reads `found` through its open metadata.
If a newer 39000 makes it private, `accept` sees no access change,
because access was already lost. Nothing resets, and the answer goes
from `found` to `unknown`. Without the dependency the hook never
refers again. No lookup is sent, and the link keeps showing its raw
label instead of "Private channel" until it remounts or a later reset.
Main's hook re-referred on that change, so dropping the dependency
would regress it.
- Tests: both are added to channel-references.test.ts.
- "the viewer leaves its private channel" pins the reset path.
- "a public channel the viewer left turns private" fails without the
dependency: no lookup read is sent. It passes with it and with
main's hook.
Performance: no production behavior changes. The search catch behaves
the same.
Tests (summed test time, 042c06e06 -> this commit, local):
- BuzzLinkPreview.test.tsx: 1.91 -> 0.06 s, 9 -> 8 tests
- channel-references.test.ts: 2.84 -> 2.99 s, 14 -> 16 tests; the new
tests take about 0.12 s each
Also run: tsc, lint, and the channel-references, BuzzLinkPreview,
useSearchMessages, channel-reference, MessageLink and thread-target
suites (7 files, 91 passed). Not exercised in the running app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Fixes the browser-journey CI failure on PR #2114 (run 41377657528). 2d4007a split the exact-target overlay read into a safety-kind filter and a separate `[7]` reaction filter. The policy relay stub answers only the multi-filter batches it recognises, so the new batch fell through to the sidebar-preferences branch and failed `toHaveLength(5)` in every journey that opens a thread (118 tests). The stub now recognises the batch: two filters on the same single `#e` target, each with `limit: 500`, the first carrying exactly kinds 5, 9005, 39005 and 40003 and the second only kind 7. The fixture's `answer()` serves each filter as before. The 1,100-member Members dialog test this commit also timed was dropped in the rebase onto main (#2028 pages the dialog's name reads). Tests (local): - Chromium and WebKit: right-pane-dismissal, message-navigation, inbox, navigation-thread-history, nested-replies, buzz-links, typing, message-actions, message-management, inbox-tabs, inbox-edit and presence specs, 207 of 208 passed in 8.4 min. The one failure is WebKit message-management.spec.mjs:361 (Delete dialog Close not focused), which also fails locally on main. - channel-opening.spec.mjs on chromium-measurements and webkit-measurements: passed. Not run: the full browser suite. Hosted CI will cover it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Fixes the second P2 finding in kalvinnchau's review of PR #2114 (2026-11-02 03:58 UTC, comment 4438794444). The first (discovery preview eviction keeping restrictive metadata) was dropped in the rebase onto main: #1574 replaced the 1,024-entry discovery cap with a 256 MiB byte budget, so there is no preview eviction to fix. Review: useSearchMessages.ts from:name namesakes (1608c3e48) - Real, and a regression of this branch. `scoped` matched only loaded names. When it found one member, `resolve` treated that member as unique, and the relay profile search ran only when `scoped` was empty. With more than 1,024 members (names load for the lowest 1,024 keys) or a failed batch (now caught), a namesake among the unloaded members was missed, and the search silently read the loaded member's messages. On main both cases threw, so the user saw an error, not a wrong answer. - Fix: the member names are partial when `ensure` rejects or a member is neither in the snapshot nor in `unnamed()`. `unnamed()` records members the relay answered without a kind 0, so profile-less bots and viewers do not count. This test also catches main's existing case, where the 2 MiB profile cache evicts early names during the same `ensure`. When partial, a loaded match no longer settles it: the existing relay name search runs too. Beside a loaded member match, its results count only if they are channel members, and they are merged with `scoped` before `resolve`. Two matches open the existing author picker, which already merges loaded members with the relay search. A full page of 40 still counts as ambiguous. A healthy roster takes the old path unchanged. - Residuals: in a large channel, a name with 40 or more relay matches always opens the picker. The relay does not fold accents, so an accented namesake past the cap is still missed, as on the existing relay branch. Performance: - from:name with 1,024 or fewer members whose names all load: no added request. One `some` over the roster with two Set lookups per member. - Otherwise: one `searchProfiles` (limit 40) per debounced query, the same cost the empty-`scoped` path already paid. Membership is checked with `includes` for at most 40 candidates. Tests: - useSearchMessages.test.tsx, 1,100 members with one Alice key starting with `1` (named) and another starting with `f` (past the cap): - Namesakes on both sides: the far one is never requested, one profile search, `ambiguousAuthor`, no message read. Fails on the previous code, which resolves to the loaded Alice. - A named member and a namesake outside the channel: one search, resolves to the member. Fails on the previous code (no search), and fails if the member restriction is dropped (ambiguous). - Only the member past the cap is named: resolves to her after one search. This guard passes before and after, as it should: the fix must keep this path. - Small fully read roster with a profile-less member: no profile search. A control that passes before and after. It fails if partial detection ignores `unnamed()`. - "resolves from:name on a roster larger than the profile budget when some names fail": its mock now answers the `search` filter, because the failed batches send it to the relay search. - Summed time for useSearchMessages.test.tsx: 2.04 -> 3.09 s, 6 -> 10 tests, about 0.27 s per new test. Also run: tsc, lint, and the useSearchMessages suite (10 passed). Not exercised in the running app. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Fixes the P3 finding in kalvinnchau's review of PR #2114 (2026-11-02 06:26 UTC, comment 4439478824). Review: useSearchMessages.ts full-page check (c6bc521) - Real, reproduced. When member names were partial, a loaded match sent the search to the relay-wide name search, which returns at most 40 profiles from the whole relay. Matches were then limited to channel members, but the full-page check still counted all 40. With 1,100 members, one loaded Alice and 39 nonmember Alices, the search returned `ambiguousAuthor` and read no messages. Any common name opened the picker in a channel past the profile budget, even with one matching member. - Fix: beside a loaded match, the name search covers only the members whose names did not load, as a kind 0 NIP-50 search with `authors` set to those keys. The block/buzz relay passes `authors` into its full-text query, so other users' profiles never fill the page. The keys go in parts of 900, which fit the reader's 64 KiB filter budget (about 975 keys), as the tombstone read in threads.ts does. A member who matches the name makes the author ambiguous. A part that returns a full page of 40 still counts as ambiguous, because another match could be on the next page. - Not chosen: reading every unnamed member's kind 0. That brings back the >1,024-id cliff and the 2 MiB cache churn for very large channels. The search returns at most 40 profiles per part. - With no loaded match, the relay-wide search is main's again: matches are not limited to members, and `scoped` is not merged in. That undoes c6bc521's member filter there. `ensure`'s failure is caught as in CommunityAdmin, since the unnamed-member set already includes failed batches. - Residuals: the full-text search matches whole profile JSON, so 40 unnamed members whose `about` or `nip05` contains the name still open the picker. The relay does not fold accents, as on the relay-wide path. A typed name over about 5 KB would push a 900-key part past the filter budget, and the search would fail with an error. Request cost per debounced from:name query in a channel: - All member names loaded, or no loaded match: unchanged (no added read, or one relay-wide `searchProfiles` with limit 40, as on main). - Loaded match with U unnamed members: ceil(U / 900) kind 0 search reads with limit 40, sent in parallel, instead of one relay-wide search. For 1,100 members (76 unnamed) that is one read. A 10,000-member channel takes 10 reads. Each response holds at most 40 profiles. Tests (useSearchMessages.test.tsx, summed test time 3.21 -> 4.15 s, 10 -> 13 tests, local): - The crew mock now limits a search's results to its `authors`. - New row with the reviewer's probe: 1,100 members, one loaded Alice, 39 nonmember Alices (a full relay-wide page). It resolves to the member after one search over unnamed members only (0.33 s). On c6bc521 it returns `ambiguousAuthor` without reading messages. - Each row now asserts the search's scope: unnamed members, never the loaded match, beside a loaded match, and relay-wide otherwise. The namesakes-on-both-sides and outside-namesake rows fail on c6bc521 on this assertion. - "treats a full page of member name matches past the budget as ambiguous": 40 members past the budget named "Alice Smith" fill the page, so the author is ambiguous and no message is read (0.42 s). c6bc521 was also ambiguous here, but through the relay-wide search, so it fails only on the search-scope assertion. - "searches the members whose names did not load in parts that fit the filter budget": 1,925 roster keys leave 901 unnamed, searched in parts of 900 and 1. The namesake in either part makes it ambiguous (0.27 s). Fails on c6bc521, which sent one relay-wide search. Also run: tsc, lint, and the useSearchMessages and SearchResults suites (2 files, 108 passed). Not exercised in the running app or against a live relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two relay-client limits failed outright once a community passed them. Each now returns what fits.
profiles.ensureasked for more than 1,024 idsfrom:search in a channel failed with the same message.from:namechecks the relay for namesakes past the cap instead of silently picking the loaded one.Each failure was recorded in the running app on main, and each works on this branch (videos below). Benchmarks show no measurable cost on paths that already worked.
Size: non-test source +68/−15 across 5 files. Docs +9/−1, tests +513/−20.
Rebase onto main (2026-11-04)
Main moved under this PR, and two of its changes removed the ground this branch stood on. The rebase dropped the affected parts rather than carrying them:
discovery.tseviction, thepinned/holdshooks instore.ts,session.tsandchannel-reference.ts,discovery-capacity.test.ts, the eviction tests instore-discovery.test.tsandchannel-references.test.ts, and the recheck-scan commit that only tuned the pin.from:search.verified-profile.test.tsexpected a 1,025-id request to be rejected before verification (fix(relay): reject over-budget ids before verified lookup and evict in one pass #2220); it now checks that only the first 1,024 ids reach verification.What changed
(a) Large rosters:
profiles.ensureprofile-directory.ts: a request above 1,024 ids (the cache's entry capacity) keeps the first 1,024 and leaves the rest unrequested instead of throwing. Fetching more would only evict names the same request had just loaded. No notice is shown for the rest, because Try again cannot name them. The verified-profile lookup gets the same sliced list, so an over-budget request never floods it (fix(relay): reject over-budget ids before verified lookup and evict in one pass #2220).ensurereports a failed batch only after its sibling batches settle, so a caller that tolerates the failure sees every name that loaded.CommunityAdmin.tsxcatches the optional name read.useSearchMessages.tsletsfrom:proceed on a partial directory, as the author picker in SearchResults already does. When the member names are partial (a member past the cap or in a failed batch is neither loaded nor known unnamed), a loaded match no longer settles it: the relay name search runs too, its results count only if they are channel members, and two matches open the existing author picker. A healthy roster takes the old path unchanged.(b) Exact open of heavily reacted messages
threads.ts: reactions move to their own[7]filter in the same overlay request. The relay applieslimitper filter, so the request count is unchanged. Reactions cannot hide or change the target, so the exact-target guard insession.tscounts only non-reaction events. Safety kinds (5, 9005, 40003, 39005) and tombstone responses still fail closed at 500.#elist above about 975 ids exceeds the reader's 64 KiB filter budget, and a target with a full reaction page plus a few hundred edits reaches that. Up to 900 ids it is one request, as before.tests/browser/policy-relay.mjsrecognises the two-filter overlay batch so browser journeys that open a thread keep passing.Docs:
docs/channels.mdcovers the roster cap and the 8 MiB batch limit.docs/relay-queries.mdcovers the separate reaction filter.Commits (messages cite pre-rebase SHAs)
reactionsCappedfrom:namechecks namesakes past the capfrom:namelooks for namesakes among unnamed members onlyEvidence: before (main) vs after (branch)
Recorded in headless Chromium against a local
buzz-relay(block/buzz 680b0a6b2) with seeded fake identities, before the rebase. The viewer is "Alex Chen". No production relay or credentials were used. Before is a3a6223; after is 98253fc4b. Each take starts from a fresh copy of the seeded database.(a) Members dialog, 1,100 members
Recorded before #2028. It shows the directory cliff through the dialog, the most visible caller at the time; on current main the dialog pages its reads and the remaining callers are Community admin and
from:search.a-members-dialog-before-after.mp4
(b) Message with 500 reactions, opened from Inbox
Main fails with the evidence-limit error. The branch opens the message with its reactions.
b-500-reactions-before-after.mp4
Relay request log for the overlay read:
[39005,40003,5,7,9005],limit 500. It returned 500 events, hit the cap and failed.[39005,40003,5,9005]and[7]. They returned 500 events, all reactions, and the message opened.Review history
limited, so popular messages said "Thread history limit reached" while older replies still loadedreactionsCappeddropped#elists above about 975 ids exceed the 64 KiB filter budgetuseSearchMessagesfrom:namewith more than 1,024 members resolved to the loaded namesake and silently read the wrong author's messagesPerformance
Measured in Node against the unit-test fakes with the production event verifier, before the rebase. Client times only.
profiles.ensure, 1,024 idsprofiles.ensure, 1,100 or 2,500 idsfrom:name, roster past the cap or a failed batchsearchProfiles(limit 40) per debounced query, the cost the empty-match path already paidKnown limits
ByteLrunever touches an entry on use. With a warm cache, a large request can evict the viewer's or on-screen names until those rows read again. Main already did this for requests of 600 to 1,024 ids.from:namein a large channel: a name with 40 or more relay matches always opens the picker, and the relay does not fold accents, so an accented namesake past the cap is still missed.#elists. A rejection fails closed, as before.Testing
tsc, Biome on the touched files, and Vitest on every test file this branch touches plusverified-profile,store-discoveryandchannel-references: 159 files, 6,013 passed, 3 skipped, 1 failed. The failure (ChannelMembersDialog.test.tsx"expands local and relay groups…", 6.5 s) is in a file this branch no longer changes and passes alone (96/96); it timed out under the parallel load.src/features/relayand the suites of every changed consumer: 141 files, 2,725 passed, 4 skipped.--no-verify. The pre-push related-tests hook times out under load on this machine.thread-target.test.ts: a target with a full reaction page and 480 edits opens, with tombstone parts of 900 and 80 ids.profile-details.test.ts: names the ids that fit; a failed batch is reported after the others land.verified-profile.test.ts: only the first 1,024 ids reach verification.CommunityAdmin.test.tsx,useSearchMessages.test.tsx(large-rosterfrom:, namesakes on both sides of the cap, a namesake outside the channel, a fully read roster with no relay search).🤖 Generated with Claude Code