Skip to content

fix(conn): one byte no longer hides a connection from the idle sweep (c10k D2) - #434

Merged
TinDang97 merged 1 commit into
mainfrom
fix/c10k-d2-partial-frame-park
Aug 6, 2026
Merged

fix(conn): one byte no longer hides a connection from the idle sweep (c10k D2)#434
TinDang97 merged 1 commit into
mainfrom
fix/c10k-d2-partial-frame-park

Conversation

@TinDang97

Copy link
Copy Markdown
Collaborator

Stacked on #431 (fix/c10k-resource-limits). Review that one first; this diff is the 5 files below.

The bug

The stage-2 park predicate required an empty read_buf:

// handler_monoio/mod.rs:868 (before)
&& read_buf.is_empty()
&& write_buf.is_empty()

A single * — the first character of every RESP array — parses to nothing, so it sits in read_buf forever. That made the connection unparkable, which dropped it into the handler's unregistered plain read at mod.rs:921. Nothing on that path carries a sweep handle, so the connection became invisible to the idle sweep for as long as the attacker left the socket open, holding its full stage-2 working set.

Cost to the attacker: one byte and one socket. No authentication, no further traffic, no CPU. At 1M connections that is roughly 10–15 GB which no amount of idle time reclaims.

The fix

Unparsed input no longer blocks a park. The remainder is carried in MigratedConnectionState::read_buf_remainder and re-parsed on resume — exactly what a migrating connection already did, and what downshift_idle_buffers was already documented to preserve ("a non-empty read_buf holds a partial frame that must survive the re-park").

Deliberately not capped — and my first attempt got this wrong

My first version capped the carried remainder at 512 bytes. That was wrong, and the reasoning is worth stating because the capped version looks more conservative:

read_buf is already bounded by client_query_buffer_limit, enforced after every read arm ahead of both parse paths (mod.rs:1001, from cluster C). A second arbitrary cap would only relocate the attack into the gap between the two limits — send 513 bytes instead of 1, pay 513× more, still free, still invisible. A partial fix here would read as a fix while leaving the vector open.

no_remainder_size_blocks_a_park fails if anyone reintroduces a threshold.

write_buf stays a strict emptiness check. The buffers are not symmetric: unparsed input is carried and re-parsed, but a pending reply is carried nowhere, so parking with one would silently drop bytes the client is owed.

Two properties verified, not assumed

  • Parking cannot strand a complete command. mod.rs:789's carried_input guard skips the read whenever unparsed input is present, and its own comment records that a partial-frame carry "parses to nothing ... and the flag is already cleared". So reaching the read arms with a non-empty read_buf provably means an incomplete frame.
  • The resume path is plumbed: spawn_resumed_parked_connread_buf_remainderinitial_read_bufread_buf.

Why the predicate moved out of handler_monoio

handler_monoio is #[cfg(feature = "runtime-monoio")], and every CI test job builds --no-default-features --features runtime-tokio. Tests written in the handler tree would never have run in CI. A security predicate guarded by invisible tests is guarded by nothing, so the policy lives in a new runtime-agnostic park_policy module.

Scope and honest limits

This closes the D2 instance, not the class. Connections non-parkable for other reasons (in_multi, saw_replconf, TLS-unsafe) still reach the same unregistered read. Those need a live session rather than one anonymous byte. Registering the fallback read itself closes all of them — that is the agreed next change.

The integration test is a regression guard, not a red test. The bug was invisible from the client's side: wire behaviour was identical either way, only the footprint differed. There is no parked-connection gauge to assert against, so the test pins the half that is observable — a command split across a park must reassemble into exactly one command. Adding that gauge would be a feature, not part of this fix.

Gates

park_policy 4/4 under the tokio feature set CI runs · parked_idle_parity 6/6 · clippy clean on both feature sets · fmt · audit-unsafe.sh 0 missing · audit-unwrap.sh 0.

Refs #20

@coderabbitai

coderabbitai Bot commented Aug 6, 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: 32 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: fad58b7c-dae2-4c64-b707-9390aede2482

📥 Commits

Reviewing files that changed from the base of the PR and between b363340 and ebe5786.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/mod.rs
  • src/server/conn/park_policy.rs
  • tests/parked_idle_parity.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

Fix idle-park predicate so partial input can’t evade idle sweep (c10k D2)

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Allow stage-2 parking even with unparsed input, preventing idle-sweep invisibility.
• Preserve partial-frame remainder across park/resume via existing migrated state plumbing.
• Add unit/integration tests covering the one-byte partial-frame attack and carry correctness.
Diagram

graph TD
H["Monoio conn handler"] --> P["Stage-2 park gate"] --> Pol["park_policy predicate"] -->|"write_buf empty"| Park["ParkIdle"] --> St[("Migrated state\nread remainder")] --> R["Resume parked conn"] --> H
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Register the non-parked stage-2 read path with the idle sweep
  • ➕ Keeps park predicate semantics unchanged
  • ➕ Ensures sweep visibility even when parking is disallowed
  • ➖ More invasive: must thread sweep registration into the plain-read path
  • ➖ Higher risk of registration lifecycle bugs/cancellation edge cases
2. Cap carried read-buffer remainder (e.g., 512B)
  • ➕ Bounds parked-state memory per connection
  • ➖ Does not close the DoS vector; attacker sends cap+1 bytes
  • ➖ Creates a second limit that can diverge from the already-enforced client_query_buffer_limit
3. Force parsing progress before allowing park
  • ➕ Could keep read_buf empty at park time without carrying remainder
  • ➖ Requires deeper changes to parse/read loop structure
  • ➖ Risks extra attacker-controlled CPU work and correctness complexity

Recommendation: Keep the PR’s approach: make parking independent of unparsed input size and rely on the existing upstream read-buffer ceiling (client_query_buffer_limit) for bounding. This fully closes the ‘one byte pins full working set and evades sweep’ vector, while preserving correctness by carrying read_buf into MigratedConnectionState::read_buf_remainder and rehydrating it on resume. Maintain strict write_buf emptiness since pending replies are not carried across park and would be dropped.

Files changed (5) +194 / -4

Bug fix (2) +125 / -4
mod.rsUse park_policy so read remainder doesn’t block stage-2 park +17/-4

Use park_policy so read remainder doesn’t block stage-2 park

• Replaces the inline 'read_buf.is_empty() && write_buf.is_empty()' park gate with 'park_policy::remainder_allows_park(read_len, write_len)', allowing parking with partial, unparsed input. Continues to carry the read remainder via 'read_buf.split()' into 'MigratedConnectionState' when parking.

src/server/conn/handler_monoio/mod.rs

park_policy.rsNew shared stage-2 park predicate + unit tests (c10k D2) +108/-0

New shared stage-2 park predicate + unit tests (c10k D2)

• Introduces 'remainder_allows_park(read_buf_len, write_buf_len)' that ignores read remainder and requires an empty write buffer. Adds unit tests pinning the security invariant (no remainder size blocks a park) and correctness invariant (pending reply never parks).

src/server/conn/park_policy.rs

Refactor (1) +1 / -0
mod.rsAdd park_policy module export +1/-0

Add park_policy module export

• Adds 'pub mod park_policy;' so the policy predicate is shared outside monoio-only handler code and can be tested across feature sets.

src/server/conn/mod.rs

Tests (1) +52 / -0
parked_idle_parity.rsIntegration regression: partial RESP frame survives park/resume +52/-0

Integration regression: partial RESP frame survives park/resume

• Adds a test that sends a single '*' byte, waits for park/sweep, then completes a GET frame and asserts the correct response. Verifies the carried remainder is neither dropped nor duplicated and that the connection remains usable afterward.

tests/parked_idle_parity.rs

Documentation (1) +16 / -0
CHANGELOG.mdDocument c10k D2 idle-sweep invisibility fix +16/-0

Document c10k D2 idle-sweep invisibility fix

• Adds a security changelog entry describing how a 1-byte RESP prefix prevented stage-2 parking and bypassed the idle sweep. Documents remainder-carry behavior, why it is intentionally not size-capped, and why 'write_buf' still blocks parking.

CHANGELOG.md

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. Stale park-cap comment 🐞 Bug ⚙ Maintainability
Description
The stage-2 sweep-cancel comment claims the park predicate caps read_buf via “MAX_PARKED_REMAINDER”,
but the new park_policy::remainder_allows_park() intentionally does not cap read_buf_len at all.
This mismatch can mislead future maintenance/security reviews about what bounds exist at park time.
Code

src/server/conn/handler_monoio/mod.rs[R908-910]

+                        // read_buf holds at most MAX_PARKED_REMAINDER bytes
+                        // (predicate) and `read_buf.split()` below carries
+                        // them into the parked state, so a partial frame
Relevance

●●● Strong

Misleading/stale comment about bounds; repo historically accepts comment/documentation correctness
tweaks.

PR-#71

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The handler comment asserts a predicate-enforced MAX_PARKED_REMAINDER bound, but the predicate
implementation explicitly ignores read_buf_len and only checks write_buf_len==0, so no such cap
exists.

src/server/conn/handler_monoio/mod.rs[865-924]
src/server/conn/park_policy.rs[49-56]

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

### Issue description
`handle_connection_sharded_monoio`’s stage-2 sweep-cancel path contains a new comment stating that `read_buf` is capped by `MAX_PARKED_REMAINDER` via the park predicate. After this PR, the park predicate is `park_policy::remainder_allows_park(read_buf_len, write_buf_len)`, which deliberately **does not** cap `read_buf_len` (it only enforces `write_buf_len == 0`). The comment is now incorrect and should be updated to reflect the actual bound (upstream `client_query_buffer_limit`) and the intentional “no per-park cap” policy.

### Issue Context
This area is security-sensitive (idle sweep / park behavior). Incorrect comments here can cause reviewers/maintainers to reason about the wrong invariant.

### Fix Focus Areas
- src/server/conn/handler_monoio/mod.rs[905-913]
- src/server/conn/park_policy.rs[49-56]

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


Grey Divider

Context used
✅ Compliance rules (platform): 55 rules

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

Qodo Logo

Comment on lines +908 to +910
// read_buf holds at most MAX_PARKED_REMAINDER bytes
// (predicate) and `read_buf.split()` below carries
// them into the parked state, so a partial frame

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

1. Stale park-cap comment 🐞 Bug ⚙ Maintainability

The stage-2 sweep-cancel comment claims the park predicate caps read_buf via “MAX_PARKED_REMAINDER”,
but the new park_policy::remainder_allows_park() intentionally does not cap read_buf_len at all.
This mismatch can mislead future maintenance/security reviews about what bounds exist at park time.
Agent Prompt
### Issue description
`handle_connection_sharded_monoio`’s stage-2 sweep-cancel path contains a new comment stating that `read_buf` is capped by `MAX_PARKED_REMAINDER` via the park predicate. After this PR, the park predicate is `park_policy::remainder_allows_park(read_buf_len, write_buf_len)`, which deliberately **does not** cap `read_buf_len` (it only enforces `write_buf_len == 0`). The comment is now incorrect and should be updated to reflect the actual bound (upstream `client_query_buffer_limit`) and the intentional “no per-park cap” policy.

### Issue Context
This area is security-sensitive (idle sweep / park behavior). Incorrect comments here can cause reviewers/maintainers to reason about the wrong invariant.

### Fix Focus Areas
- src/server/conn/handler_monoio/mod.rs[905-913]
- src/server/conn/park_policy.rs[49-56]

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

…(c10k D2)

The stage-2 park predicate required an EMPTY `read_buf`. A single `*` — the
first character of every RESP array — parses to nothing and therefore sits in
`read_buf` forever, which made the connection unparkable, which dropped it into
the handler's UNREGISTERED plain read at mod.rs:921. Nothing on that path
carries a sweep handle, so the connection became invisible to the idle sweep
for as long as the attacker left the socket open, holding its full stage-2
working set. Cost to the attacker: one byte and one socket, no authentication,
no further traffic. At 1M connections, roughly 10-15 GB that no amount of idle
time reclaims.

Unparsed input no longer blocks a park. The remainder is carried in
`MigratedConnectionState::read_buf_remainder` and re-parsed on resume — exactly
what a migrating connection already did, and what `downshift_idle_buffers` was
already documented to preserve.

DELIBERATELY NOT CAPPED. The first version of this fix capped the carried
remainder at 512 bytes; that was wrong and is worth recording. `read_buf` is
already bounded upstream by `client_query_buffer_limit`, enforced after every
read arm ahead of both parse paths. A second, arbitrary cap would only relocate
the attack into the gap between them — send 513 bytes instead of 1 and the
connection is invisible again, 513x more expensive and still free. A partial
fix here would read as a fix while leaving the vector open. `no_remainder_size_
blocks_a_park` fails if anyone reintroduces a threshold.

`write_buf` stays a strict emptiness check. The buffers are not symmetric:
unparsed input is carried and re-parsed, but a pending reply is carried nowhere,
so parking with one would silently drop bytes the client is owed.

Two properties verified rather than assumed:

  - Parking cannot strand a COMPLETE command. mod.rs:789's `carried_input`
    guard skips the read whenever unparsed input is present, and its own
    comment records that a partial-frame carry "parses to nothing ... and the
    flag is already cleared". So reaching the read arms with a non-empty
    `read_buf` provably means an incomplete frame.
  - The resume path is plumbed: spawn_resumed_parked_conn takes
    `read_buf_remainder` into `initial_read_buf` into `read_buf`.

The predicate lives in a new runtime-agnostic `park_policy` module rather than
in `handler_monoio`, because `handler_monoio` is `#[cfg(feature =
"runtime-monoio")]` and EVERY CI test job builds `--no-default-features
--features runtime-tokio`. Tests written in the handler tree would never have
run in CI, and a security predicate guarded by invisible tests is guarded by
nothing.

Scope: this closes the D2 instance, not the class. Connections non-parkable for
other reasons (in_multi, saw_replconf, TLS-unsafe) still reach the same
unregistered read. Those need a live session rather than one anonymous byte;
registering the fallback read itself would close all of them and is left as its
own change.

Testing note: the new integration test is a regression guard, not a red test.
The bug was invisible from the client's side — wire behaviour was identical
either way, only the footprint differed — so it pins the half that IS
observable: a command split across a park must reassemble into exactly one
command.

Gates: park_policy 4/4 under the tokio feature set CI runs, parked_idle_parity
6/6, clippy clean on BOTH feature sets, fmt, audit-unsafe 0 missing,
audit-unwrap 0.

Refs #20

author: Tin Dang
@TinDang97
TinDang97 force-pushed the fix/c10k-d2-partial-frame-park branch from ab552ea to ebe5786 Compare August 6, 2026 14:58
@TinDang97
TinDang97 changed the base branch from fix/c10k-resource-limits to main August 6, 2026 14:58
@TinDang97
TinDang97 merged commit 4c25c9d into main Aug 6, 2026
1 check 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