Skip to content

Relay silently drops fan-out frames under send-buffer backpressure; connected clients never recover the missed event #25

Open

Description

@spiritabsolute

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)

  1. state.rs:1120: try_send_ws_message handles TrySendError::Full by logging a warning and dropping the frame. The connection is cancelled only after BUZZ_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.
  2. event.rs:373: OK means the event was durably accepted. Fan-out runs afterwards in a separate task, and no receipt from subscribers is involved.
  3. ingest.rs:3505 and delivery.rs:421: re-publishing an already accepted event, or retrying messages deliver for 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.
  4. 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

Proposed scope

A separate change, not bundled with the session-continuity work (#6/#7, PR #15).

  1. Relay: detect a dropped frame per subscription and signal the client in a backward-compatible way. Count drops without sampling.
  2. 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.
  3. 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.
  4. 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 Full while 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

  1. mfethe1 commented on Oct 19, 2026

    @mfethe1

    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:1120 in your tree at 9b76dbeb45). In block#6090 the emit sits in a separate try_send_fanout_frame; try_send_ws_message keeps the old behaviour (count grace, drop, no signal). Both paths share a try_data_send helper 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 in buzz_fanout_dropped_frames_total and the connection takes the disconnect path instead. Worth knowing before you size BUZZ_SEND_BUFFER against a repair design: the two buffers are independent.

    3. There is no coalescing in the contract — one signal per dropped frame.
    Each TrySendError::Full on 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-fast 885 passed / 3 failed (host A) and 884 passed / 4 failed (host B), 43 ignored on both; cargo fmt --all --check clean; cargo clippy -p buzz-relay --tests --all-features -- -D warnings clean on both. All failures reproduce unchanged at the base commit f956e6fe (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 currently dirty against 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions