Repository navigation
Relay silently drops fan-out frames under send-buffer backpressure; connected clients never recover the missed event #25
Description
Summary
When a connection's outbound queue is full, the relay drops the fan-out frame for that subscriber and keeps the connection open. The event is already stored and the publisher has its OK, so nobody notices: the connected client never receives the event, and nothing tells it to fetch it.
This is independent of the background result delivery contract (#18). A canonical report that was accepted and stored can still be missed by a client that is connected at the time.
Mechanism (fork main @ 9b76dbeb)
state.rs:1120:try_send_ws_messagehandlesTrySendError::Fullby logging a warning and dropping the frame. The connection is cancelled only afterBUZZ_SLOW_CLIENT_GRACE_LIMIT(default 15) consecutive failures, and any successful send resets the counter. A dropped frame is not kept for a later retry.event.rs:373:OKmeans the event was durably accepted. Fan-out runs afterwards in a separate task, and no receipt from subscribers is involved.ingest.rs:3505anddelivery.rs:421: re-publishing an already accepted event, or retryingmessages deliverfor a confirmed key, returns the stored result without a new fan-out. Retrying on the publisher side cannot repair the gap, and choosing a different report would break the canonical-report contract.- Recovery after reconnect is not guaranteed. Desktop replays channel subscriptions with a bounded lookback; mobile and buzz-acp replay from their last-seen cursor. Newer events that did arrive move that cursor past the gap.
The queue holds BUZZ_SEND_BUFFER frames per connection (default 1000), shared by all subscriptions on that connection. The queue fills when a client stops reading (a hung or sleeping app) or during bursts, such as concurrent history REQs on one connection (see block#8196).
Impact
- Humans (desktop, mobile): a message or agent report is missing from the timeline until a reload that happens to cover it.
- Agents (buzz-acp): a missed mention is silently not answered. The next turn in the thread fetches context over REST, so a later mention brings the missed message back into context, but the missed trigger itself starts no turn.
Related upstream work
- fix(relay): emit BUZZ_SYNC_REQUIRED gap signal on fan-out backpressure drops block/buzz#6090 (open) adds a
["BUZZ_SYNC_REQUIRED","backpressure"]signal and a dropped-frame metric on the relay side. It does not include client recovery. In this fork, desktop and mobile ignore unknown relay messages, buzz-acp logs a parse error and continues, and the shared Rust WS client returnsUnexpectedMessagefor an unknown head (message.rs:164). So porting the signal alone does not recover a single gap. - REQ history stops partway with no EOSE or CLOSED when the per-connection send buffer fills (two concurrent REQs) block/buzz#8196 (open): the same queue and the same drop path cut REQ history short with no
EOSE/CLOSED.
Proposed scope
A separate change, not bundled with the session-continuity work (#6/#7, PR #15).
- Relay: detect a dropped frame per subscription and signal the client in a backward-compatible way. Count drops without sampling.
- Clients (desktop, mobile, buzz-acp, shared Rust WS client):
- handle the signal and keep a lower bound of the gap that newer events cannot move;
- re-read the missed range from history under current authorization, deduplicated by signed event id;
- coalesce repeated signals into one bounded repair with backoff;
- if repair is impossible, show an explicit degraded or reconnect state rather than silent success.
- The repair re-reads the same stored events (same ids, same completion keys). It must never re-publish, pick another report, or resume a provider session.
- Interim option: close the connection on the first dropped frame, so existing reconnect replay kicks in. Only with documented limits, because replay windows are bounded.
Acceptance criteria
- Accepted canonical report → queue
Fullwhile the connection stays open → the client detects the gap and ends up with exactly one copy of the original event id. - Newer events arriving after the gap do not erase its lower bound.
- Repeated signals, a full control queue, or an access change during repair cause no unbounded repair loop, no duplicates, and no data the client is not allowed to read.
- Old clients that do not understand the signal keep working (no protocol errors on subscribed connections).
Notes
The mechanism above was verified against the source and with an isolated local check of the unchanged queue function. Latency and drop numbers reported by third parties were not reproduced here.
Activity
mfethe1 commentedon Oct 19, 2026
Author of block#6090 here. Your read of it is right — it is relay-only by design — and since nobody has answered this issue I want to put the measurements we actually hold behind that statement, because two of them bear directly on your acceptance criteria.
1. The signal is attached to the fan-out frame path only, not to
try_send_ws_message.Your mechanism section points at
try_send_ws_message(state.rs:1120in your tree at9b76dbeb45). In block#6090 the emit sits in a separatetry_send_fanout_frame;try_send_ws_messagekeeps the old behaviour (count grace, drop, no signal). Both paths share atry_data_sendhelper that resets the grace counter on success. So a port of block#6090 alone leaves every non-fan-out drop on that connection still silent. If your repair contract is "a connected client never silently misses a stored event", you need the emit on the shared helper, not on the fan-out caller — that was a deliberate narrowing on our side to keep the contract frozen, not an oversight we validated as sufficient.2. The signal goes out on the ctrl channel, which is a different mpsc than the one that just overflowed.
conn.ctrl_tx.try_send(sync_required_frame()). That is what makes it deliverable at all during a full data queue — the ctrl queue has its own capacity. Our test sets them separately (setup_conn_with_ctrl(1, 8): data buffer 1, ctrl buffer 8) and asserts the frame lands on ctrl and the socket stays alive below the grace limit. If ctrl is also full/closed, the drop is counted inbuzz_fanout_dropped_frames_totaland the connection takes the disconnect path instead. Worth knowing before you sizeBUZZ_SEND_BUFFERagainst a repair design: the two buffers are independent.3. There is no coalescing in the contract — one signal per dropped frame.
Each
TrySendError::Fullon the fan-out path queues another["BUZZ_SYNC_REQUIRED","backpressure"]. Under a sustained burst a client sees up to ctrl-capacity signals, not one. Your acceptance criterion "repeated signals … cause no unbounded repair loop" therefore has to be met entirely on the client, with the current frozen contract. We did not measure what that costs on a real desktop/mobile client; we only have the relay-side unit assertion.4. The frame carries no lower bound. It is reason-only: no subscription id, no dropped event id, no
created_at. Your criterion "newer events arriving after the gap do not erase its lower bound" is not satisfiable from the signal itself — a client would have to treat it as "my whole current view may be stale" and re-read every live subscription's window. That is the single biggest open design question on this axis and it is the one we deliberately did not answer.Verification we hold, at exact head
0048e96a0acfb2fea04918ff5027a244f6d8af60, two independent Windows/MSVC hosts with separate gate stacks:cargo test -p buzz-relay --lib --no-fail-fast885 passed / 3 failed (host A) and 884 passed / 4 failed (host B), 43 ignored on both;cargo fmt --all --checkclean;cargo clippy -p buzz-relay --tests --all-features -- -D warningsclean on both. All failures reproduce unchanged at the base commitf956e6fe(two WSL-bash HMAC tests, one redis-gated mesh test, and on host B an intermittent tracing-callsite test that passes with-- --exact). Honest status: block#6090 has been open since 2026-08-17, has had no review, and is currentlydirtyagainst main — so treat it as a measured relay-side contract, not as something upstream has blessed.Open question back to you, since you have the client side that we do not: your note says the queue function was checked in isolation but third-party latency/drop numbers were not reproduced. Did that isolated check give you a drop rate under a realistic burst (concurrent history REQs on one connection, per block#8196) — i.e. is the gap a once-a-day event or a per-burst event? That number decides whether the interim option 4 (close the connection on the first dropped frame and lean on reconnect replay) is cheap or pathological, and it is the measurement we are missing on our side.
feat(fork): visible and controllable agent sessions (#15)