Commit Graph

13 Commits

Author SHA1 Message Date
Luke Tornquist
e670b672b8 perf(loadgen): spread seed writes across channel creators (#9206)
## What

`buzz-loadgen seed` signed every channel create, member add, and thread
root with the community owner. In private channels without human
members, the owner also signed all the history. The relay limits HTTP
calls per pubkey (default 300 a minute), so a community with thousands
of channels can't be seeded against a relay that keeps its default
limits. The seeder doesn't retry a 429, so the seed fails partway, and a
torn-down `run_id` can't be reused.

Take one community with 20,000 private channels: the owner alone signs
more than 40,000 writes, which is over two hours of its quota. Human
authors had the same problem on a smaller scale. A human in hundreds of
consecutive private channels signed hundreds of history posts within
seconds.

Now the community owner creates its first 8 channels, and each further
block of 8 gets its own creator identity (`Role::Creator`, derived from
`run_id`). A creator signs its channels' kind:9007 and kind:9000 events
and their thread roots, and `run` looks roots up by the creator. That
keeps every creator well under one minute of quota, whatever the
community's channel count.

History and replies still rotate across humans: a private channel's own
human members, or its creator when it has none. Because only humans look
roots up, the creator signs nothing but roots in their channels. No
amount of history or replies can push a root out of `run`'s newest-100
lookup.

Human signers can still bunch up, for example a human in hundreds of
consecutive channels or a deep thread. To cover that, a seed write the
relay throttles now waits out the `retry in <n>s` window the relay names
and tries again, up to 10 times, before it counts as failed.

Compatibility: a community with up to 8 channels is seeded as before,
apart from the retries. An existing fixture with that shape keeps
working with `run`, including its root lookups.

## What got simpler

Nothing got simpler. This adds a creator role and a retry loop, which is
the smallest change that makes large fixtures seedable against default
limits.

## Validation

- `cargo fmt --check`, `cargo clippy -p buzz-test-client --all-targets
-D warnings`, and `cargo test -p buzz-test-client --lib loadgen`, run on
a Linux workstation at the head commit.
-
`no_channel_creator_signs_more_seed_writes_than_one_minute_of_its_quota`
seeds a private community of 400 channels against a fake relay. The
community has wide channels, a tier of humans in 280 channels each, and
runs at 2x. The test requires every creator to sign fewer than 300
writes. With the owner signing everything, it fails: the owner signs all
7,212 writes.
-
`throttled_writes_wait_out_the_quota_and_a_lasting_quota_fails_the_seed`:
when every write gets two 429s it is retried and the seed passes. When
the quota never clears, each write is tried 11 times and the seed fails
with the quota error.
- `private_channels_add_every_member_before_anything_is_posted` now uses
20 channels. It checks that every create, member add and root is signed
by its channel's creator, that the owner keeps channels 0–7, and that
every other post is by a human member.
- `root_lookups_search_the_channel_creators_posts` checks that `run`
searches for roots by the owner in a community's first block and by the
block's creator after it, across two communities.

🤖 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-06 19:48:24 +00:00
Luke Tornquist
3fdd8c6ff1 feat(loadgen): model agent turns and sustained holds (#9182)
## Why

The loadgen agent polled HTTP `/query` every few seconds. A real agent
harness (`buzz-acp`) never polls when idle. It works in bursts around
each prompt, and those bursts are the load a fleet of active agents puts
on the relay. To answer "how well does the relay handle N active users
plus their agents over a sustained period", the loadgen needs to model
those bursts and hold them for an hour or more.

## What

- **`[agent.turn]`**: an idle agent starts a turn on a Poisson schedule
and runs one turn at a time. A turn lasts 0.5–1.5 × `mean_secs`. The
steps follow `buzz-acp`:
- **Start**: a `limit:1` `/query` finds the message to answer
(`http_query`), which stands in for the harness's subscription. Then the
harness's own reads (`turn_context` op): for a threaded message, its
thread context in one `/query` (root by id, newest 13 replies, the
agent's own newest reply) plus a `/count` when the window is full. Then
kind:39000 metadata, project home (kind:30621, kind:30617) and the
author's profile. Then two kind:7 reactions on the message.
- **Work**: typing every `typing_secs` and observer frames of
`frame_bytes` every `frame_secs` over the socket, plus `reads` tool
reads (`/query`, limit 50).
- **End**: a threaded reply, under the root that NIP-10 `resolve()`
gives. Then reaction removal: query own reactions, then a kind:5
deletion for each.
- One HTTP action at a time runs next to the socket. Socket publishes
(presence, typing, frames) don't wait for their OK: each OK is matched
as it arrives, one missing after 30 s is an error, and at most 128 wait
at once. So the socket and HTTP sides stay on schedule while either is
slow, as in the harness. A 2xx response that doesn't decode counts as an
error. No agent request starts after the stop.
- The request mix approximates sustained turns: every turn reads like a
first prompt. A harness already holding a thread reads 25 replies (not
13), batches profile lookups and caches project-home lookups for 30 s,
so this reads profiles and project homes more often than a busy harness.
- `agent.channel_subscriptions`: one live mentions REQ per channel, like
the harness. `agent.presence_interval_secs`: presence heartbeats.
- `agent.query_interval_secs = 0` turns polling off.
- **Sustained holds**: ramp steps may now repeat a level. A 60-minute
hold can be six 600 s steps, each one reported and judged, so drift
shows up as a trend.
- New op `turn_context`. New scenario
`perf/scenarios/local-agent-turns.toml`. `perf/LOADGEN.md` documents the
model.

All new fields are off by default (`turn.per_hour = 0`, presence 0, no
channel subscriptions), so existing scenarios run unchanged.

## What got simpler

- The agent loop's per-action branches now go through one `sent` result.
The observer frame build, the HTTP submit and the `/query` body read are
shared helpers. The old inline frame code is gone.
- "Hold for an hour, judge every 10 minutes" is now a ramp of repeated
levels. No new abort-window concept was needed.
- Identified debt, not touched here: abort is still judged only at step
end, so a collapsed relay is not detected until the step closes.
Repeated short steps reduce that gap.

## Testing

- On a Blox workstation at 6bb151d1: `cargo fmt --check`, `cargo clippy
-p buzz-test-client --all-targets -D warnings`, and `cargo test -p
buzz-test-client --lib` (103 passed). The five turn tests passed 10/10
repeated runs. Three mutants (awaiting the OK again, dropping the stop
guard in `submit`, dropping it in `query`) each fail a test.
- `local-agent-turns` against a local relay at 6bb151d1: both steps 0
errors, 0 clients cancelled.
- `agent_turns_read_react_stream_reply_and_remove_their_reactions` runs
an agent against a fake socket and a fake HTTP relay. It asserts the
exact thread-context filters and count, the metadata, project and
profile reads, the reactions, typing, frames and presence, and that
typing or frames arrive while a tool read is held. It also asserts the
threaded reply under the right root and the deletion of exactly the
reactions the agent added.
- `agent_turns_keep_moving_while_socket_oks_lag`: the same journey with
every socket OK 1.5 s late, past the 1 s heartbeat.
- `a_turn_stopped_while_adding_a_reaction_sends_nothing_more` and
`a_turn_stopped_while_reading_its_reactions_deletes_none`: the stop
lands while a reaction POST or removal query is held, and no request
follows it.
- `agent_turn_reads_that_do_not_decode_are_errors`: 200 responses that
are not events count as errors, and no turn reacts.
- `a_timed_out_route_is_recovered_again_after_a_renewal` (desktop) fails
2 times in 10 on unmodified main as well. That flake is pre-existing and
unrelated to this change.
- Live: a single-relay run with 10k humans and 20k turn-taking agents
(held at 1x for 60 min, then 1.5x and 2x). Results will be added here.

🤖 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-06 16:33:28 +00:00
Luke Tornquist
e3a0f3d59d feat(cli): send NIP-FI evidence via credential chain and Buzz desktop (#8999)
## Summary

Relays that enforce federated identity (NIP-FI) reject every protected
`buzz` CLI request with `401`. The CLI never sends
`Nostr-Federated-Identity`. This PR gives the CLI a credential chain,
lets it present NIP-FI evidence, and lets it take its identity from Buzz
desktop. Relays without NIP-FI behave exactly as before.

**Credential chain** (`crates/buzz-cli/src/credentials.rs`)
- Raw `Keys` are replaced by `Credentials`, which provide the public
key, signing through nostr's `NostrSigner`, NIP-44, and the NIP-OA auth
tag. NIP-FI evidence comes through `evidence(relay)`.
- The explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first
link. A later link can add a signer that holds no local key (a desktop
provider, for example) without reshaping callers.
- Operations that need key material rather than a signature are
purpose-bound methods instead of a key accessor:
  - `engram_d_tag` (the NIP-AE HMAC over the conversation key)
- the relay-scoped GIF customer id (random and fixed for the process
when there is no local key)
- `buzz mem` now encrypts, decrypts and derives the `d` tag through the
provider. Invalid ciphertext skips an entry; a signer failure aborts, so
an unavailable credential never reads as empty memory.
`buzz_core::engram` gains the composable steps behind it: `encode_body`,
`event_builder`, `validate_envelope` and `check_d_tag`. `build_event`
and `validate_and_decrypt` are unchanged wrappers, so `buzz-acp` and
desktop callers don't move.
- Agent-management drafts (`agent_management.rs`) encrypt and sign
through the provider.

**Evidence on every protected request**
(`crates/buzz-cli/src/client.rs`)
- A single `authorize` helper gets the NIP-FI assertion first, then
signs the proof just before sending, so a slow mint cannot age it past
NIP-98's 60 s window. It sets these on every protected route:
`Authorization` (NIP-98 or Blossom kind:24242), `x-auth-tag`, and
`Nostr-Federated-Identity: Bearer …`. The routes are `/query`, `/count`,
`/events` (stored and moderation), `/upload`, `/media/upload`,
`/media/*`, `get_authed`, `post_json_authed` and
`post_json_once_authed`.
- Exempt routes (`get_public`) are unchanged.
- The WebSocket publish sends the header on the upgrade via
`connect_authenticated_with_headers`. NIP-42 AUTH signs through the
signer (`buzz-ws-client` now takes `NostrSigner`; `&Keys` callers
compile unchanged).
- An upgrade denial is an HTTP response under NIP-FI.
`WsClientError::upgrade_rejection` returns its status and body, which
map like any other relay error, so a `403` still says whether evidence
or authorization was refused. A denial after NIP-42 (such as a key
mismatch) is a 1008 close; `WsClientError::PolicyClose` keeps its reason
and the CLI reports it as an `auth_error`.
- The WebSocket publish caps each network phase as a whole, writes
included: connect, AUTH challenge (with any Pongs), AUTH send plus OK,
EVENT send plus OK, and a best-effort close. NIP-42 signing between
phases is bounded by the signer. The relay drops a socket not signed in
within 5 s, so a sign-in that is throttled or comes back after the
socket closed reconnects and signs the new challenge, at most 3
connections. Each connection fetches current NIP-FI evidence for its
upgrade, and a throttled sign-in is waited out between connections, so a
dead challenge is never signed. EVENT is signed before connecting and
sent once, after AUTH succeeds. Running out is `unavailable` (exit 2,
`retryable:true`).
- Relay traffic follows no redirects: reqwest strips `Authorization`
across origins but not `x-auth-tag` or `Nostr-Federated-Identity`, so a
3xx is returned as a relay error. Credential header values are marked
sensitive.

**Headless evidence source**
(`crates/buzz-cli/src/federated_identity.rs`)
- Set `BUZZ_FEDERATED_IDENTITY_TOKEN` (your own NIP-FA sign-in session:
a human session for your key, not an agent grant) and
`BUZZ_FEDERATED_IDENTITY_ADAPTER` (the adapter base) to enable it. Both
are env only: command lines are visible to other local users.
- The CLI requests an assertion and the adapter mints it, through `POST
{adapter}/v1/identity/assertions` with a NIP-98 proof in
`Nostr-Authorization` (NIP-FA §Relay assertion).
- It checks the returned pubkey and expiry, parses the assertion as a
compact JWS (three base64url segments, JSON header with `alg`, JSON
payload), and requires its `exp` to equal `expires_at`. It caches one
assertion per process, bound to the relay when credentials are resolved,
and re-mints at the earlier of half-life and 30 s before expiry; the
desktop link caches and refreshes its assertion by the same rule.
Concurrent callers share one mint. An HTTP proof is signed again, after
current evidence, when the assertion changed while it was signed or the
evidence check aged it 30 s or more, up to 3 rounds, then fails as
`unavailable` (retryable).
- Compared with the separate rules before: a 300 s assertion now
refreshes at 150 s (was 270 s) on both links, a 40 s adapter assertion
at 10 s (was 20 s), and a 20 s desktop assertion is kept for 10 s
instead of fetched on every lookup. Desktop assertions now need a
readable JWS header with `alg` and a non-empty signature segment. An
unsupported headless relay URL now fails when credentials are resolved.
- The token goes only to the adapter origin (https, or http on
loopback), never follows a redirect, and never appears in errors or
`Debug`.
- A mint retries network errors, `429` and any `5xx` up to three
attempts with jittered backoff (or the adapter's `Retry-After` on
`429`/`503`, as delta-seconds or any HTTP-date form, capped at 30 s), on
every path (reads, post-once, moderation, stored events, WebSocket). It
then fails as `CliError::Adapter` (`adapter_error`, exit 2,
`retryable:true`). Relay retry loops never repeat it. A stored write
keeps `delivery_unknown` when any earlier attempt may have been
delivered, so a later adapter failure never invites a re-sign. Relay
mutation retries are otherwise unchanged.
- An explicit port 80 relay URL (read numerically from the authority, on
any scheme) is a usage error: `wss://host` means port 443, and NIP-FA
has no relay on port 80.
- A refusal is never retried: a non-transient status with an unreadable
or truncated body is still a refusal.
- Each NIP-FA denial code maps to an `auth_error` with a specific
message.
- These settings belong to the explicit key: without one they are not
read, and when the desktop link wins a warning names them.
- If an enforcing relay answers `401` and no assertion was sent, the CLI
returns an `auth_error` that names both settings, or, for a desktop
user, says to sign in to the community in Buzz. A desktop user's `403`
hint no longer mentions `BUZZ_AUTH_TAG`.

**What got simpler**
- About ten request sites built `Authorization` and `x-auth-tag`
themselves. Five copies of the 401/403 error-mapping block are now one
`relay_error`.
- The `sign_nip98` / `sign_blossom_*` helpers are gone. Proofs are a
three-case `Proof` enum consumed by `authorize`.
- `BuzzClient::keys()` (raw secret access from every command) is
removed. Commands use `public_key()`, `sign_event` or a purpose-bound
credentials method.

**Desktop credential provider**
(`crates/buzz-cli/src/desktop_provider.rs`,
`docs/local-credential-provider.md`)

Buzz desktop is a credential provider in the chain, after an explicit
key:

1. An inherited `BUZZ_CREDENTIAL_PROVIDER` token (set by the desktop for
the agents it launches). It decides the identity: when it is revoked,
not bound to the relay or unreachable, the command fails. A paired
token, which acts as the human, is never tried. That keeps a
well-behaved CLI from acting as the owner by accident; it is not a
boundary against an agent that can run its own commands as the owner's
OS user, and the contract says so with the mitigations.
2. Otherwise, a paired token stored in the OS keychain (`keyring`; macOS
native keychain, Linux Secret Service; no file fallback). A desktop that
answers `denied` or `unavailable` fails the command; only an unreachable
or unconfirmed advertisement is skipped. Revoked entries are forgotten.

New commands: `buzz auth desktop pair` and `buzz auth desktop unpair`.
The help no longer marks `BUZZ_PRIVATE_KEY` as required.

The CLI talks to the desktop over the loopback contract in
`docs/local-credential-provider.md` (NIP-46-shaped envelopes,
`Buzz-Credential-Provider` version header, same-user check on the
listening socket for paired and inherited tokens, via `/usr/sbin/lsof`
on macOS, given 5 s; a check that runs out of time is a retryable
`unavailable`, a wrong owner is refused). Every response's `id` must
match its request, error envelopes included. Paired tokens live only in
the keychain, survive desktop restarts, and are harness-neutral for
agents.

- **Relay binding:** tokens bind the whole relay URL (`http`/`https`
read as `ws`/`wss`, lowercase host, no default port, `/` for an empty
path; path and query count). The CLI sends that URL in `pair` and
`federated_identity`; NIP-FA audience canonicalization is the desktop's.
- **Rate limits:** a `rate_limited` answer refuses before acting, so the
CLI re-sends after `retry_after` (capped at 30 s), up to four times,
except a NIP-42 sign-in, whose wait goes to the WebSocket reconnect. A
re-sent NIP-42, NIP-98 or Blossom proof is re-dated (new id, Blossom
`expiration` moved with it); content events are re-sent exactly. If it
is still throttled, the command fails as `rate_limited` (exit 2,
`retryable:true`), not `auth_error`, whether the throttled call was
NIP-98, event signing, NIP-42 or encryption. Transport failures are
never re-sent, and relay commands are never replayed. A `buzz mem` list
that is throttled partway through completes.
- **Memory:** a `nip44_decrypt` answered with `invalid_request` is
invalid ciphertext, so `buzz mem` skips that entry (NIP-AE filtering);
any other provider failure aborts.
- **Keychain changes:** each change reloads the map under a short
`flock` on `~/.buzz-foundation/cli-pairings.lock` (directory forced to
`0700`, lock opened with `O_NOFOLLOW`) and changes only its own
instance; the lock is never held across the approval wait. An unreadable
entry is refused, never overwritten; `unpair` with no instance clears
it.

### Related

Related:
[block/buzz-app#2260](https://github.com/block/buzz-app/pull/2260) (the
desktop side of the provider contract)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-06 00:26:22 +00:00
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
Luke Tornquist
152441ae0a feat(loadgen): model desktop clients and large-community shape (#9005)
## 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](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-03 15:12:39 -05:00
Luke Tornquist
83bd8b1cae fix(loadgen): sign public URLs when dialling connect_addr in plaintext (#8926) 2026-11-03 02:04:19 +00:00
Luke Tornquist
4cf2a3bd66 feat(loadgen): add a hot-thread stressor and teardown (#8746)
## Summary

`buzz-loadgen` spreads load evenly: every channel gets the same history
and the same short thread chains. That never puts heavy load on a single
thread. A busy conversation, such as agents talking to each other, can
give one root a huge number of replies, and thread-summary work grows
with it (see #8635). This PR adds an optional `[hot_thread]` scenario
section so a load test can measure that case.

- **Seed:** `seed` posts a hot root in community 0, channel 0, then
`seed_replies` replies directly under it. The root comes from a
dedicated identity that posts nothing else, so `run` finds it however
many other roots the channel has. The replies go out concurrently, like
the rest of the seed, so the run starts with the thread already large.
- **Run:** `authors` hot-thread clients are scaled by each ramp step's
`users`, the same way humans and agents are. Each one replies to the hot
root over HTTP `/events` and reads the thread's newest 100 replies over
HTTP `/query`, at `replies_per_hour` and `fetches_per_hour`.
- **Metrics:** two new report ops, `hot_reply` and `thread_fetch`. The
replies are timed, so humans subscribed to that channel also record
delivery latency for them.
- **No root, failed run:** an author that can't find the seeded root
counts a `thread_fetch` error on every attempt, so a run without the hot
thread fails instead of quietly measuring nothing.
- **Off by default:** the section is disabled while `seed_replies` and
`authors` are both 0, so existing scenarios are unchanged.

`perf/scenarios/local-smoke.toml` gets a small hot thread, and
`perf/LOADGEN.md` documents the knobs.

### Teardown

The PR also adds `buzz-loadgen teardown <scenario>`, so a staging
fixture can be removed after the load test.

- For each community from `target.community_host_template`, it first
calls `POST /operator/communities/archive` as the fixture owner, using
the operator key `seed` uses. It then calls `POST
/operator/communities/delete`.
- The deletion `request_id` is derived from `run_id` and the host, so a
re-run replays the same requests. That retries failures and reports
progress without a `deletion_request_conflict`.
- The relay's deletion drain does the actual deletion asynchronously.
- If the fixture owner does not own a host, the host is reported
`absent` and left untouched.
- A scenario without a host template is refused.
- Deleted hosts stay as tombstones, so `LOADGEN.md` says each fixture
needs a fresh `run_id`.

The signing has to happen as each derived owner, which is why this lives
in the generator rather than in deployment config.

## What got simpler

Nothing got simpler; this adds a scale vector. To keep it small, the PR
reuses what's already there:
- the seeder's post plan, plus `find_roots` with a content prefix
- the existing HTTP helper, whose auth tag is now optional
- humans' delivery measurement

A separate chain-depth knob and dedicated viewer clients were left out.
`shape.thread_depth` already seeds chains up to the relay's depth cap,
and humans in the hot channel already receive the replies.

## Testing

-
`loadgen::seed::tests::the_hot_thread_seeds_direct_replies_under_its_own_root`
checks that the hot root and its replies are seeded, that every reply is
direct (depth 1), and that a rejected root fails the seed.
-
`loadgen::run::tests::hot_thread_authors_reply_to_the_hot_root_and_fail_without_it`
checks several things:
  - authors post replies tagged to the hot root;
- root lookups query only the hot-root identity, and a newer
reply-shaped decoy is never taken as the root;
  - scheduled reads all target the hot root's channel and id;
  - the run fails when the root isn't seeded.
-
`loadgen::run::tests::hot_thread_authors_start_nothing_once_the_run_stops`
runs 8 hot authors directly. It holds every in-flight `/events` reply at
a gate until the stop is signalled, so each author's next reply is
already overdue when the gate opens. It then checks that exactly 8
replies were posted, none started after the stop. Removing the
post-`select!` stop check fails the test, and the gate is released
before any assertion, so a failure can't hang the job.
- The run now signals the stop before the last step's snapshot instead
of after building that step's report. Under a loaded parallel test run,
work started in that gap leaked into `shutdown`.
-
`loadgen::teardown::tests::teardown_deletes_only_the_fixture_owners_communities_and_replays_on_rerun`
runs teardown twice against a fake operator that checks the owner,
requires archive before delete, and conflicts on a different request id.
It checks that:
  - the owned host is archived and deletion-requested;
- another owner's host and an unseeded host are reported absent and left
untouched;
  - a 503 on archive is reported as a failure;
  - the second pass converges to the same result;
- a deletion already past membership purge, where archive returns 404,
is replayed and reported at its stage under `requested`;
- the same run under another host template gets its own request ids:
another owner's host is reported absent rather than conflicting with a
request id already used.

If the request id is replaced with a fresh one per run, the test fails.
-
`loadgen::teardown::tests::teardown_refuses_a_scenario_without_templated_communities`
- All of these run in the existing `loadgen::` unit lane.
- Teardown was run end to end at `28192004` against a local relay. The
relay had operator keys configured, local S3 behind it, and `buzz-admin
deletions drain`. The run covered:
  - seeding three templated communities;
- teardown, with every fixture archived and a deletion request at its
derived id;
  - a re-run that converged;
  - the drain, which tombstoned the fixtures;
- teardown again after the purge, which reported each fixture at
`retention_pending` and exited 0;
  - a re-seed of the torn-down run, which failed at provisioning.

A community outside the fixture stayed active, with no deletion request.
- At `28192004`, `cargo fmt --check`, `cargo clippy -p buzz-test-client
--all-targets -D warnings` and six parallel runs of `cargo test -p
buzz-test-client` pass.

🤖 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-10-30 13:52:30 -04:00
Matt Toohey
1893c3ab5a test: dedupe collect_until_eose by event id and uncap two shell tests (#8677)
## Summary

Fixes two CI flakes from the 21–28 Oct 2026 audit, both in test code
with no product change:

- The relay E2E helper `collect_until_eose` now drops repeat copies of
an event id.
- Two Windows shell tests drop a 5 s cap that a cold Git Bash start can
exceed.

This is one of seven PRs split out of the `flakey-tests` branch
(siblings below).

### Related issue

None found for these two. The buzz-terminal `passwd_shell()` `getpwuid`
race
(`env_fence_tests::resolve_shell_falls_through_to_passwd_not_the_default`,
run 38590095289) is left to open PR #7687, which already replaces
`getpwuid`. #8636 and #7976 edit `buzz-test-client/src/lib.rs`, and
#7607 and #7915 edit `buzz-dev-mcp/src/shell.rs`, but none of them
changes `collect_until_eose` or these timeouts.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| Relay E2E
`e2e_project::test_project_tombstone_deletes_coordinate_and_spares_members`
(e2e_project.rs:338, "left: 2 right: 1") | 40408678412#1, job
121879630394 (PR #8620, schema-only) | The EVENT handler sends OK, then
fans out in a spawned task. `handle_req` registers the live subscription
before its history query, and `req.rs`'s `seen_ids` dedups history rows
only. If fan-out runs after the REQ is registered, the event arrives
once live and once from history, both before EOSE, and
`collect_until_eose` kept every frame. | `collect_until_eose` keeps the
first copy of each event id, as production clients do. Its doc comment
says a test of the relay's own dedup must read frames with `recv_event`.
A new unit test, `collect_until_eose_keeps_one_copy_of_each_event_id`,
uses a fake relay that sends the duplicate. |
| Windows `buzz-dev-mcp` `shell::tests::basic_echo` (shell.rs:1026, exit
124) | 39675698081#1, job 119485363399 (green on re-run) | 124 is the
shell tool's own timeout exit code. `basic_echo` capped the shell at 5
s, and it is one of the first tests in the process to start Git Bash,
which can take longer than 5 s on a cold runner. | `timeout_ms: None`
(the production 120 s default) in `basic_echo` and in
`workdir_is_honored`, which has the same exposure. `timeout_fires` still
covers timeouts. |

Pre-check: no e2e test relies on the relay's own history dedup. Every
exact-count assertion uses a single filter. The NIP-33 "only newest
remains" assertions would see a failed replacement as two different ids,
and this dedup keeps both.

Local evidence is from the original `flakey-tests` commit (fc4518d39):

- The new unit test: FAIL before, PASS after.
- 13 of the 14 e2e files that call the helper, plus `e2e_thread_roots`,
against an isolated local relay. All pass, except `e2e_relay`
`test_unarchive_emits_member_added_notification`. That test fails by
itself 3/5 with the original helper too, and CI's Relay E2E lane doesn't
select it.
- The Windows failure can't be reproduced on macOS. The Windows Rust
lane is the check.

Follow-up (out of scope): make the relay deliver each event id at most
once per subscription, keeping the live/history overlap.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs clippy and unit tests.

- Both files are byte-identical to their final versions on
`flakey-tests` (67185df6a).
- Checked for this PR: `git diff --check`, `rustfmt --check`, and a
conflict-free merge with all six siblings onto `main`.

### Readiness

Test-only change. AGENTS.md's readiness steps (agent review, then human
confirmation) are still outstanding, so `buzz-review-completed` is not
set.

### Merge order

Independent of the other six. It merges cleanly onto `main`, and with
every sibling, in any order.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease +
lane), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680
(desktop product races), #8681 (desktop E2E state gates), #8682 (desktop
E2E composer + row actions).

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

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:00:21 +11:00
Luke Tornquist
aed4be8592 Add a scenario-driven relay load generator (#8636)
## Summary

Buzz has no way to load-test the relay as a whole. `wamp_bench` sends
kind:9 messages from one identity to one channel, and
`perf/relay_bus_scaling.py` only measures Redis fan-out. Neither can
show what happens to a relay with many more users, channels, or deep
threads.

This PR adds `buzz-loadgen`, a load generator in
`crates/buzz-test-client`:

- `buzz-loadgen seed <scenario.toml>` creates communities (through `POST
/operator/communities`), channels, channel history, and deep threads.
- `buzz-loadgen run <scenario.toml>` drives simulated humans and agents
through ramp steps (1x, 2x, ...) and prints p50/p90/p99 latency, errors,
and rate-limit rejections per operation for each step.
- Humans hold a WebSocket with live subscriptions, fetch history on
connect, and send presence, typing, messages, thread replies, and
reactions. Agents authenticate with a NIP-OA tag, poll HTTP `/query`,
post through HTTP `/events`, and send encrypted observer frames to their
owner.
- Per-client rates come from the scenario. Set them to model a
deployment, and a step of `users = 10` means 10x that deployment's
population.
- Every identity, host, and channel id comes from the scenario's
`run_id`, so `--shard i/n` splits a population across machines with no
coordination.
- Each step measures from ramp start to hold end, so connect failures
during the ramp count. A step aborts the run when an operation's error
rate or p99 (delivery included) crosses the scenario's `[abort]` limits,
when a relay `CLOSED`s a live subscription often enough, or when a
client exits unexpectedly. The report names the limit that tripped.
- Each connect plus authentication has a deadline
(`target.connect_timeout_secs`), and shutdown is bounded, so a relay
that stalls connections still produces a report and a non-zero exit.
- `seed` exits non-zero unless the relay accepted every planned post.
- `NostrWsConnection::connect_to_addr` opens the TCP connection to a
given address while the URL still names the host. This lets a client
inside the relay's cluster reach a community host through the relay's
service address.
- `wamp_bench` is deleted. Its paced single-channel load is a subset of
a scenario.

What got simpler: one load tool instead of two partial ones. The change
touches only test tooling, the unit-test lane, and one additive method
in `buzz-ws-client`. Relay code is unchanged.

### Related issue

None found.

### Testing

Smoke run on a Linux workstation against a local relay built from this
branch, with its own Postgres, Redis, and MinIO containers:

```bash
cargo run --release -p buzz-test-client --bin buzz-loadgen -- seed perf/scenarios/local-smoke.toml
cargo run --release -p buzz-test-client --bin buzz-loadgen -- run perf/scenarios/local-smoke.toml
```

- `seed`: 710 of 710 posts accepted (10 channels, 200 history messages,
10 thread roots, 500 thread replies).
- `run`: both steps (1x = 50 humans + 5 agents, then 2x) completed with
0 errors and 0 rate-limit rejections. Every operation recorded traffic:
55/55 connect/auth, 205/205 live subscriptions, 150 history fetches,
presence/typing/observer frames, messages and reactions, HTTP `/query`,
HTTP `/events`, and publish→delivery. Delivery p99 was 45 ms at 1x and
42 ms at 2x.
- Failure paths are covered by unit tests: a listener that accepts TCP
but never answers (connect deadline, then `run` aborts and returns), a
fake `/events` server that rejects posts (`seed` fails), `CLOSED`
subscriptions, and invalid scenarios.

`just test-unit` (and its `scripts/run-tests.sh` fallback) now runs the
new `loadgen::` unit tests. Before this change, no CI lane ran
`buzz-test-client` lib tests.

🤖 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-10-29 13:49:37 -04:00
Tyler
2e936d439c relay: skip TTL deadline bump for known-permanent channels (T1a write-amp) (#2125)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
2026-07-19 14:55:51 -04:00
Will Pfleger
0042c8e106 feat(relay): allow agent owners to edit/manage agent-owned content (#1403)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-30 18:31:47 -04:00
Will Pfleger
79fcfd82bc feat(relay): implement NIP-ER event reminder support (kind:30300) (#934)
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-17 10:24:02 -04:00
Will Pfleger
d99ad131f1 refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
2026-06-10 19:29:51 -04:00