Repository navigation
fix(relay): coalesce verified profile publishes to one per macrotask - #2216
Conversation
each verified-profile change called publish() synchronously from the relay socket callback, and every publish refolds the whole profile directory and notifies every listener. a burst of kind 30382 frames (large roster, member dialog, reconnect) ran those refolds back to back without yielding, starving socket frames, input and paint for tens of seconds. verified changes now schedule one publish per macrotask; kind 0 accept and clear stay synchronous. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]>
verified.dispose() clears the verified directory, which schedules a flush; clearing the timer afterwards cancels it instead of relying on the closed guard. Co-Authored-By: Claude Opus 5.5 <[email protected]>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account.
No actionable findings at head c35e9bd32759510714f3ac20ae4f5f18d410f8ce, base 1abd828add3c28b09fcbd260e7f80d4f7007174e. Reviewed the three-file PR patch from merge-base 0cdafb11270071e56b0d58546a421bd392dc5b3e and its callers at the head; the broader base-to-head diff includes base-branch drift.
Rocket, Mantis and Groot independently checked timer/performance lifecycle, reentrant clear/revocation and snapshot correctness, and profile consumers/error/retry behavior. The flush folds current verified state rather than a captured payload; kind-0 acceptance and full cache clear retain their synchronous notification boundaries. The closed guard prevents deferred publication after disposal.
Optional follow-up: cover pending-flush clear/disposal in the existing tests. Idle disposal can schedule one inert timer through verified.dispose() after cancellation; checking closed before arming would avoid that, but no user-impacting failure was found.
Source-only review: no tests, builds, app workflows or benchmarks were run, and CI was not assessed. The added test demonstrates a synchronous burst, not actual socket-frame grouping, paint fairness or bounded timer latency. Desktop performance and live member-list behavior remain unverified. This is a COMMENT review, not approval.
…2235) ## Problem Every profile-directory `publish()` asks the verified directory for a snapshot. That re-projects every verified head with `verifiedProfile()`, which re-reads its tags and builds a fresh object. The publish then compares each new projection with the old one using `JSON.stringify`. With 1,024 verified subjects, a single kind-0 update costs 6–8× as much as the same update with no verified overlay. ## Change - The verified directory keeps the last projection of each head event in a `WeakMap`, keyed by the event and checked against the authority and the checked flag. An unchanged head returns the same frozen object. - The profile-directory comparison checks identity before falling back to `JSON.stringify`. ## Evidence Blox matrix: a profile directory with N kind-0 profiles, optionally overlaid with N verified heads, receiving 300 single kind-0 updates. Base and variant runs were interleaved, 7 runs on each of 2 Linux and 2 macOS workstations. Values are medians of 14 per OS. | ms per kind-0 publish | Linux before | Linux after | Linux Δ | macOS before | macOS after | macOS Δ | |---|---|---|---|---|---|---| | 1,024 verified | 3.191 | 0.804 | −75% | 1.769 | 0.482 | −73% | | 512 verified | 1.604 | 0.372 | −77% | 0.873 | 0.240 | −73% | | 1,024 kind-0 only | 0.400 | 0.335 | −16% | 0.270 | 0.233 | −14% | After a discovery refresh (every 5 minutes while profiles are wanted). Second Blox matrix on the revised patch, with base and variant runs interleaved: 4 runs on each of 2 Linux and 2 macOS workstations. Values are medians of 8 per OS. "First revision" is this PR before the equal-refresh fix. | 1,024 verified | Linux main | Linux first revision | Linux this PR | Linux Δ vs main | macOS main | macOS first revision | macOS this PR | macOS Δ vs main | |---|---|---|---|---|---|---|---|---| | ms per kind-0 publish, no refresh | 3.204 | 0.814 | 0.803 | −75% | 1.931 | 0.519 | 0.517 | −73% | | ms per kind-0 publish after one equal refresh | 3.241 | 1.376 | 0.806 | −75% | 1.950 | 0.879 | 0.521 | −73% | | CPU ms for 5 equal refreshes | 25 | 27 | 8 | −68% | 16.5 | 17 | 5.5 | −67% | What you gain: on a NIP-FI community, every profile change, and every other republish such as a live verified change, costs about a quarter of what it did. An equal periodic discovery refresh keeps the current authority object, so the projections and the identity fast path survive it. The Blox runs used the first revision of this patch. Its projection logic was inline instead of in the `project()` helper, and its behavior was the same. - `verified-profile.test.ts`: "reuses an unchanged head's projection across snapshots" fails on `main`. "keeps an unchanged head's projection through an equal discovery refresh" fails without the refresh change. - `vitest run` on `src/features/relay`, `src/features/profiles` and `src/features/messages`: 220 files and 5,001 tests pass, 4 skipped. `tsc --noEmit` and biome are clean. ## Related #2216 changes how often verified changes publish. This PR changes what each publish costs, so they compose. Same probe on top of #2216, 4 runs on each of 4 Linux and 4 macOS workstations. Values are medians of 16 per OS. | On top of #2216 | Linux #2216 | Linux #2216 + this | Linux Δ | macOS #2216 | macOS #2216 + this | macOS Δ | |---|---|---|---|---|---|---| | ms per kind-0 publish, 1,024 verified | 3.227 | 0.851 | −74% | 1.802 | 0.509 | −72% | | CPU ms for a 1,024-event issuer republish | 373.5 | 144 | −61% | 241.5 | 109 | −55% | ## Tradeoff The cache holds one small record per retained head event. It is a `WeakMap`, so an evicted head's entry goes with it. A changed authority (pubkey or `multipleIssuers`) or a checked-status flip re-projects the head; an equal refresh keeps the existing authority object. The change introduces no regressions or behavior changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <[email protected]> Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
loganj
left a comment
There was a problem hiding this comment.
🤖 Approving for @loganj after a native before/after run (debug desktop, FI, #4things members dialog, one run per build).
base 0cdafb1 |
head aded36f |
|
|---|---|---|
| Stalls ≥50 ms after connect (count / total) | 124 / 24.0 s | 141 / 21.8 s |
| Longest stall | 1.97 s | 0.90 s |
| Verified-name changes → publishes | 248 → 248 | 253 → 172 |
| All publishes (count / time) | 304 / 6.0 s | 212 / 3.7 s |
| Freeze or "Relay read timed out" | no | no |
Base did not reproduce the 65 s freeze, so that freeze is already gone on main (#2159, #2209). This PR is a measured improvement on top: about a third fewer verified publishes and a shorter longest stall. The remaining main-thread time is mostly signature verification (verify.read, 13–16 s per session). Counters were local-only instrumentation, not part of this PR.
…#2234) ## Problem A native NIP-FI socket renews its assertion every ~285 s: the lifetime is capped at 300 s, minus a 15 s margin. `live.ts` reconnects quietly and the connection stays reported `connected`, but `clearSocket()` ends every sent sync with `disconnected: live connection lost`. Each verified-profile (kind 30382) batch treated that as a failure: - it cleared `checked` for its subjects; - it republished the whole profile directory, and every name flipped from checked to cached; - the next `ensure` re-sent the batch, and its replay republished the directory again at EOSE, even though nothing had changed. With 1,024 verified subjects that is 8 batches. Each renewal therefore caused 16 full republishes and 2,048 changed entries, and every checked name dropped to cached for a moment. ## Change - A batch that ends with `disconnected:` is re-sent at once with its checked status kept, as a rebuilt batch already is. The decision waits one microtask. An outage reports the connection down in the same task, which stops every batch first, so an outage still clears status and sends nothing extra. - EOSE notifies only when it changed a head, a checked status or a retraction. A replay that confirms what is shown is quiet. ## Evidence Blox matrix: the real `createRelaySession` with the profile and verified directories over a scripted live transport, 1,024 verified subjects. Each renewal ends every sync as `clearSocket()` does, while states stay `connected`. Base and variant runs were interleaved, 5 runs on each of 2 Linux and 2 macOS workstations. Values are medians of 10 per OS. | Metric (5 renewals, ≈24 min of uptime) | Linux before | Linux after | Linux Δ | macOS before | macOS after | macOS Δ | |---|---|---|---|---|---|---| | CPU (ms) | 327.5 | 22 | −93% | 194 | 27 | −86% | | Profile notifications | 80 | 0 | −100% | 80 | 0 | −100% | | Entries changed | 10,240 | 0 | −100% | 10,240 | 0 | −100% | | Names checked after | 1,024 | 1,024 | 0 | 1,024 | 1,024 | 0 | | Verified REQs / events | 40 / 5,120 | 40 / 5,120 | 0 | 40 / 5,120 | 40 / 5,120 | 0 | What you gain: every listener stops refolding on every renewal, about 12.6 times an hour on a NIP-FI relay, and verified names no longer flicker from checked to cached. Wire traffic is unchanged. The Blox runs predate the one-microtask deferral. Locally, the same harness gives an identical renewal result (0 notifications, 1,024 checked). An outage scenario keeps `main`'s 40 REQs and clears status as before. - `verified-profile.test.ts`, new: "re-sends a batch a replaced socket ended and keeps its check quiet while connected" fails on `main`. "does not re-send a batch an outage ended" pins the outage path. - `vitest run` on `src/features/relay`, `src/features/profiles` and `src/features/messages`: 220 files and 5,002 tests pass, 4 skipped. `tsc --noEmit` and biome are clean. ## Related #2216 makes verified publishes one per macrotask. That reduces how many republishes a renewal causes, but each ended batch still drops its names to cached until the re-sent batch answers. This PR keeps them checked and keeps a confirming replay quiet, so the two compose. Same Blox harness, stacked on #2216, interleaved, 4 runs on each of 4 Linux and 4 macOS workstations. Values are medians of 16 per OS. | 5 renewals @1,024 verified | Linux #2216 | Linux #2216 + this | Linux Δ | macOS #2216 | macOS #2216 + this | macOS Δ | |---|---|---|---|---|---|---| | CPU (ms) | 74.5 | 21 | −72% | 64 | 27.5 | −57% | | Profile notifications | 10 | 0 | −100% | 10 | 0 | −100% | | Checked↔cached flips | 10,240 | 0 | −100% | 10,240 | 0 | −100% | | Verified REQs | 40 | 40 | 0 | 40 | 40 | 0 | Outage (5 disconnect/reconnect cycles) on #2216 with and without this change: 10 notifications and 40 REQs in both, CPU 78 vs 78 ms on Linux and 132.5 vs 131.5 ms on macOS. The outage path is unchanged. ## Tradeoff - The directory infers a renewal from the documented `disconnected:` reason and from the connection still being up after the current task. It does not get an explicit signal from `live.ts`, which keeps the change to one file. - During an outage, batches now end through `connected(false)` alone. That is one profile notification per outage instead of one per batch plus one. The end state is unchanged. - A relay CLOSED whose reason starts with `disconnected:` is now reported as `error: relay closed the sync`, so only local socket loss takes the renewal path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <[email protected]> Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
…lock#2216) ## Problem Opening the members list of a ~1,175-member channel froze the desktop app for minutes. Client metrics from one click recorded main-thread long tasks of 65.5 s, 34.2 s, 15.8 s and 13.6 s in a row: 206 s of long tasks in a 6.5 min session, with 605,689 `fold` calls. The socket starved in the meantime. A foreground read timed out after 23.9 s, the live connection was declared unresponsive and reconnected, and the dialog showed "Relay read timed out" / "The member list could not load". The trace shows 25 full 128-subject kind 30382 batches (1,290 events). Every verified-profile change called `publish()` synchronously inside the socket callback. Each `publish()` refolds every profile, runs the namesake scan and notifies every listener (views then re-render), and nothing yields between frames. block#2209 cut replay notifications to one per batch EOSE. Those were still synchronous, as were every live change, disconnect, reschedule and failure. ## Change `profile-directory.ts`: the verified directory's change callback now schedules one `publish()` per macrotask (`setTimeout`) instead of calling it inline. Changes within one task (a disconnect fan-out, a reschedule, one relay frame), plus frames already queued before the timer fires, coalesce into one publish. This does not strictly cap publishes across separate frames: `live.ts` delivers each WS frame as its own task, so frames arriving after the timer fires get their own publish. `dispose()` cancels a pending publish. Not changed: kind 0 `accept()` and `clear()` still publish synchronously. `session.ts` and `store.ts` treat a profile notification as a synchronous boundary (subscribers can revoke or clear the session inside it), and those paths are already one publish per read or batch. Behavior change: verified overlays reach `snapshot()` one macrotask after the relay frame instead of inside it. Hidden or occluded webviews throttle `setTimeout`, so while the app is backgrounded, verified changes (retractions, the switch to "cached" on disconnect) reach `snapshot()` later. Only readers outside the rendered UI, such as notifications, would see that delay. ## Related - block#2159 makes each publish's namesake scan linear. This PR limits how often publish runs; block#2159 limits what each run costs. - block#2028 limits the members dialog's name reads to the visible page. ## Validation - New test `coalesces a burst of verified changes into one directory notification`: fails on main, passes here. - Verified-profile and ProfilePanel tests now await the macrotask before reading verified state. - Full Vitest run locally: every file that failed under machine load (load average ~30) passed on an isolated rerun. The pre-push hook timed out on two unrelated UI tests (`App.profile`, `ProfileSettings`, 5 s timeouts); both files pass when run alone (49/49). - Not yet run in the desktop app. ## Open before merge - The build that produced the trace above is not recorded, and block#2159 and block#2209 are already in base, so this PR has not yet been shown to fix the freeze. Needed: open the ~1,175-member members dialog with client metrics on base and on head, and record total and max long-task time, `fold` and publish counts, and whether "Relay read timed out" recurs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
…block#2234) ## Problem A native NIP-FI socket renews its assertion every ~285 s: the lifetime is capped at 300 s, minus a 15 s margin. `live.ts` reconnects quietly and the connection stays reported `connected`, but `clearSocket()` ends every sent sync with `disconnected: live connection lost`. Each verified-profile (kind 30382) batch treated that as a failure: - it cleared `checked` for its subjects; - it republished the whole profile directory, and every name flipped from checked to cached; - the next `ensure` re-sent the batch, and its replay republished the directory again at EOSE, even though nothing had changed. With 1,024 verified subjects that is 8 batches. Each renewal therefore caused 16 full republishes and 2,048 changed entries, and every checked name dropped to cached for a moment. ## Change - A batch that ends with `disconnected:` is re-sent at once with its checked status kept, as a rebuilt batch already is. The decision waits one microtask. An outage reports the connection down in the same task, which stops every batch first, so an outage still clears status and sends nothing extra. - EOSE notifies only when it changed a head, a checked status or a retraction. A replay that confirms what is shown is quiet. ## Evidence Blox matrix: the real `createRelaySession` with the profile and verified directories over a scripted live transport, 1,024 verified subjects. Each renewal ends every sync as `clearSocket()` does, while states stay `connected`. Base and variant runs were interleaved, 5 runs on each of 2 Linux and 2 macOS workstations. Values are medians of 10 per OS. | Metric (5 renewals, ≈24 min of uptime) | Linux before | Linux after | Linux Δ | macOS before | macOS after | macOS Δ | |---|---|---|---|---|---|---| | CPU (ms) | 327.5 | 22 | −93% | 194 | 27 | −86% | | Profile notifications | 80 | 0 | −100% | 80 | 0 | −100% | | Entries changed | 10,240 | 0 | −100% | 10,240 | 0 | −100% | | Names checked after | 1,024 | 1,024 | 0 | 1,024 | 1,024 | 0 | | Verified REQs / events | 40 / 5,120 | 40 / 5,120 | 0 | 40 / 5,120 | 40 / 5,120 | 0 | What you gain: every listener stops refolding on every renewal, about 12.6 times an hour on a NIP-FI relay, and verified names no longer flicker from checked to cached. Wire traffic is unchanged. The Blox runs predate the one-microtask deferral. Locally, the same harness gives an identical renewal result (0 notifications, 1,024 checked). An outage scenario keeps `main`'s 40 REQs and clears status as before. - `verified-profile.test.ts`, new: "re-sends a batch a replaced socket ended and keeps its check quiet while connected" fails on `main`. "does not re-send a batch an outage ended" pins the outage path. - `vitest run` on `src/features/relay`, `src/features/profiles` and `src/features/messages`: 220 files and 5,002 tests pass, 4 skipped. `tsc --noEmit` and biome are clean. ## Related block#2216 makes verified publishes one per macrotask. That reduces how many republishes a renewal causes, but each ended batch still drops its names to cached until the re-sent batch answers. This PR keeps them checked and keeps a confirming replay quiet, so the two compose. Same Blox harness, stacked on block#2216, interleaved, 4 runs on each of 4 Linux and 4 macOS workstations. Values are medians of 16 per OS. | 5 renewals @1,024 verified | Linux block#2216 | Linux block#2216 + this | Linux Δ | macOS block#2216 | macOS block#2216 + this | macOS Δ | |---|---|---|---|---|---|---| | CPU (ms) | 74.5 | 21 | −72% | 64 | 27.5 | −57% | | Profile notifications | 10 | 0 | −100% | 10 | 0 | −100% | | Checked↔cached flips | 10,240 | 0 | −100% | 10,240 | 0 | −100% | | Verified REQs | 40 | 40 | 0 | 40 | 40 | 0 | Outage (5 disconnect/reconnect cycles) on block#2216 with and without this change: 10 notifications and 40 REQs in both, CPU 78 vs 78 ms on Linux and 132.5 vs 131.5 ms on macOS. The outage path is unchanged. ## Tradeoff - The directory infers a renewal from the documented `disconnected:` reason and from the connection still being up after the current task. It does not get an explicit signal from `live.ts`, which keeps the change to one file. - During an outage, batches now end through `connected(false)` alone. That is one profile notification per outage instead of one per batch plus one. The end state is unchanged. - A relay CLOSED whose reason starts with `disconnected:` is now reported as `error: relay closed the sync`, so only local socket loss takes the renewal path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <[email protected]> Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
Problem
Opening the members list of a ~1,175-member channel froze the desktop app for minutes. Client metrics from one click recorded main-thread long tasks of 65.5 s, 34.2 s, 15.8 s and 13.6 s in a row: 206 s of long tasks in a 6.5 min session, with 605,689
foldcalls. The socket starved in the meantime. A foreground read timed out after 23.9 s, the live connection was declared unresponsive and reconnected, and the dialog showed "Relay read timed out" / "The member list could not load".The trace shows 25 full 128-subject kind 30382 batches (1,290 events). Every verified-profile change called
publish()synchronously inside the socket callback. Eachpublish()refolds every profile, runs the namesake scan and notifies every listener (views then re-render), and nothing yields between frames.#2209 cut replay notifications to one per batch EOSE. Those were still synchronous, as were every live change, disconnect, reschedule and failure.
Change
profile-directory.ts: the verified directory's change callback now schedules onepublish()per macrotask (setTimeout) instead of calling it inline. Changes within one task (a disconnect fan-out, a reschedule, one relay frame), plus frames already queued before the timer fires, coalesce into one publish. This does not strictly cap publishes across separate frames:live.tsdelivers each WS frame as its own task, so frames arriving after the timer fires get their own publish.dispose()cancels a pending publish.Not changed: kind 0
accept()andclear()still publish synchronously.session.tsandstore.tstreat a profile notification as a synchronous boundary (subscribers can revoke or clear the session inside it), and those paths are already one publish per read or batch.Behavior change: verified overlays reach
snapshot()one macrotask after the relay frame instead of inside it. Hidden or occluded webviews throttlesetTimeout, so while the app is backgrounded, verified changes (retractions, the switch to "cached" on disconnect) reachsnapshot()later. Only readers outside the rendered UI, such as notifications, would see that delay.Related
Validation
coalesces a burst of verified changes into one directory notification: fails on main, passes here.App.profile,ProfileSettings, 5 s timeouts); both files pass when run alone (49/49).Open before merge
foldand publish counts, and whether "Relay read timed out" recurs.🤖 Generated with Claude Code