Repository navigation
feat(loadgen): model desktop clients and large-community shape - #9005
Conversation
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>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 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
left a comment
There was a problem hiding this comment.
🤖 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
-
Shutdown can detach the HTTP read worker, and reads aren't abandoned at stop (
desktop.rs:994-997,1036-1040,1140).Fetcher::finish()takes theJoinHandleout ofFetcherbefore awaiting it. If a read outlasts the 15 sSHUTDOWN_GRACE,shutdown()aborts the client during that await, which drops the handle and detaches the worker.Fetcher::dropthen seesNone, so its abort never runs. The worker can outliverun(), and its in-flight ops miss the final report instead of settling asunfinished at stop. Separately, reads don't stop at stop: one can take up toHTTP_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 againststopping()andforget()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. -
Thread discovery bypasses the reader's bounds (
desktop.rs:897-903→run.rs:524-535).Reader::thread()calls the sharedroots()directly, outsidequery_with(). An uncached lookup therefore runs under reqwest's 60 s timeout rather than the desktop's 30 s, skips the stop check (aThreadstill queued at stop can start a fresh request), and doesn't count against the 6-read limit.Activity::step()can also callroots()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. -
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 awaitsActivity::step(). A publish there waits for its OK, and meanwhileNostrWsConnection::wait_for_okbuffers 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 asEOSE 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. -
legacyleaves out the per-channel routes the app used to open (desktop.rsbatch_reqs). Before the volume fixes, the app gave every open (priority) channel and every non-joined interest its ownchannel:<id>route outside the 10-channel batches (block/buzz-appb5f9bf5de^: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 aboutwindowsper 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), socurrentis right.
MINOR
- No presence subscription in either mode. The app opens a live-only
presenceroute ({"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. - 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. - 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 inperf/LOADGEN.mdas a simplification. This only matters when the relay is slow. - Abandoned subscription attempts disappear from the report on renewals (
desktop.rs:1411-1413). Every session end callspending.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. - 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.
…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>
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
shape.private_channelsfalseseedcreates 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 runseed_concurrencyat a time. A rerun adds nobody new, and any member the relay refuses fails the seed.shape.wide_channels0population.wide_member_fraction0.0population.wide_activity_sharepopulation.tiers[][{ fraction, channels }]: that share of humans joinschannelschannels instead ofchannels_per_member.Assignment is deterministic by global index, so
seedand every--shardagree without coordination. With no wide channels and no tiers it is the old assignment, and a test pins that.Desktop clients (
[desktop])fraction0.0mode"current"legacyis the client before its volume fixes;currentis after them.windows,threads3,1tick_secs60backstop_secs900currentonly: each tick item (subscription replacement, roster, unread repair) waits this long since it last ran.renewal_secs00disables 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:
legacywith more than 10: one per-channel batch per 10 channels;currentwith 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.
legacymakes every read as it connects.currentreads 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.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.mddocuments the knobs and the model.perf/scenarios/local-desktop.tomlis a short local example with every knob on.What got simpler
human()'s schedule loop (presence, typing, messages, reactions, session end) moved into oneActivity. The basic and desktop humans share it, so neither client duplicates the schedule. The basic human's random draw order is unchanged.member_channelsremains the single assignment function, andchannel_membersderives 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,rootsandNet.Identified debt, not fixed here: CI's
test-unitrunsbuzz-test-client --libwith the filterloadgen::, so the five crate-roottests::inlib.rs(relay message parsing, auth and text event building,collect_until_eose) never run in CI. That gap predates this PR, which doesn't touchlib.rs.Testing
cargo fmt --check,cargo clippy -p buzz-test-client --all-targets -- -D warnings, andcargo test -p buzz-test-client(plain parallel): 84 lib tests pass.Wide activity: a seeded
Rngover 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_membersinverts 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:
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:
legacyandcurrentat 5 and 25 channels;until+before_idof the last row, not the older aux event), and stops when the bounds say history is exhausted even though that page came back full;currentrenewal, 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;legacyticks replace batches (new REQ before the CLOSE of the old one) and reread;currentticks do nothing until the backstop, then replace live-only;legacyrenewals are full reconnects;currentrenewals 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
seedtwice, thenrunoflocal-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:
currentlegacydesktop_live_eosedesktop_replay_eosedesktop_rosterdesktop_read_state/desktop_unreaddesktop_profilesdesktop_nip11desktop_windowWindow 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:
before_idwithuntil;Both are fixed, and the fakes now enforce both.
🤖 Generated with Claude Code