Skip to content

feat(loadgen): model desktop clients and large-community shape - #9005

Merged
TheSentinel454 merged 4 commits into
mainfrom
elrond/loadgen-desktop-shape
Nov 3, 2026
Merged

TheSentinel454 merged 4 commits into
mainfrom
elrond/loadgen-desktop-shape

Conversation

@TheSentinel454

@TheSentinel454 TheSentinel454 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The load generator models every community as many small open channels, and every human as a bare WebSocket client: four REQs, a little history, then nothing until the run ends. Large deployments don't look like that. They have thousands of people in a few communities, a handful of very wide channels, and some people in hundreds of channels. Their desktop clients also make most of their relay requests over HTTP and on timers. This PR adds opt-in scenario knobs for both, so a run can reproduce that shape and compare the desktop client before and after its recent request-volume fixes.

Every new key defaults to the previous behavior. Existing scenarios parse, assign members and run exactly as before.

Shape

Key Default Meaning
shape.private_channels false seed creates channels private and adds every member with kind:9000 before posting history. Members are the humans and agents assigned to each channel up to the largest ramp step, plus the hot thread's root and authors. Events carry up to 100 members each and run seed_concurrency at a time. A rerun adds nobody new, and any member the relay refuses fails the seed.
shape.wide_channels 0 The first K channels of each community are wide channels.
population.wide_member_fraction 0.0 Share of humans that join every wide channel on top of their own.
population.wide_activity_share unset Share of its typing, messages and reactions a member of the wide channels sends there; the rest goes to its regular channels. Unset keeps the uniform pick, which sends half of a 3-wide/3-regular member's traffic to the wide channels and makes fan-out, not reads, the bottleneck.
population.tiers [] [{ fraction, channels }]: that share of humans joins channels channels instead of channels_per_member.

Assignment is deterministic by global index, so seed and every --shard agree without coordination. With no wide channels and no tiers it is the old assignment, and a test pins that.

Desktop clients ([desktop])

Key Default Meaning
fraction 0.0 Share of humans that run the desktop model instead of the basic human.
mode "current" legacy is the client before its volume fixes; current is after them.
windows, threads 3, 1 Open channel windows and threads, which are refreshed on connect.
tick_secs 60 Recovery tick period.
backstop_secs 900 current only: each tick item (subscription replacement, roster, unread repair) waits this long since it last ran.
renewal_secs 0 Identity renewal period. 0 disables renewal, and the first renewal is jittered.

A desktop human keeps the basic human's presence, typing, messages and reactions. What it adds:

  • Cold start. It reads NIP-11 and opens its profile and membership subscriptions. Its channel subscriptions are batched per mode:

    • up to 10 channels: one replaying batch;
    • legacy with more than 10: one per-channel batch per 10 channels;
    • current with more than 10: a live-only subscription per 2048 channels, plus replays of 10 that are closed at EOSE.

    It also makes the app's HTTP reads: roster, channel names, sidebar, profiles, statuses, emoji, read state, unread repair, reminders, window catch-up plus notices for each window, and root plus thread page for each thread. A window catch-up follows the cursor in the relay's window bounds (kind 39006) for up to five older pages, until the bounds say history is exhausted.

  • Route establishment. legacy makes every read as it connects. current reads NIP-11, names and sidebar up front, and the rest as each subscription reaches EOSE, by its replay outcome. A replay of 500 events, a missed deadline, or the live-only batch over 10 channels is a cut, and so is a route the previous connection never caught up. Only an EOSE catches a route up: one recovered after a missed deadline cannot be resumed or carried by the next connection.

    • A continuous profile route skips its read (unless one is owed).
    • A continuous membership route only repairs unreads, without the read-state snapshot, unless a snapshot read failed since; then it runs the full repair.
    • A cut global reads in full, and channel routes always recover windows and threads.
  • Ticks and renewals. These follow each mode:

    • legacy: every tick replaces subscriptions and rereads roster and unreads, and a renewal is a full reconnect;
    • current: tick items wait for the backstop, measured from the last replacement, the last successful roster read, and the last full unread repair. A failed roster read, unread repair or read-state snapshot is owed and retries on the next tick; snapshot debt is tracked apart from repair debt, so a repair without the snapshot never settles it. A renewal carries its routes: nothing is read while every replay keeps continuity, and once one is cut every route establishes as on a reconnect. With more than 10 channels the live-only batch is always cut, so the channels recover and unreads are repaired, but globals are not reread.
  • Reporting. Each request type is its own op (desktop_roster, desktop_unread, desktop_replay_eose, …).

  • Bounds. At most 4 REQs set up at once, with a 10 s EOSE timeout. A tick replacement that misses it is closed, the batch it would have replaced stays, and the next tick retries. At most 6 HTTP reads in flight, with a 30 s timeout. A read that comes due while the same read is running or queued is not added again, and each read leaves the running set as soon as it settles, so a failed read can retry while a slow one is still under way. Connects and failed sessions back off. A stop abandons pending EOSE waits without counting them as failures.

  • Not modeled. The app also establishes channel routes when a tick replacement reaches EOSE; the generator does not.

perf/LOADGEN.md documents the knobs and the model. perf/scenarios/local-desktop.toml is a short local example with every knob on.

What got simpler

  • human()'s schedule loop (presence, typing, messages, reactions, session end) moved into one Activity. The basic and desktop humans share it, so neither client duplicates the schedule. The basic human's random draw order is unchanged.
  • Membership stays in one place. member_channels remains the single assignment function, and channel_members derives from it rather than from a second formula, so seeding and the clients can't drift apart.

The desktop model is a new concept: about 1,420 lines of model in its own module, plus about 1,300 lines of tests. Everything else goes through existing seams: Stats/Op, connect, Backoff, observe, roots and Net.

Identified debt, not fixed here: CI's test-unit runs buzz-test-client --lib with the filter loadgen::, so the five crate-root tests:: in lib.rs (relay message parsing, auth and text event building, collect_until_eose) never run in CI. That gap predates this PR, which doesn't touch lib.rs.

Testing

  • cargo fmt --check, cargo clippy -p buzz-test-client --all-targets -- -D warnings, and cargo test -p buzz-test-client (plain parallel): 84 lib tests pass.

  • Wide activity: a seeded Rng over 100k picks gives each share within 1%, and an unset share makes the identical draws to before. A run against a loopback relay checks that a human's typing lands only in its regular channels at share 0, and only in its wide channels at share 1.

  • Scenario: validation of every new key; deterministic wide-channel and tier assignment that honors the fractions; channel_members inverts the assignment up to the peak; the default assignment is unchanged; the shipped scenarios parse.

  • Seed: with private channels, a fake relay sees the following, in order:

    • every 9007 before any 9000, and every 9000 before any post;
    • owner signatures;
    • ≤100 members per event;
    • exactly the expected members per channel;
    • posts authored by channel members.

    A refused member fails the seed, and open channels add nobody.

  • Desktop: request planning, window bounds validation, route establishment, and tick and owed-read rules are unit-tested. End-to-end tests drive clients against loopback WS and HTTP fakes. The WS fake replays a planned number of events per subscription and can delay an EOSE or drop a connection. The HTTP fake answers window pages with relay-signed bounds, an older aux event and equal-timestamp rows, refuses unknown cursors, and can fail a read once. They check:

    • exact cold-start counts per op for legacy and current at 5 and 25 channels;
    • a window catch-up sends the head, then exactly the bounds' cursor (until + before_id of the last row, not the older aux event), and stops when the bounds say history is exhausted even though that page came back full;
    • after a current renewal, 499 replayed events everywhere read nothing over HTTP; 500 on the channel batch adds channel recovery plus a covered unread repair; 500 on membership adds roster, read state, unread and reminders plus channel recovery; 500 on profiles adds profiles, statuses and emoji plus channel recovery and a covered repair;
    • after a dropped connection, complete global replays read only NIP-11, channel recovery and a covered repair; truncated ones reread both globals in full;
    • with renewals shorter than the backstop, read-state snapshots still recur at the backstop;
    • a tick replacement past its EOSE deadline is closed, the old batch stays, the next tick replaces it, and one batch wire stays open;
    • a failed roster and unread read retry on the next tick, and successful reads do not run again;
    • a failed roster read retries on the next tick while an unrelated slow read is still running;
    • after a failed snapshot, a continuous membership establishment before any tick still reads the snapshot;
    • a route whose EOSE missed its deadline is recovered, and after a renewal with a complete replay it is recovered in full again rather than carried;
    • legacy ticks replace batches (new REQ before the CLOSE of the old one) and reread;
    • current ticks do nothing until the backstop, then replace live-only;
    • legacy renewals are full reconnects;
    • current renewals carry small sessions without rereading globals or channels, and recover channels in large ones.

    Each of the nine review-fix tests was checked against a mutant that restores the old behavior, and every mutant fails its test.

  • Smoke: a local relay with Postgres and Redis. I ran seed twice, then run of local-desktop.toml (60 s) once per mode.

    • Both seeds were accepted (780 of 780 posts each), including private channels and memberships for 340 people. Neither run aborted, no client was cancelled, and no op recorded an error.

    • Each run used 18 desktop clients:

      op current legacy
      desktop_live_eose 266 483
      desktop_replay_eose 24 0
      desktop_roster 72 289
      desktop_read_state / desktop_unread 72 / 99 289 / 289
      desktop_profiles 18 73
      desktop_nip11 18 73
      desktop_window 372 596

      Window reads are below the first smoke (406 and 1086) because catch-ups stop where the relay's bounds say history is exhausted, rather than paging while pages came back full. They are above the previous revision (179 and 470) because each read now leaves the running set as soon as it settles, so a channel recovery is no longer dropped as a duplicate of a read that already finished inside a still-running batch.

    • The first smoke caught two relay contract mismatches that the fakes had missed:

      • top-level window pages need before_id with until;
      • the read-state snapshot answers with an object.

      Both are fixed, and the fakes now enforce both.

🤖 Generated with Claude Code

TheSentinel454 and others added 4 commits November 3, 2026 12:13
Add opt-in scenario knobs for communities shaped like a large deployment
and for desktop app clients:

- shape.private_channels: seed creates channels private and adds every
  member with kind:9000, seed_concurrency at a time, idempotently.
- shape.wide_channels + population.wide_member_fraction: the first K
  channels of each community are joined by a fraction of humans.
- population.tiers: fractions of humans in more channels than
  channels_per_member.
- [desktop]: a fraction of humans run the desktop app's subscriptions
  and HTTP reads, in legacy (before the client volume fixes) or current
  mode, with cold start, recovery ticks, backstops and identity
  renewals; each request type reports as its own op.

Every knob defaults to the previous behavior and assignment.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
…the relay

- population.wide_activity_share: optional share of a wide-channel
  member's typing, messages and reactions that goes to the wide
  channels; unset keeps the uniform pick and its exact draws.
- Desktop older pages send until with before_id, which top-level
  window pages require, and the read-state snapshot's object response
  decodes. Both failed against a local relay; the test fakes now
  enforce the same contract.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
…ow bounds

Review fixes for the desktop client model:

- Window catch-ups follow the cursor in the relay's kind 39006 window
  bounds, validated as the app does, instead of guessing from page length
  and the oldest event (which an older aux event skews).
- Each route's replay is counted to its EOSE; 500 events, a missed
  deadline, or a live-only batch over ten channels is a cut. `current`
  reads follow each route's establishment: a continuous global skips its
  full read, membership then only repairs unreads, and channel routes
  recover windows and threads. A renewal carries routes until one is cut.
- Only a full unread repair (snapshot plus repair) restarts its backstop.
- A tick replacement that misses its deadline is closed, keeps the batch
  it would have replaced, and the next tick retries.
- Read outcomes are kept: a failed roster or unread read is owed and
  retries on the next tick; the roster clock advances only on success.
  The tick clock runs across connections, and the read task drains its
  own queue so reads that come due mid-round start without waiting for
  the socket.

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imed-out routes

Re-review fixes for the desktop client model:

- The read-state snapshot is owed apart from the unread repair. A covered
  repair no longer settles it: a continuous membership route with a
  snapshot owed runs the full repair, and the tick retries it.
- Each HTTP read leaves the running set as soon as it settles, and the
  read task starts queued reads as slots free (NIP-11 still first and
  alone). A failed read's retry no longer waits for an unrelated slow one.
- A live route that misses its EOSE deadline is still recovered as cut,
  but is not caught up: the next connection cannot resume or carry it.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
@TheSentinel454
TheSentinel454 marked this pull request as ready for review November 3, 2026 18:20
@TheSentinel454
TheSentinel454 requested a review from a team as a code owner November 3, 2026 18:20

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Approving the server-side review. All new scenario keys are opt-in and leave the default assignment and the basic human's random draw order unchanged. Private-channel seeding creates channels, then adds members, then posts, with at most 100 members per add, and it fails the seed if any member is refused. Desktop clients stay bounded: 4 pending REQs, 6 HTTP reads in flight, deduplicated reads, and jittered backoff. The window-bounds cursor, until + before_id and read-state snapshot requests match the relay handlers.

One non-blocking nit: DesktopClient::thread (desktop.rs:902) finds roots through the shared roots() helper. That request gets Net's 60 s client timeout, not the 30 s bound that perf/LOADGEN.md gives for desktop HTTP reads. Either apply the 30 s bound there too or note the exception in the doc.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Blocking: four IMPORTANT findings need fixes before this merges. Reviewed at head 15135c9d: two independent source reviews, a check of the desktop model against block/buzz-app, and a live run against a local relay.

The large-community shape work is solid. With none of the new keys set, member assignment, the basic human's random draw order and the open-channel seed are unchanged. The private-channel seed reads the relay's kind:9000 receipt correctly, and it is idempotent: against a real relay, a second seed added nobody (340 already members) and left every membership row unchanged. The lib suite passes (84/84). local-desktop.toml ran clean in both modes, with current against legacy at roster 72 vs 289, unread 99 vs 286, profiles 18 vs 73 and NIP-11 18 vs 73. local-smoke.toml, which sets none of the new keys, also seeded and ran clean. The current model matches the app on every threshold we checked: 2048-channel live batches, 10-channel replays, the 500-event cut, the 60 s tick, the 900 s backstop, 4 REQs set up at once, the 10 s EOSE deadline, the 960 s resume lag and the 300 s startup floor.

The blocking findings are in the desktop client's lifecycle and in the legacy model.

IMPORTANT

  1. Shutdown can detach the HTTP read worker, and reads aren't abandoned at stop (desktop.rs:994-997, 1036-1040, 1140). Fetcher::finish() takes the JoinHandle out of Fetcher before awaiting it. If a read outlasts the 15 s SHUTDOWN_GRACE, shutdown() aborts the client during that await, which drops the handle and detaches the worker. Fetcher::drop then sees None, so its abort never runs. The worker can outlive run(), and its in-flight ops miss the final report instead of settling as unfinished at stop. Separately, reads don't stop at stop: one can take up to HTTP_TIMEOUT (30 s), longer than the grace period, and any cancelled client fails the whole run (run.rs:286-287). So a run whose last step was healthy can be reported failed because one read was slow. EOSE waits are already abandoned at stop; reads should be too. Fix: race each read against stopping() and forget() its pending op, keep abort ownership of the worker through the join, and make sure it has finished before the report is taken. A test could hold one desktop read past a shortened grace and check that no worker survives and that the op settles.

  2. Thread discovery bypasses the reader's bounds (desktop.rs:897-903 → run.rs:524-535). Reader::thread() calls the shared roots() directly, outside query_with(). An uncached lookup therefore runs under reqwest's 60 s timeout rather than the desktop's 30 s, skips the stop check (a Thread still queued at stop can start a fresh request), and doesn't count against the 6-read limit. Activity::step() can also call roots() for a reply while six reads are already running, which makes a seventh request. Fix: route desktop root discovery, including the reply path, through the reader's stop, deadline and concurrency limits, and keep the basic human's behavior as it is.

  3. An EOSE received on time can be reported as a timeout (desktop.rs:1247-1257, 1361-1375). EOSE deadlines are checked only in the socket loop, and that loop awaits Activity::step(). A publish there waits for its OK, and meanwhile NostrWsConnection::wait_for_ok buffers every other message, EOSEs included (buzz-ws-client/src/connection.rs:276-318). When the step returns, the loop expires waiting REQs before it reads the buffered EOSEs. A REQ answered inside its 10 s deadline is then recorded as EOSE timeout, closed if it was a replay or a replacement, and recovered as cut. The result is false subscription errors and extra recovery reads exactly when publish acknowledgements slow down, which is the condition the generator is meant to measure. Fix: drain buffered messages before checking deadlines, or keep each EOSE's receive time so one that arrived on time can't expire. Add a case where the EOSE arrives promptly while a publish OK is delayed past the REQ deadline.

  4. legacy leaves out the per-channel routes the app used to open (desktop.rs batch_reqs). Before the volume fixes, the app gave every open (priority) channel and every non-joined interest its own channel:<id> route outside the 10-channel batches (block/buzz-app b5f9bf5de^:src/features/relay/live.ts:705-708, the parent of block/buzz-app#1574). Legacy also re-sent every route every tick. The model's legacy cold start for 25 channels is batches only (10, 10, 5), so it undercounts legacy REQs by about windows per connect and per tick. That is significant at small channel counts (5 channels: about 4 REQs per minute, not 1) and small at large ones. It understates the legacy-vs-current gap, which is the comparison this PR exists to make. Today's app doesn't route priority channels separately (live.ts:729-731), so current is right.

MINOR

  1. No presence subscription in either mode. The app opens a live-only presence route ({"kinds":[20001],"authors":[…]}, up to 256 authors) whenever it watches anyone's presence, both before and after the fixes (live.ts:734-739, 1109; b5f9bf5de^:live.ts:635-639). The desktop model publishes presence but subscribes to nobody's (global_reqs, desktop.rs:124), while the basic human it replaces subscribes to 10 peers (run.rs:564-610). Turning humans into desktop clients therefore removes presence fan-out from the load.
  2. No silence probe. After a tick with nothing received, the app sends REQ probe {"kinds":[44100],"#p":[viewer],"limit":0} under the 10 s deadline (live.ts:624-640, also present before the fixes). Idle clients in a quiet community miss up to one REQ per minute in both modes.
  3. The read limits don't come from the app. There is no 6-wide read limiter in block/buzz-app's relay code (the cap is probably the browser's per-host connection limit), and the app's own read timeouts are 10 s or 20 s per read (session.ts:1640, 1663-1767; unread.ts:1532, 1679, 1785), not a flat 30 s. Worth matching, or noting in perf/LOADGEN.md as a simplification. This only matters when the relay is slow.
  4. Abandoned subscription attempts disappear from the report on renewals (desktop.rs:1411-1413). Every session end calls pending.forget() on REQs still waiting, not just a stop. With a renewal period shorter than replay completion, those attempts never show up as successes, errors or pending. Planned cancellation shouldn't count as a relay error, but an explicit abandoned count would keep it visible.
  5. The seed log counts people named, not people added (seed.rs:46-49, 284-287). A rerun prints "memberships added (340 people)" even though the relay added nobody. "memberships ensured" would be accurate.

@TheSentinel454
TheSentinel454 merged commit 152441a into main Nov 3, 2026
102 of 104 checks passed
@TheSentinel454
TheSentinel454 deleted the elrond/loadgen-desktop-shape branch November 3, 2026 20:12
TheSentinel454 added a commit that referenced this pull request Nov 4, 2026
…resence and probes (#9016)

## Summary

Follow-up to #9005, addressing the review findings that merged
unaddressed. None of them affect a relay. `buzz-ws-client` gains one
method. Each one skews what the desktop client measures when the relay
is slow, which is the case the load generator exists for.

- **Reads stop at the stop.** A desktop HTTP read (including NIP-11) now
races the run's stop and is abandoned there without being recorded, as
EOSE waits already were. Before, a read that outlived the 15 s shutdown
grace got its client cancelled and failed an otherwise healthy run.
`Fetcher::finish` also keeps the read task's handle until the task ends,
so a client cancelled during the join still aborts the task instead of
detaching it.
- **Root lookups are bounded.** The desktop's seeded-root lookups, for
thread recovery and replies, now run under the desktop's 30 s read
timeout and stop, not reqwest's 60 s. `roots()` takes an optional bound,
and the basic human passes none. If the stop cuts a reply's lookup
short, the reply isn't published. That applies to the basic human too: a
reply whose root lookup finishes after the stop is dropped.
- **An EOSE that arrives in time stays in time.** While a publish waits
for its OK, `NostrWsConnection` buffers every other frame. The session
loop used to judge EOSE deadlines before reading that buffer, and read
one frame per pass. It now handles every frame already in hand (at most
256 per pass) before judging deadlines. Each frame is judged by when it
arrived, not when it is read. `NostrWsConnection` keeps each buffered
frame's arrival time, and the new `next_event_received` returns it. So
an EOSE that arrived after its deadline while an OK wait held it counts
as a timeout, and so does a frame that answers the probe late. Past that
cap, a pass defers due deadlines, for at most one timeout from the first
pass that defers them. While it is deferring, the pass only drains, so
another OK wait can't expire an EOSE that's already in hand, and
sustained traffic can't postpone a missing EOSE forever.
- **Presence and the silence probe.** Both modes now open the app's
live-only `presence` route
(`{"kinds":[20001],"authors":[…],"since":…,"limit":0}`, up to 256
co-members) alongside the profile and membership routes. As in the app,
presence takes no part in establishment: it isn't resumed or carried,
and if it fails it doesn't cut a renewal's carry for the other routes.
After a tick with no frame since the previous one, they send the app's
probe, `REQ probe-N {"kinds":[44100],"#p":[viewer],"limit":0}`, which
shows up as `desktop_probe` and is closed at its EOSE. Only frames
actually received count, including a publish's OK when it arrives before
the probe's deadline. A reaction with no target or a publish whose OK
timed out doesn't count. Expiry is judged after the drain against the
probe still outstanding. If no frame arrives within the EOSE deadline,
the session ends as failed and the client reconnects with backoff.
Before this, desktop runs left out presence fan-out and idle-client
probes, so they understated load.
- **Seed log.** "memberships added (N people)" now reads "memberships
ensured", since a rerun adds nobody.
- **Docs.** `perf/LOADGEN.md` notes that the six-read cap and flat 30 s
timeout are simplifications (the app has neither), and that `legacy` is
a lower bound. The pre-fix app also opened a per-channel route for each
open or non-member channel and re-sent every route each tick, and the
model leaves both out.

Not done: an explicit count of REQs abandoned at a renewal (report
polish; planned cancellations still aren't errors).

### What got simpler

Reads abandoned at the stop now go through one helper, `run::bounded`,
which the desktop reads and the root lookup share. Before, each read had
its own timeout and its own stop check. `global_reqs` returns the routes
the client actually opens, not a fixed pair. The probe and presence
routes add one `Route` variant, one op and about 40 lines of session
code. `Route::establishes` is now the one place that says which routes
take part in establishment, replacing the two `Replay` special cases.
"Heard" used to be inferred by comparing activity schedules before and
after each step. It now comes from frames only, so `publish` reports
whether the relay answered and that comparison is gone.

## Testing

On a Blox workstation, at the tree of this PR's head:
- `cargo fmt --all -- --check`, `cargo clippy -p buzz-test-client -p
buzz-ws-client -p buzz-cli --all-targets -- -D warnings`
- `cargo test -p buzz-test-client --lib` (parallel) and `cargo test -p
buzz-ws-client`, plus the `loadgen::desktop` tests five more times

New tests, each confirmed to fail when its fix is reverted:
- `a_read_in_flight_at_the_stop_is_abandoned_not_waited_for`: an 8 s
roster read at the stop. Without the stop race the client outlives
`drive`'s 5 s stop limit.
- `a_cancelled_finish_still_aborts_the_read_task`: aborting `finish()`
mid-join ends the read task. With the old `take()`, the task is
detached.
- `an_eose_in_time_is_not_expired_behind_a_slow_ok`: EOSEs at 1.2 s,
publish OKs at 3.5 s, deadline 2.5 s. Without the drain, all four routes
time out.
- `a_silent_tick_probes_and_an_unanswered_probe_reconnects`: the first
connection never answers its probe, so the client reconnects and its
next probe is answered.
- `a_probe_answered_behind_a_late_ok_does_not_reconnect`: the probe's
EOSE arrives in time, but it sits behind a heartbeat whose OK comes
after the probe's deadline. With expiry judged against a deadline
captured before the drain, the session fails and reconnects.
- `a_publish_ok_in_time_answers_an_outstanding_probe`: no probe EOSE
ever arrives, and heartbeat OKs answer each probe.
- `a_reaction_with_no_target_is_not_heard_as_a_frame`: targetless
reactions several times a second still leave the tick silent, so it
probes.
- `a_reply_whose_root_lookup_the_stop_cut_short_is_not_published`: the
stop cuts an 8 s root search short, and OKs are withheld. No `kind 9`
EVENT reaches the relay, and the client returns promptly.
- `a_backlog_defers_deadlines_for_at_most_one_timeout`: the deferral
bound and its reset.
- `a_long_backlog_behind_slow_oks_keeps_its_timely_eoses`: 600 replayed
events per REQ behind 3.5 s OKs, with a 1.5 s deadline and heartbeats
every second. The test fails if the deferral is timed from the REQ
instead of from the first pass that defers, and it fails if a second OK
wait can run between the deferred passes.
- `presence_is_a_live_only_global_only_while_someone_is_watched`: the
route's order and its live-only filter in both modes, and that it's
absent with nobody watched.
- `a_late_eose_is_not_answered_behind_a_slow_ok`: the timely case above
with EOSEs at 3 s instead, so all four routes time out, not succeed.
- `a_probe_answered_late_behind_a_slow_ok_reconnects`: a probe's EOSE
arrives 0.2 s after its deadline while a heartbeat waits for its OK, so
the session fails and reconnects. The test also checks that a heartbeat
followed that probe on the same connection, so the test can't pass on a
probe that simply went unanswered.
- `an_answered_probe_is_closed_at_its_eose`: `CLOSE probe-1` goes out
before any next probe.
- `routes_establish_once_and_a_renewal_holds_them_until_a_cut` gains a
case: a renewal where presence times out keeps the other routes carried
and establishes nothing.

Each new test fails when its fix is reverted (seven mutants in the first
round and six in the review round, run on Blox). The test harness now
has a tick override that can go below the scenario's 1 s floor, and
records EVENT frames by kind. The fake relay sends a REQ's replayed
events after the REQ's delay, not before, and the fake API's slow reads
sleep in `block_in_place`. No existing test changed behavior.

Existing expectations moved for the presence route (one more live EOSE
per client) and for reads now abandoned at the stop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

No deployments
codex-review — 15135c9d Deployed Nov 3, 2026 by TheSentinel454 via Run Codex Security Review #12195
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.

2 participants