relay: fuzz WebSocket 1012 restart-close timing on graceful drain (BUZZ_DRAIN_JITTER_MS) - #4542
relay: fuzz WebSocket 1012 restart-close timing on graceful drain (BUZZ_DRAIN_JITTER_MS)#4542bradseiler wants to merge 3 commits into
Conversation
On SIGTERM the relay sends every live WebSocket a 1012 Service Restart close frame via ConnectionManager::drain_all(), all in one instant. On a pod holding thousands of sessions that makes every client reconnect simultaneously — the thundering herd behind the DB pool-timeout bursts seen on each rolling deploy. Add BUZZ_DRAIN_JITTER_MS (default 0 = unchanged all-at-once behavior). When set, drain_all_jittered spreads each connection's restart close over an independent uniform delay in [0, jitter_ms], smearing reconnects across the window while keeping the well-attributed 1012 close and the 30s hard-drain backstop. The sticky drain flag is still set synchronously before returning, so the shutdown-boundary race guarantee holds: a registration that lands after the snapshot self-signals immediately with no jitter. jitter_ms == 0 delegates to drain_all for identical synchronous behavior and no task spawns. Tests: config default-off/reject-junk parse; jittered drain defers the close until within the window (paused-time), zero-jitter stays synchronous, and a late registration self-signals unaffected by jitter. Co-authored-by: npub1srl70fhzyu3fsnahl06vw2czvqc2w3ds37hyzvjnk8ve8f03ngcqg9le2w <80ffe7a6e22722984fb7fbf4c72b026030a745b08fae413253b1d993a5f19a30@buzz.block.builderlab.xyz> Signed-off-by: npub1srl70fhzyu3fsnahl06vw2czvqc2w3ds37hyzvjnk8ve8f03ngcqg9le2w <80ffe7a6e22722984fb7fbf4c72b026030a745b08fae413253b1d993a5f19a30@buzz.block.builderlab.xyz>
tlongwell-block
left a comment
There was a problem hiding this comment.
Reviewed at 344bfdc486537f5d0036263a5d5ff4cfa40acda3.
Verified locally (this SHA, same shell):
cargo clippy -p buzz-relay --all-targets -- -D warnings— clean.- Full
cargo test -p buzz-relay --lib— 840 passed, 1 failed:api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo(504 vs 200). Reproduced the identical failure onorigin/main@027a74a61, so it is pre-existing and unrelated to this PR. - Traced the call path:
main.rsshutdown task →drain_all_jittered→ per-conn detached task →restart_close_frame()+cancel(). The store-then-iterate sticky-flag ordering matchesdrain_all's existing contract withregister's insert-then-check, and the late-registration self-signal is untouched and covered by the paused-time test.
One real issue — the phantom cap 👻:
The config.rs doc for drain_jitter_ms says "Values are capped at shutdown to leave headroom under the 30s hard-drain timeout", and the parse-site comment repeats "Bound is enforced (capped) at shutdown." No such cap exists — main.rs:1230 passes config.drain_jitter_ms straight through, and drain_all_jittered uses it unclamped.
Consequence: an operator who sets BUZZ_DRAIN_JITTER_MS=45000 (trusting the documented cap) gets every connection whose draw lands past ~30s killed by the hard-drain process::exit(1) — a TCP reset with no 1012 frame, which is precisely the silent-death failure mode this drain path exists to prevent. Default-0 is safe, but the doc promises a guardrail the code doesn't have.
Fix is one line either way:
- (preferred) actually cap at the call site, e.g.
drain_jitter_ms.min(25_000)with a comment tying it to the 30s hard timeout — makes the config doc true; or - fix the doc to say the value is not capped and must be kept well under 30s (the
state.rsdocstring already says this correctly — the two docs currently contradict each other).
Nits (no action required):
self: &Arc<Self>receiver ondrain_all_jittered— nothing in the spawned tasks capturesself;&selfwould do.- Docstring says the delay is drawn from
[0, jitter_ms];% jitter_msis[0, jitter_ms). The inline comment has it right.
Everything else is genuinely tight: default-off byte-identical path, race guarantee preserved and tested under paused time, uses the existing relay jitter idiom, 239 lines mostly docs+tests. Fix the phantom cap (or its documentation) and this is ready.
tlongwell-block
left a comment
There was a problem hiding this comment.
Correction to my review above — the jitter mechanism does not survive shutdown. Downgrading from "fix the cap and ship" to "needs a redesign of task lifetime."
Max ran the real relay process (200 NIP-42-authenticated sockets, BUZZ_DRAIN_JITTER_MS=2000): the relay logged connections=200, then 0/200 received the 1012 frame — all 200 got abnormal 1006 closes at ~5.018s. The jitter=0 control delivered 1012 to 100/100.
Root cause, confirmed in the code: drain_all_jittered spawns detached timer tasks and returns immediately without cancelling any connection. With no cancel() fired synchronously, axum::serve(...).with_graceful_shutdown (main.rs:1281) completes as soon as the watch signal fires, serve() returns at main.rs:1076, main falls through the audit drain and returns — and #[tokio::main]'s runtime drop destroys every parked jitter timer and the 30s hard-timeout task. The frames never send; clients get a TCP-level 1006. The jitter=0 path only works because drain_all queues the frame and cancels synchronously before the graceful-shutdown signal propagates.
The paused-time ConnectionManager unit tests can't see this — they never tear the runtime down. That's why they pass while the live process fails at even 2s of jitter.
What a fix needs:
- The shutdown task must await completion of all jittered closes (e.g.
JoinSet/ collected handles) before allowingserve()'s return path to reach the end ofmain— ormainmust explicitly wait on a drain-complete signal afterserve()returns. - The
>30scap issue from my earlier comment still stands once the lifetime is fixed. - Validation must include a real-process test (spawn the relay, open live WS connections, SIGTERM, assert 1012s arrive spread over the window) — unit tests alone demonstrably pass on a broken implementation.
Credit: live failure isolated by Max (200-conn regression vs 0ms control). My earlier "mechanism itself is sound" was wrong — it was sound only inside a runtime that stays alive.
Co-authored-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz> Signed-off-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
Co-authored-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz> Signed-off-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
Problem
On SIGTERM the relay sends every live WebSocket a 1012 Service Restart close frame via
ConnectionManager::drain_all()— all in the same instant (crates/buzz-relay/src/main.rsshutdown task →state.rs::drain_all). On a pod holding thousands of sessions, that makes every client reconnect simultaneously: the thundering-herd reconnect behind the DB pool-timeout bursts observed on each rolling deploy.Change
Add
BUZZ_DRAIN_JITTER_MS(default0= unchanged all-at-once behavior). When set, a newConnectionManager::drain_all_jittered(jitter_ms)spreads each connection's restart close over an independent uniform delay in[0, jitter_ms], smearing reconnects across the window. The well-attributed 1012 close is preserved, and the existing 30s hard-drain timeout still backstops the window.config.rs—drain_jitter_msfield, non-negative parse mirroringBUZZ_REPLICA_READ_MAX_AGE_MS(default 0, junk fails loudly).state.rs—drain_all_jittered: sets the stickydrainingflag synchronously before returning, then schedules each captured connection's close in a detached task after its random delay.jitter_ms == 0delegates todrain_allfor identical synchronous behavior and no task spawns.main.rs— shutdown task callsdrain_all_jittered(config.drain_jitter_ms).Safety
jitter_ms == 0is byte-for-byte the current path. Safe to deploy dark and dial up.drain_allalready provides.process::exitremains the ceiling. Keep the jitter well underterminationGracePeriodSeconds(60s).Tests
config::tests::drain_jitter_defaults_off_and_rejects_junkstate::tests::drain_all_jittered_zero_is_synchronous_drain_allstate::tests::drain_all_jittered_defers_close_until_within_jitter_window(paused-time: verifies deferred close fires within the window, 1012 preserved, and a late registration self-signals unaffected by jitter)Local validation at
344bfdc48:cargo build -p buzz-relayclean;cargo clippy -p buzz-relay --all-targets -- -D warningsclean;buzz-relay --lib= 831 passed, 10 failed — all 10 are pre-existing DB-backed integration tests (Sqlx(PoolTimedOut)/ media/admin/mesh) that need a live Postgres+Redis not provisioned locally; none touch drain/shutdown/config. Push hooks (rust-tests, desktop-tauri-checks) passed.Rollout
Ship with default
0, then setBUZZ_DRAIN_JITTER_MS(e.g. 10000–20000) on staging first, watch the roll-window pool-timeout metric, then production. Complements the preStopsleep(which stops routing before close but doesn't spread within a pod).