fix(acp): honor relay rate limits and pace resubscribes on bad links#2199
Merged
Conversation
wpfleger96
added a commit
that referenced
this pull request
Jul 21, 2026
…d pacing, encapsulation, tests Fixes all 16 reviewer findings (F1-F16) on PR #2199. **F1: control-sub recovery from rate-limited CLOSED** Membership and observer-control subs now recover from rate-limited CLOSED instead of dying permanently. Two new `BgState` flags (`membership_resub_needed`, `observer_resub_needed`) are set when the relay rejects these REQs before registering them (the relay's admission check runs before subscription registration, so no drain existed to clean them up). The main-loop drain condition is extended to wake and recover even when `rate_limited_pending` is empty, so control subs aren't silently lost. **F2: select-integrated pacing, shutdown-aware resubscribe sleeps** Removed all inline `tokio::time::sleep(REQ_PACING_INTERVAL).await` calls from drain functions and `resubscribe_after_reconnect`. Replaced with: - A `drain_pacing_next: Option<Instant>` budget gate in the main loop. - A new `select!` timer arm that fires at `REQ_PACING_INTERVAL` and resets the gate, waking the loop for the next drain batch. - A new `pacing_sleep` helper used by `resubscribe_after_reconnect` that selects on `cmd_rx` so `Shutdown` is honoured during inter-REQ sleeps and non-Shutdown commands are applied to state while waiting. - Budget-limited drain invocations (`DRAIN_BUDGET_PER_ITER = 1` REQ shared across both drains per main-loop tick), keeping effective rate ≤ 8 REQ/s. The combined effect: a 48-channel resubscribe no longer blocks the select! loop for ~6s, `cmd_rx` (capacity 64) can't fill up during drain, ping ticks fire on schedule, and graceful shutdown is honoured during any inter-REQ gap. `resubscribe_after_reconnect` now returns `ResubscribeResult` (Ok/ControlFailed/ Shutdown) so callers can handle shutdown correctly without losing reconnect context. **F3: gate-clearing scope** `resubscribe_after_reconnect` now accepts `is_fresh_connection: bool`. Gate clearing is restricted to fresh connections (new socket = fresh relay admission window). The proactive-resubscribe call site passes `false`, preserving parked channels across a backpressure-triggered resubscribe on the existing socket. Fresh-connection callers (`try_autonomous_reconnect`, `wait_for_reconnect`) pass `true`. **F4: remove `unwrap` from `set_rate_limit_gate`** Compute the winning deadline first, assign to field, return the value. **F5: evict channels from drain queues on successful live subscribe** `execute_connected_command`'s Subscribe success path now removes the channel from `rate_limited_pending` and `resubscribe_retry` so the drain loop can't send a duplicate REQ for an already-live subscription. **F6: encapsulate rate-gate reads via `check_rate_gate`** Renamed `is_rate_gated() -> bool` to `check_rate_gate() -> Option<Instant>`. Returns the deadline when gated (so callers don't re-read the field directly) and preserves the lazy-clear behaviour. All call sites updated. **F7: drain state-transition tests** New unit tests for: rate-limited CLOSED setting control-sub flags; drain failure re-queuing with +5s penalty; gate re-arm mid-drain moving channels from `resubscribe_retry` to `rate_limited_pending`; successful drain clearing `channel_dropped_since`. **F8: fix Finding #44 → #46 in `DNS_RETRY_INTERVAL` comment** **F9: move `DNS_RETRY_INTERVAL` and `REQ_PACING_INTERVAL` to top-of-file constants block; add `DRAIN_BUDGET_PER_ITER` constant.** **F10: document DNS retry asymmetry** `try_autonomous_reconnect` (bounded, capped at 10 DNS flat retries) and `wait_for_reconnect` (unbounded) each get a one-sentence note explaining the intentional difference. **F11: drop `retry_after` alias in CLOSED handler** — use `deadline` directly. **F12: comment threshold in `set_rate_limit_gate`** — explains why 1s hints floor to 5s, and notes the deliberate cross-client difference vs. the desktop TypeScript client's 10s no-hint default. **F13: `parse_rate_limit_retry_secs`** — replace `String` collect with subslice parse (ASCII digits → byte count = char count, so `&after[..len]` is safe). **F14: `drain_resubscribe_retry`** — budget-bounded `.take(budget)` replaces full-set clone; pacing enforced by caller. **F15: `is_dns_error` test** — add production-shaped `RelayError::WebSocket` wrapping a `tungstenite::Error::Io` variant, which is the actual error shape emitted by `connect_async` on macOS DNS failures. **F16: document ephemeral-only invariant on gated `PublishEvent` drop path.**
wpfleger96
force-pushed
the
wpfleger/acp-degraded-network
branch
from
July 21, 2026 03:44
b3919e8 to
4f3f7d4
Compare
On a degraded link (3.4s avg RTT, 40% packet loss, DNS brownouts) the ACP
harness generated 12,000+ rate-limit NOTICEs and 7,756 reconnect attempts
in 7 minutes. This commit addresses all root causes.
**Timeout tuning** — `AUTH_TIMEOUT` raised from 5s to 20s; `CONNECT_TIMEOUT`
from 10s to 30s. At 3.4s average RTT the old 5s auth window expired before
the relay's first frame arrived.
**Rate-limit gate** — When the relay sends a CLOSED or NOTICE with a
`"rate-limited:"` prefix, the harness now arms a `rate_limit_gate` deadline
using the relay's `"retry in {N}s"` hint (`parse_rate_limit_retry_secs`).
While gated: new `Subscribe` commands park in `rate_limited_pending`;
ephemeral `PublishEvent` frames (typing indicators) are dropped (they're
worthless when the relay already rejected us). The gate takes the max of
overlapping deadlines. `set_rate_limit_gate` floors hints below 2s to 5s.
**Control-sub recovery** — When the rate-limited CLOSED arrives for the
membership or observer-control subscription (which the relay rejects before
registering, so no per-channel entry exists), `membership_resub_needed` /
`observer_resub_needed` flags are set. The main-loop drain fires even when
`rate_limited_pending` is empty, ensuring control subs are re-sent once the
gate clears.
**Select-integrated pacing** — Inline `tokio::time::sleep(REQ_PACING_INTERVAL)`
calls removed from drain functions and `resubscribe_after_reconnect`. Replaced
with: (a) `drain_pacing_next: Option<Instant>` gate; (b) a `select!` timer arm
(`sleep_until` / `pending()`) that wakes the loop at each pacing tick; (c)
`DRAIN_BUDGET_PER_ITER = 1` REQ per 125ms tick, keeping effective rate ≤8
REQ/s; (d) a `pacing_sleep` helper (no jitter) for shutdown-aware inter-REQ
gaps in `resubscribe_after_reconnect`. A 48-channel drain no longer blocks the
select! loop for ~6s.
**Shutdown-aware resubscribe** — `resubscribe_after_reconnect` accepts `cmd_rx`
and returns `ResubscribeResult` (Ok/ControlFailed/Shutdown). `Shutdown`
commands during pacing sleeps propagate immediately.
**Gate-clearing scope** — `resubscribe_after_reconnect` accepts
`is_fresh_connection: bool`. Gate and pending queues are only cleared on a
fresh connection (new admission window). Proactive resubscribe on the existing
socket passes `false`, preserving parked channels.
**Partial reconnect failure** — Individual channel REQ failures park in
`resubscribe_retry` (drained by the pacing timer) rather than aborting the
entire reconnect. Successful `Subscribe` commands evict stale drain entries to
prevent duplicate REQs. `check_rate_gate() -> Option<Instant>` (renamed from
`is_rate_gated`) returns the deadline so callers don't re-read the field.
**Backoff ladder reset on stability** — The 60s stability block resets
`state.backoff_step` so a brief drop after a long healthy run retries at the
short end of the ladder.
**DNS brownout classification** — DNS resolution failures retry on a flat
`DNS_RETRY_INTERVAL` (2s ± jitter) without consuming a backoff ladder rung.
`try_autonomous_reconnect` caps DNS-only retries at 10 so agent startup can't
hang through a total brownout; `wait_for_reconnect` is unbounded.
Tests: 5 new unit tests covering rate-limit gate arm/expiry/max-extend,
control-sub flag set/clear, drain failure re-queue, gate re-arm mid-drain,
and successful drain cleanup. `is_dns_error` test adds the
`RelayError::WebSocket(tungstenite::Error::Io(...))` production-shaped variant.
560 tests pass.
wpfleger96
force-pushed
the
wpfleger/acp-degraded-network
branch
from
July 21, 2026 04:23
4f3f7d4 to
1d47386
Compare
Relay admission quotas survive socket replacement, so reconnect replay must keep an active gate rather than burst into the same exhausted window. Commands consumed during paced replay also need their live-socket effects or durable intent preserved if replay fails. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
tlongwell-block
pushed a commit
that referenced
this pull request
Jul 21, 2026
* origin/main: fix(desktop): keep project branches after reload (#2214) fix(git): make project branch workflows reliable (#2213) feat(desktop): manage project branches (#2212) fix(acp): honor relay rate limits and pace resubscribes on bad links (#2199) chore(lefthook): add CI-style path filtering to hooks (#2211) feat(cli): manage repository protection rules (#2193) feat(cli): filter archived instances from --template roster resolution (#2207) chore(release): release Buzz Desktop version 0.4.21 (#2209) fix(desktop): clarify data deletion action (#2208) feat: brand authentication complete page (#2192) fix(buzz-acp,buzz-agent): surface stall duration and fate (#2204) fix(desktop): resolve activity feed showing channel UUID instead of message content (#2201) feat(cli): add agents archive/unarchive/archived subcommands (#2173) chore(acp): strip stale finding-number references from comments (#2202) chore(release): release Buzz Mobile version 0.4.9 (#2200) Simplify first-community onboarding choices (UI only) (#2194) fix(mobile): sanitize Android image uploads (#2188) fix(cli): paginate channel directory queries (#2181) Semantic resolutions in crates/buzz-acp/src/lib.rs: - kept lazy_pool startup + pool_ready select guard from this branch, adopted main's comment cleanup (#2202) - dropped this branch's duplicated steer_renewal_tests module: #2204 moved those tests to queue.rs on main Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
tlongwell-block
pushed a commit
that referenced
this pull request
Jul 21, 2026
The generic PublishEvent arm silently drops every publish while the rate-limit gate (#2199) is armed. That is correct for typing indicators, but observer telemetry frames (kind 24200) are durable: one NOTICE from any traffic source erased ~30 frames of turn history over the default ~5s gate. Observer frames now park in a bounded drop-oldest FIFO on BgState (cap 256 ≈ 40s of upstream pacing at 6/s) whenever the gate is armed or earlier parked frames are still draining (order preserved). The existing main-loop drain machinery delivers them one frame per 125ms pacing tick once the gate clears, arming the pacing timer to the gate deadline so drains fire even with no other traffic. Overflow evicts oldest and is counted + logged — visible loss, never silent. The same parking now also covers observer frames hit by a live send failure or emitted while disconnected, so they survive reconnect via the post-reconnect drain. Typing indicators keep the existing drop-while-gated behavior. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
On a degraded link (3.4s average RTT, 10s maximum RTT, 40% packet loss, and DNS brownouts), the ACP harness generated more than 12,000 rate-limit notices and 7,756 reconnect attempts in seven minutes. Authentication timed out before slow handshakes completed, reconnect replay exhausted the relay's shared admission quota, and retries could form a reconnect storm.
Changes
AUTH_TIMEOUTfrom 5s to 20s andCONNECT_TIMEOUTfrom 10s to 30s for high-latency links.CLOSEDandNOTICEresponses, honor the relay's retry hint, and extend overlapping gate deadlines monotonically.REQper 125ms tick.REQframes.Subscribe,Unsubscribe, and ephemeralPublishEventcommands can no longer disappear during replay; stateful intent is retained if the replay socket fails before the queue drains.CLOSE, local-state commands, and ephemeral publishes immediate rather than adding unnecessary pacing. Typing indicators remain best-effort and are dropped while gated or after a failed socket send rather than replayed stale.REQ,CLOSE, andEVENTemission, plus deferred-intent preservation on replay failure.