Files
buzz/crates/buzz-ws-client
Luke Tornquist 25abfc4117 fix(loadgen): bound desktop reads at stop, keep timely EOSEs, model presence 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>
2026-11-04 12:57:38 -05:00
..