Skip to content

fix(blocking): reap clients that disconnect while blocked (c10k A1) - #428

Merged
TinDang97 merged 1 commit into
mainfrom
fix/c10k-blocked-client-eof
Aug 6, 2026
Merged

fix(blocking): reap clients that disconnect while blocked (c10k A1)#428
TinDang97 merged 1 commit into
mainfrom
fix/c10k-blocked-client-eof

Conversation

@TinDang97

Copy link
Copy Markdown
Collaborator

Stacked on #426 (fix/c10k-hardening-blocking-registry) — A1 rewrites the same select! blocks that #426's A3 cleanup touches. Review the top commit only; the base merges first.

Problem (c10k hardening finding A1)

The infinite-wait select! behind BLPOP/BRPOP/BLMOVE/BZPOPMIN/BZPOPMAX/BLMPOP/BZMPOP/BRPOPLPUSH had exactly two arms — reply_rx and shutdown. Nothing watched the socket.

BLPOP key 0 followed by a disconnect therefore leaked the handler task, the WaitEntry, the client-registry entry and the maxclients slot forever:

  • infinite waiters carry deadline: None, so the W6 deadline sweep skips them;
  • timeout exempts blocked clients by design (Redis clientsCronHandleTimeout parity);
  • CLIENT KILL couldn't reach them either.

A few thousand throwaway connections wedge the server until restart. One unauthenticated command, no auth required.

Fix

The wait now watches the peer. Two mechanisms, because the runtimes have different cancellation semantics — this is the substance of the change:

tokioAsyncReadExt::read_buf is documented cancel-safe (a losing select! branch reads nothing), so the watch is a plain read arm.

monoio — a read owns its buffer, so a read arm losing the race could take client bytes down with the cancelled op. The watch is instead a two-step: a non-consuming readiness poll inside the select!, then the drain outside it, where nothing can cancel it. Level-triggered readiness guarantees that drain completes at once (data, EOF, or error). Racing the reply is harmless — reply_tx is a flume bounded(1), so a wake delivered mid-drain waits in the channel for the next loop turn.

A vanished client tears down every registration it held, local and remote, through the same cleanup the timeout path uses. CLIENT KILL flows through this too, since it closes the fd with shutdown(2).

Only a real error is death. EWOULDBLOCK (the legacy driver can hand back a readiness that yields nothing), EINTR and ECANCELED all re-arm. Misreading any of them as EOF would drop a healthy blocked client — the exact inverse of the bug being fixed.

Byte carry, and the regression it required fixing

Bytes a client legally pipelines behind its blocking command are carried into the parse stream instead of being consumed and dropped. That needed a matching fix in both read loops: pre-A1 those bytes sat in the kernel and the next read() returned them at once, but now they are already in read_buf, so parking in read() first would hang the pipelined command. Both handlers skip exactly one read after a blocking command leaves unparsed input; a carry that is only a partial frame parses to nothing and the loop comes straight back with the flag cleared.

Known gap — TLS

TLS keeps the pre-A1 behaviour. The vendored monoio-rustls read_inner loops on read_io until rustls yields plaintext, so a post-readiness read can park inside a partial record — the one thing the cancel-free design must never do. IdleParkRead::peer_readable defaults to a never-resolving future, which is what keeps those streams on exactly the old path. TLS at least requires a completed handshake, so it is not the unauthenticated one-command DoS this fixes.

The carry buffer gets its own 64 MiB ceiling rather than inheriting the read loop's (which has none — that is finding C2, a separate fix).

Verification

All on the Linux VM (macOS runs were contended; ELF magic asserted on the binary).

RED proven independently per runtime, by disabling only that runtime's peer watch:

monoio tokio
single-key (24 clients) connected_clients is still 25 connected_clients is still 25
multi-key (16 clients) connected_clients is still 17 connected_clients is still 17
GREEN 5/5 5/5

In both RED runs the two positive-control tests stayed green — so the failures are the defect, not the harness.

tests/blocking_peer_eof.rs (5 e2e, green on both runtimes):

  • disconnected_single_key_blocked_clients_are_reaped — the A1 regression test
  • disconnected_multi_key_blocked_clients_are_reaped — 4 shards, exercises the remote BlockCancel unwind and asserts no ghost waiter swallows a later push
  • pipelining_behind_a_blocking_command_still_works — the live-client inverse failure mode
  • partial_frame_carried_while_blocked_completes_later — the read-skip guard
  • silent_blocked_client_stays_blocked — no spin, no self-trigger

Gates. fmt; clippy -D warnings on default and runtime-tokio,jemalloc; lib tests 4471 monoio / 3633 tokio; blocking_ghost_waiter, blocking_list_timeout (incl. --ignored), parked_idle_parity, idle_downshift_parity, tls_idle_downshift_parity all still green.

Notes

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 088cb7a8-52ff-4f9a-a4b2-308ac425e013

📥 Commits

Reviewing files that changed from the base of the PR and between 602aead and 42b7e36.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/server/conn/blocking.rs
  • src/server/conn/handler_monoio/dispatch.rs
  • src/server/conn/handler_monoio/idle_park.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/handler_sharded/mod.rs
  • tests/blocking_peer_eof.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Reap disconnected blocked clients by watching peer socket (c10k hardening A1)

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add peer-socket watching to blocking waits so disconnected clients are reaped.
• Carry pipelined bytes into the normal parse stream to preserve command ordering.
• Add regression tests for disconnect storms and pipelining/partial-frame carries.
Diagram

graph TD
  C["Client"] --> H["Conn handler"] --> B["blocking::handle_*"] --> W["Blocking wait loop"] --> D{Outcome}
  W --> P["Peer watch"] --> K["Carry into read_buf"] --> H
  W --> R["WaitEntry cleanup"]
  D -->|"Reply/Timeout/Shutdown"| H
  D -->|"PeerGone"| X["Close connection"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Spawn a dedicated per-connection peer-watcher task
  • ➕ Decouples peer monitoring from blocking wait logic
  • ➕ Avoids adding read/readiness arms to multiple select!/loop sites
  • ➖ More tasks under load; higher overhead at c10k
  • ➖ Coordination complexity (cancellation, ordering, and buffer ownership) increases
  • ➖ Still must solve pipelined-byte carry semantics safely
2. Unify peer-watch behind a runtime-agnostic abstraction (trait/future)
  • ➕ Reduces duplicated logic between tokio and monoio paths
  • ➕ Centralizes tricky cancellation/readiness rules and error classification
  • ➖ Hard to design with monoio buffer-ownership constraints and TLS limitations
  • ➖ May obscure subtle correctness requirements (cancel-safety, level-triggered readiness) during maintenance
3. Rely on periodic sweeps/registry timeouts for infinite blockers
  • ➕ Simpler control-flow; avoids socket I/O while blocked
  • ➖ Breaks Redis parity for infinite blocks and can drop legitimate long blockers
  • ➖ Does not address untrusted disconnect storms promptly; still leaves resource pressure windows

Recommendation: The PR’s approach (explicit peer watch integrated into the blocking wait) is the most direct and resource-efficient fix, and it correctly accounts for tokio vs monoio cancellation semantics while preserving pipelined bytes. A follow-up worth considering is extending the monoio/TLS path (currently left with pre-A1 behavior due to rustls read-loop semantics) so blocked TLS clients can also be safely reaped without risking cancellation-induced byte loss.

Files changed (7) +784 / -71

Enhancement (1) +28 / -0
idle_park.rsAdd peer_readable readiness primitive for blocked peer watching +28/-0

Add peer_readable readiness primitive for blocked peer watching

• Extends IdleParkRead with a cancel-safe peer_readable readiness future (default pending) and implements it for TcpStream using readable(false). Documents why TLS keeps the default to avoid unsafe post-readiness reads inside partial records.

src/server/conn/handler_monoio/idle_park.rs

Bug fix (4) +397 / -71
blocking.rsWatch peer during blocking waits and return PeerGone outcome +316/-65

Watch peer during blocking waits and return PeerGone outcome

• Introduces BlockingOutcome (Reply vs PeerGone) and adds peer-socket monitoring to blocking wait loops for both tokio and monoio. Implements safe byte-carry behavior (including limits) and ensures disconnect-triggered cleanup runs through the same remove_wait/BlockCancel paths as timeout/shutdown for both single-key and multi-key waits.

src/server/conn/blocking.rs

dispatch.rsPropagate PeerGone and pass read_buf for carried input +23/-2

Propagate PeerGone and pass read_buf for carried input

• Extends BlockingResult with PeerGone and wires blocking handling to pass the handler’s read buffer into the blocking wait so pipelined bytes preserve order. Ensures the caller closes the connection without writing when a blocked peer vanishes.

src/server/conn/handler_monoio/dispatch.rs

mod.rsSkip one read when blocking carry leaves unparsed bytes +25/-2

Skip one read when blocking carry leaves unparsed bytes

• Adds a carried_input flag so the main loop avoids parking in a socket read when blocking peer-watch already pulled bytes into read_buf. Ensures parsing consumes carried/pipelined input before the next read and closes immediately on PeerGone.

src/server/conn/handler_monoio/mod.rs

mod.rsTokio handler: treat read_buf as carry buffer and skip one read +33/-2

Tokio handler: treat read_buf as carry buffer and skip one read

• Passes stream + read_buf into the blocking handler and closes the connection on PeerGone without replying. Adds a carried_input guard so pipelined bytes carried during a blocking wait are parsed before awaiting the next read, including partial-frame carry cases.

src/server/conn/handler_sharded/mod.rs

Tests (1) +340 / -0
blocking_peer_eof.rsAdd regression tests for blocked-client disconnect reaping and carry +340/-0

Add regression tests for blocked-client disconnect reaping and carry

• Adds integration-style tests that reproduce the pre-fix leak (single-key and multi-key across shards) and assert maxclients/connected_clients slots are released. Verifies pipelining behind blocking commands works, partial carried frames complete later, and silent blocked clients remain wakeable.

tests/blocking_peer_eof.rs

Documentation (1) +19 / -0
CHANGELOG.mdDocument A1 fix for blocked-client disconnect leaks +19/-0

Document A1 fix for blocked-client disconnect leaks

• Adds an Unreleased entry describing the disconnect-while-blocked leak, the peer-watch fix, and the pipelined-byte carry behavior. Notes a known gap for TLS connections under monoio where peer watching remains unsafe.

CHANGELOG.md

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 55 rules

Grey Divider


Remediation recommended

1. Flaky INFO response parsing 🐞 Bug ☼ Reliability
Description
tests/blocking_peer_eof.rs reads the INFO reply with a single TcpStream::read() and then searches
for "connected_clients:", so a short/partial read can make the test panic even when the server is
healthy. This can introduce intermittent CI failures for the new regression suite.
Code

tests/blocking_peer_eof.rs[R68-83]

+fn read_some(stream: &mut TcpStream) -> std::io::Result<String> {
+    let mut buf = [0u8; 8192];
+    let n = stream.read(&mut buf)?;
+    Ok(String::from_utf8_lossy(&buf[..n]).into_owned())
+}
+
+fn connected_clients(port: u16) -> u64 {
+    let mut c = connect(port);
+    send(&mut c, &["INFO", "clients"]);
+    let body = read_some(&mut c).expect("INFO reply");
+    for line in body.lines() {
+        if let Some(rest) = line.strip_prefix("connected_clients:") {
+            return rest.trim().parse().unwrap_or(0);
+        }
+    }
+    panic!("INFO clients has no connected_clients; got:\n{body}");
Relevance

●●● Strong

Team previously accepted fixing test flakiness by properly reading/parsing RESP replies instead of
single-read assumptions (PR #65).

PR-#65

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The test’s read_some() does exactly one read() and returns whatever bytes happened to arrive;
connected_clients() panics if the substring isn’t present in that single chunk. Prior accepted
bugfix work in the test suite addressed the same root cause by parsing bulk-string replies fully
instead of assuming single-read completeness.

tests/blocking_peer_eof.rs[68-83]
PR-#65

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`connected_clients()` assumes the full RESP reply to `INFO clients` arrives in a single `read()` via `read_some()`. TCP is a byte stream and partial reads are allowed, so this can miss the `connected_clients:` line and panic, making the test flaky.

## Issue Context
`INFO` replies are RESP Bulk Strings (`$<len>\r\n...payload...\r\n`). The test should read/parse the complete frame (or at least read until the full bulk payload is received) before scanning for `connected_clients:`.

## Fix Focus Areas
- tests/blocking_peer_eof.rs[68-83]

## Suggested fix
Replace `read_some()`/`connected_clients()` response handling with RESP-aware reading:
- read the first line to determine reply type
- if it is `$<len>`: read exactly `<len> + 2` bytes (payload + trailing CRLF)
- then scan the complete payload for `connected_clients:`
Alternatively, reuse an existing RESP test helper/parser if the repo already has one.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. find_moon_binary() without MOON_BIN 📘 Rule violation ▣ Testability
Description
The new integration test spawns moon via common::find_moon_binary(), which falls back to guessed
paths when MOON_BIN is unset. This violates the requirement that integration tests must explicitly
use MOON_BIN for selecting the server binary, to avoid non-reproducible/flake-prone test runs.
Code

tests/blocking_peer_eof.rs[R28-30]

+fn spawn(dir: &std::path::Path, port: u16, shards: &str) -> Child {
+    Command::new(common::find_moon_binary())
+        .args([
Relevance

● Weak

Similar “require MOON_BIN; avoid find_moon_binary fallbacks” suggestions were rejected in PRs #421
and #216.

PR-#421
PR-#216

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 992389 requires integration tests that spawn moon to set and use MOON_BIN
explicitly, and explicitly flags calling find_moon_binary()-style fallbacks as non-compliant. The
new test calls common::find_moon_binary() when spawning the server, and the helper is implemented
with multiple fallback paths beyond MOON_BIN.

Rule 992389: Integration tests must set MOON_BIN explicitly for server binaries
tests/blocking_peer_eof.rs[28-30]
tests/blocking_peer_eof.rs[115-120]
tests/common/mod.rs[140-166]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`tests/blocking_peer_eof.rs` starts the server using `common::find_moon_binary()`, which allows falling back to `CARGO_BIN_EXE_moon` / `target/{release,debug}/moon` when `MOON_BIN` is not set. The compliance requirement is to make integration tests explicitly depend on `MOON_BIN` for server binary selection.

## Issue Context
The shared helper `tests/common/mod.rs::find_moon_binary()` intentionally implements fallbacks, but the compliance rule requires integration tests to avoid this pattern and fail fast if `MOON_BIN` is not provided.

## Fix Focus Areas
- tests/blocking_peer_eof.rs[28-30]
- tests/blocking_peer_eof.rs[115-120]
- tests/common/mod.rs[140-166]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +68 to +83
fn read_some(stream: &mut TcpStream) -> std::io::Result<String> {
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf)?;
Ok(String::from_utf8_lossy(&buf[..n]).into_owned())
}

fn connected_clients(port: u16) -> u64 {
let mut c = connect(port);
send(&mut c, &["INFO", "clients"]);
let body = read_some(&mut c).expect("INFO reply");
for line in body.lines() {
if let Some(rest) = line.strip_prefix("connected_clients:") {
return rest.trim().parse().unwrap_or(0);
}
}
panic!("INFO clients has no connected_clients; got:\n{body}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Flaky info response parsing 🐞 Bug ☼ Reliability

tests/blocking_peer_eof.rs reads the INFO reply with a single TcpStream::read() and then searches
for "connected_clients:", so a short/partial read can make the test panic even when the server is
healthy. This can introduce intermittent CI failures for the new regression suite.
Agent Prompt
## Issue description
`connected_clients()` assumes the full RESP reply to `INFO clients` arrives in a single `read()` via `read_some()`. TCP is a byte stream and partial reads are allowed, so this can miss the `connected_clients:` line and panic, making the test flaky.

## Issue Context
`INFO` replies are RESP Bulk Strings (`$<len>\r\n...payload...\r\n`). The test should read/parse the complete frame (or at least read until the full bulk payload is received) before scanning for `connected_clients:`.

## Fix Focus Areas
- tests/blocking_peer_eof.rs[68-83]

## Suggested fix
Replace `read_some()`/`connected_clients()` response handling with RESP-aware reading:
- read the first line to determine reply type
- if it is `$<len>`: read exactly `<len> + 2` bytes (payload + trailing CRLF)
- then scan the complete payload for `connected_clients:`
Alternatively, reuse an existing RESP test helper/parser if the repo already has one.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

The infinite-wait select! behind BLPOP/BRPOP/BLMOVE/BZPOPMIN/BZPOPMAX/
BLMPOP/BZMPOP/BRPOPLPUSH had exactly two arms, reply_rx and shutdown.
Nothing watched the socket, so `BLPOP key 0` followed by a disconnect
leaked the handler task, the WaitEntry, the client-registry entry and the
maxclients slot forever: infinite waiters carry `deadline: None` so the W6
deadline sweep skips them, and `timeout` exempts blocked clients by design
(Redis clientsCronHandleTimeout parity). A few thousand throwaway
connections wedged the server until restart — one unauthenticated command,
not reapable by disconnect, CLIENT KILL, or timeout.

The wait now watches the peer. Two mechanisms, because the runtimes have
different cancellation semantics:

  * tokio — AsyncReadExt::read_buf is documented cancel-safe (a losing
    select! branch reads nothing), so the watch is a plain read arm.
  * monoio — a read owns its buffer, so a read arm losing the race could
    take client bytes down with the cancelled op. The watch is instead a
    two-step: a non-consuming readiness poll inside the select!, then the
    drain OUTSIDE it, where nothing can cancel it. Level-triggered
    readiness guarantees that read completes at once. Racing the reply is
    harmless — reply_tx is a flume bounded(1), so a wake delivered during
    the drain waits in the channel for the next loop turn.

A vanished client tears down every registration it held, local and remote,
through the same cleanup the timeout path uses. CLIENT KILL of a blocked
client flows through this too, since it closes the fd with shutdown(2).

Only a real error is death: EWOULDBLOCK (the legacy driver can hand back a
readiness that yields nothing), EINTR and ECANCELED all re-arm. Misreading
any of them as EOF would drop a healthy blocked client — the exact inverse
of the bug being fixed.

Bytes a client legally pipelines behind its blocking command are carried
into the parse stream instead of being consumed and dropped. That carry
needed a matching fix in both read loops: pre-A1 those bytes sat in the
kernel and the next read returned them at once, but now they are already
in read_buf, so parking in read() first would hang the pipelined command.
Both handlers skip exactly one read after a blocking command leaves
unparsed input; a carry that is only a partial frame parses to nothing and
the loop comes straight back with the flag cleared.

Known gap: TLS keeps the pre-A1 behaviour. The vendored monoio-rustls
read_inner loops on read_io until rustls yields plaintext, so a
post-readiness read can park inside a partial record — the one thing the
cancel-free design must never do. TLS at least requires a completed
handshake, so it is not the unauthenticated one-command DoS this fixes.
IdleParkRead::peer_readable defaults to a never-resolving future, which is
what keeps those streams on exactly the old path.

The carry buffer gets its own 64 MiB ceiling rather than inheriting the
read loop's (which has none — that is finding C2, a separate fix).

Verification (Linux VM, both runtimes):
  * RED proven independently per runtime by disabling only that runtime's
    peer watch: 24 disconnected single-key clients left connected_clients
    at 25, 16 multi-key at 17. The two positive-control tests stayed green
    in both RED runs, so the failures are the defect, not the harness.
  * tests/blocking_peer_eof.rs — 5 e2e, green on monoio and tokio:
    single-key reap, multi-key reap across 4 shards (exercises the remote
    BlockCancel unwind and asserts no ghost waiter swallows a later push),
    pipelining-while-blocked, partial-frame carry, and idle-blocked-client
    stays blocked and wakeable.
  * fmt; clippy -D warnings on default and runtime-tokio,jemalloc; lib
    tests 4471 monoio / 3633 tokio; blocking_ghost_waiter,
    blocking_list_timeout (incl. --ignored), parked_idle_parity,
    idle_downshift_parity, tls_idle_downshift_parity all still green.

Refs: tmp/C10K-HARDENING-REVIEW.md finding A1. Stacked on #426 (A2-A6) —
same select! blocks.

author: Tin Dang
@TinDang97
TinDang97 force-pushed the fix/c10k-blocked-client-eof branch from 7421a0d to 42b7e36 Compare August 6, 2026 14:34
@TinDang97
TinDang97 merged commit 72b1bd4 into main Aug 6, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant