Skip to content

feat(runtime): multiplex TCP response streams - #11918

Open
jthomson04 wants to merge 3 commits into
mainfrom
jthomson04/multiplexed-tcp-response-v2
Open

feat(runtime): multiplex TCP response streams#11918
jthomson04 wants to merge 3 commits into
mainfrom
jthomson04/multiplexed-tcp-response-v2

Conversation

@jthomson04

@jthomson04 jthomson04 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • hard-replace dedicated worker-to-frontend response sockets with the versioned tcp_response_mux_v1 transport
  • maintain four persistent response connections per worker-process/frontend pair while keeping request streams on their existing dedicated sockets
  • multiplex logical responses by UUID with stream-local cancellation, bounded admission, per-stream byte credits, and independent slow-consumer backpressure
  • batch response data for up to 1 ms by default to reduce TCP writes and per-packet operating-system overhead; interval 0 enables opportunistic batching and the maximum is 100 ms
  • simplify the hot path to direct bounded writer ingress with ordered and urgent lanes, removing trait-object dispatch, per-stream writer mutexes, writer-owned stream maps, ready rings, and round-robin scheduler bookkeeping
  • use a compact binary codec from the first byte of each response connection and remove the JSON handshake, connection-level credits, cumulative connection acknowledgements, and physical connection IDs
  • keep operational metrics for connections, streams, writes, admission stalls, resets, reconnects, failure fanout, and opt-in Linux TCP segment diagnostics

This is a coordinated transport cutover: response connections do not fall back to the former dedicated protocol. Frontends and workers must be upgraded together.

Design

Protocol and batching

MuxCodec handles the entire connection. ConnectionHello contains the protocol version and frontend UUID, and the frontend returns an empty ConnectionReady. Subsequent frames use a 21-byte header containing payload length, frame kind, and logical-stream UUID.

Successful prologues and resets have empty payloads. Failed prologues carry UTF-8 error text. Prologue and Reset use an urgent lane; Data and End use an ordered lane. End is the sole completion acknowledgement and flushes preceding data for its stream.

Data flushes at the earliest of:

  • DYN_TCP_RESPONSE_BATCH_INTERVAL_MS (default 1 ms)
  • DYN_TCP_RESPONSE_BATCH_MAX_BYTES (default 65536)
  • DYN_TCP_RESPONSE_BATCH_MAX_FRAMES (default 64)
  • an urgent frame becoming ready

Backpressure and ownership

Each stream has eight ordered-frame permits and byte credits controlled by DYN_TCP_RESPONSE_STREAM_WINDOW_BYTES (default 262144). A connection-wide queued-byte semaphore bounds userspace memory. TCP supplies physical-connection backpressure; there is no additional connection credit window.

The connection writer exclusively owns batching and socket writes. Producers submit concrete frames through bounded channels without trait-object or boxed-future dispatch. Frontend Data routing stays in a connection-local HashMap, so decoded data frames do not touch the global lifecycle map.

Pending and active response entries share one lifecycle DashMap, with pending-to-active replacement performed atomically. Host pools live for the worker-process lifetime, and one process-wide maintenance task warms new frontend pools to four connections and repairs failed connections.

Performance qualification

Final simplification versus mandatory-mux baseline

Three interleaved, freshly restarted repetitions at 8 workers / concurrency 1,024, ISL 1,024, OSL 256, and mocker speedup 10:

Metric 06d22825e9 Final simplification Delta
Request throughput 953.5 req/s 978.2 req/s +2.59%
Output throughput 250,185 tok/s 256,659 tok/s +2.59%
TTFT p50 205.66 ms 199.41 ms -3.04%
TTFT p95 434.38 ms 443.16 ms +2.02%
Request latency p95 1,066.88 ms 1,060.93 ms -0.56%
Frontend CPU/request 2.545 ms 2.473 ms -2.84%
Worker CPU/request 2.159 ms 1.877 ms -13.07%
Response writes/token 0.03155 0.03214 +1.88%
Response sockets 32 32 unchanged

All six runs completed without request errors, cancellations, resets, or connection-failure fanout. Additional very-large-scale qualification also preserved performance after the final simplification.

Packet reduction versus dedicated responses

Earlier 16-worker / concurrency 2,048 qualification of the 1 ms mux mode measured 0.04705 response segments/token and 0.04708 response writes/token, versus 1.04417 segments/token and an estimated 1.00762 writes/token for dedicated responses. That is 22.2x fewer response segments and 21.4x fewer response writes, with exactly 64 mux response sockets. The final simplification changed writes/token by only +1.88% relative to the mandatory-mux baseline.

For comparison, the 5 ms interval produced larger batches (35.8 frames/write versus 21.5 at 1 ms) and 37.1x fewer response segments than dedicated responses, quantifying the additional packet-reduction/latency tradeoff available through configuration.

Validation

  • cargo fmt --all
  • git diff --check
  • cargo test -p dynamo-runtime --lib pipeline::network::tcp -- --nocapture — 50 passed
  • cargo test -p dynamo-runtime --lib metrics::response_mux -- --nocapture — 1 passed
  • cargo test -p dynamo-runtime --lib -- --skip transport_resolution_falls_back_when_selected_instance_disappears — 480 passed, 2 ignored, 1 known unrelated hang skipped
  • cargo clippy -p dynamo-runtime --all-targets -- -D warnings
  • pre-commit run --all-files
  • fern check — 0 errors
  • fern docs broken-links

The checked-in benchmarks/frontend/scripts/run_perf.sh is unchanged by this PR.

Summary by CodeRabbit

  • New Features

    • Added TCP response multiplexing to support multiple logical response streams over persistent connections.
    • Added configurable batching, flow control, connection pooling, and response-stream lifecycle handling.
    • Added Prometheus metrics for response-multiplexing activity, throughput, stalls, resets, and connection health.
    • Added optional TCP packet diagnostics and environment-based tuning controls.
  • Documentation

    • Documented the response multiplexing protocol, configuration options, batching, and compatibility requirements.

@datadog-official

datadog-official Bot commented Jul 20, 2026

Copy link
Copy Markdown

Pipelines

⚠️ Warnings

🚦 5 Pipeline jobs failed

PR | backend-status-check   View in Datadog   GitHub Actions

PR | dynamo-runtime / test / sequential cuda13.0, amd64   View in Datadog   GitHub Actions

PR | dynamo-runtime / test / sequential cuda13.0, arm64   View in Datadog   GitHub Actions

View all 5 failed jobs.

ℹ️ Info

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 43.98% (+2.10%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 4cae994 | Docs | Give us feedback!

@jthomson04
jthomson04 marked this pull request as ready for review July 21, 2026 16:05
@jthomson04
jthomson04 requested review from a team as code owners July 21, 2026 16:05

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@jthomson04

Copy link
Copy Markdown
Contributor Author

Code review

Full read of the diff (all 15 files) plus verification of call sites and lint behavior.

Overview

Opt-in, versioned TCP response-mux: 4 persistent connections per worker-process/frontend pair, logical streams multiplexed by UUID, HTTP/2-like framing with per-stream + per-connection byte credits, cumulative connection ACKs, a priority lane for control frames, fair round-robin scheduling (quantum 8), and up-to-5 ms cross-stream write batching. Dedicated transport retained for rollback. Overall well engineered: strict frame validation, credit-conserving flow control, bounded-cardinality metrics, and a genuinely strong test suite.

Correctness — one significant bug

1. blocked_data can be silently overwritten in the writer task → frame loss (high severity).
In writer_task (lib/runtime/src/pipeline/network/tcp/mux/client.rs), the scheduler-quantum loop can park a popped-but-unwritable frame in blocked_data (~L696 batch-byte-limit hold, ~L710 connection-credit NoPermits hold) and set held_for_next_turn. Execution then falls through to the batching deadline loop (~L738), whose condition (first_is_data && !force_flush && batch.len() < max_frames && batch_bytes < max_bytes) does not check blocked_data.is_none(). That loop can pop a frame from another ready stream and hit the same limit/credit paths (~L799, ~L811), assigning blocked_data = Some(...) again — dropping the previously held command.

  • Failure scenario: mux enabled, ≥2 active streams, connection credits exhausted (or batch near batch_max_bytes). Stream A's Data frame is held; the deadline loop pops stream B's frame and also fails to acquire credits → A's frame is destroyed. It was already removed from A's pending queue with counters decremented, so nothing retries it: the client receives a response with a silently missing chunk (the stream still Ends "cleanly"), and A's stream-window credits leak permanently (frontend never sees the bytes, never replenishes), which can later stall the producer. If the lost frame is an End, finish() hangs on its oneshot.
  • Credit exhaustion under high concurrency is exactly the regime this feature targets, so this should block merge despite the flag.
  • Suggested fix: add blocked_data.is_none() to the deadline-loop condition (and skip it when held_for_next_turn is set) so at most one frame is parked per cycle. A debug_assert!(blocked_data.is_none()) before each assignment would have caught this. Needs a regression test (two streams contending for exhausted connection credits in one batch cycle).

Correctness — smaller issues

2. Normal client cancellation now surfaces as a handler error. push_handler.rs calls publisher.finish() after pump_response_stream and propagates failure as PipelineError::Generic. When the frontend kills a stream (routine client disconnect), the worker's stream state is already removed, so finish() fails → every ordinary cancellation returns an error from handle_payload, inflating error metrics/logs. Consider tolerating closed-stream errors when context.is_killed()/is_stopped().

3. One malformed prologue tears down the whole shared connection. In process_response_mux (server.rs), a serde_json failure on a Prologue payload uses ? and kills the physical connection, resetting every co-located stream. Codec errors must be connection-fatal (framing lost), but a bad prologue payload is stream-scoped — a Reset for just that stream would be more robust.

4. Mixed-fleet rollout hazard. Enablement is per-process via env with no capability negotiation — the frontend unilaterally hands out mux connection info, and a worker with mux disabled hard-fails those requests. Rolling deploys with mixed config break requests. Deserves a prominent deploy note; the new env vars also aren't documented anywhere yet.

5. Handshake buffer discard is safe only by convention. Both sides call FramedRead::into_inner() to switch codecs, which discards bytes buffered beyond the handshake frame. Safe today because neither side sends more until the ack round-trip completes, but fragile — a comment stating the invariant (or draining read_buffer() into the new FramedRead) would prevent a future desync.

Code quality / style

  • Clippy claim doesn't reproduce: the End arm in process_response_mux contains an else { if ... } block that trips clippy::collapsible_else_if under -D warnings (verified the lint fires on this pattern; no clippy.toml or [workspace.lints] allow exists). Worth re-running the stated clippy command on the branch tip.
  • per_frame_metrics_enabled() always returns true — wire it to config or drop the indirection.
  • writer_task is ~400 lines with triple-nested loops and four blocked_data assignment sites — where bug Update README.md #1 hides. Extracting a batch-assembler that owns the hold slot, batch limits, and credit acquisition would make the invariant enforceable in one place.
  • ConnectionHandshake::RequestStream is a dead variant; if future-proofing, say so in a comment.
  • HostPool::connection() calls warm() on every stream creation, spawning a short-lived task even when the pool is full — check healthy_connections().len() < pool_size before the swap.
  • Idle-TTL cleanup can cancel a HostPool concurrently with a fresh create_response_stream → one-off request failure at the idle→active transition. Acceptable, but known.
  • The hand-rolled LinuxTcpInfoThroughDataSegments layout checks out (field offsets verified against the kernel's tcp_info through tcpi_data_segs_out), and the getsockopt length check is sound. A one-line comment citing the kernel struct would help maintainers.

Performance

  • One tradeoff worth documenting: at low concurrency, a lone stream's Data frames each wait up to batch_interval (default 5 ms) in the deadline loop before flushing — up to +5 ms inter-token latency per chunk on an otherwise idle pipe. Inherent to the design (userspace Nagle with TCP_NODELAY), but low-QPS latency-sensitive users should be pointed at DYN_TCP_RESPONSE_BATCH_INTERVAL_MS=0.
  • Data path adds per-frame Instant::now() calls and several histogram observations, unconditionally given the always-true gate. Pre-bound counters are a nice touch; consider actually gating the histograms.
  • Metrics use global Lazy statics + a process-global OnceCell — a second DistributedRuntime with a different registry won't get these metrics.

Test coverage

Strong: codec round-trips and rejections, config bounds, credit-cap invariants, cumulative-ACK monotonicity, waiter wakeups on close, End-bypasses-credit-exhaustion, cross-stream backpressure isolation, failure scoping with reconnect, 1,000 streams over exactly 4 connections. Gaps:

Security

No concerns beyond the existing transport's trust model. Handshake validates version and frontend UUID (misdirection protection, not authentication — same as the dedicated transport). Frame validation is strict in both codecs; the single unsafe block (TCP_INFO getsockopt) is length-checked, zero-initialized, and opt-in.

Verdict

Architecture, flow-control accounting, and test discipline are excellent. Requesting changes for #1 (frame loss — fix + regression test); recommend addressing #2 and the clippy discrepancy before merge. #3#5 and doc notes can be follow-ups.


🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

TCP response multiplexing

Layer / File(s) Summary
Runtime ownership and metrics
lib/runtime/src/config/environment_names.rs, lib/runtime/src/metrics/*, lib/runtime/src/distributed.rs, lib/runtime/src/component/endpoint.rs
Adds response-mux environment names and metrics, initializes the client pool in DistributedRuntime, and injects it into endpoint handlers.
Stream sender and receiver lifecycle
lib/runtime/src/pipeline/network.rs, lib/runtime/src/pipeline/network/egress/*, lib/runtime/src/pipeline/network/ingress/push_handler.rs, lib/runtime/src/pipeline/network/tcp/client.rs
Supports dedicated and multiplexed senders, credit-aware receivers, explicit stream completion, and updated ingress/egress request and response handling.
Mux protocol and configuration
lib/runtime/src/pipeline/network/tcp.rs, lib/runtime/src/pipeline/network/tcp/mux.rs, docs/design-docs/request-plane.md
Defines response-mux configuration, frame encoding and validation, connection metadata, TCP diagnostics, protocol tests, and design documentation.
Worker-side mux client
lib/runtime/src/pipeline/network/tcp/mux/client.rs
Adds pooled persistent connections, handshake processing, batching, flow control, logical stream lifecycle, failure handling, and integration tests.
Server registration and response delivery
lib/runtime/src/pipeline/network/tcp/server.rs
Adds mux-aware registration, handshake processing, stream routing, cancellation, cleanup, response forwarding, and mux integration tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it misses required template sections like Where should reviewer start and Related Issues. Add the missing template sections, especially Where should reviewer start and a required Related Issues choice with an issue link or no-issue confirmation.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 94.58% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: multiplexing TCP response streams.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
lib/runtime/src/pipeline/network/tcp/mux/client.rs (1)

89-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Gate per-frame metrics behind config per_frame_metrics_enabled() is hardcoded to true, so the per-frame histogram/counter paths run unconditionally on the hot writer/reader/send paths. If this is meant to mirror response_packet_metrics_enabled(), wire it to config instead of always paying the cost.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/runtime/src/pipeline/network/tcp/mux/client.rs` around lines 89 - 92,
Update per_frame_metrics_enabled() to read the appropriate configuration value,
mirroring response_packet_metrics_enabled(), instead of always returning true.
Ensure the existing per-frame metric paths remain enabled only when that
configuration flag is enabled.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@benchmarks/frontend/scripts/run_perf.sh`:
- Around line 764-771: Validate AIPERF_WORKERS_MAX and AIPERF_RECORD_PROCESSORS
as canonical non-negative integer strings before adding them to
_AIPERF_WORKER_ARGS or serializing them into config.json. Reject values such as
foo, 08, or other non-canonical inputs consistently for both CLI and environment
sources, including the corresponding handling around the alternate lines noted
in the comment.

---

Nitpick comments:
In `@lib/runtime/src/pipeline/network/tcp/mux/client.rs`:
- Around line 89-92: Update per_frame_metrics_enabled() to read the appropriate
configuration value, mirroring response_packet_metrics_enabled(), instead of
always returning true. Ensure the existing per-frame metric paths remain enabled
only when that configuration flag is enabled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: ee7ed488-6a16-40a3-92d2-339d5e1f8d94

📥 Commits

Reviewing files that changed from the base of the PR and between ee1d3b3 and c616b8e.

📒 Files selected for processing (15)
  • benchmarks/frontend/scripts/run_perf.sh
  • lib/runtime/src/component/endpoint.rs
  • lib/runtime/src/config/environment_names.rs
  • lib/runtime/src/distributed.rs
  • lib/runtime/src/metrics.rs
  • lib/runtime/src/metrics/response_mux.rs
  • lib/runtime/src/pipeline/network.rs
  • lib/runtime/src/pipeline/network/egress/addressed_router.rs
  • lib/runtime/src/pipeline/network/egress/tcp_client.rs
  • lib/runtime/src/pipeline/network/ingress/push_handler.rs
  • lib/runtime/src/pipeline/network/tcp.rs
  • lib/runtime/src/pipeline/network/tcp/client.rs
  • lib/runtime/src/pipeline/network/tcp/mux.rs
  • lib/runtime/src/pipeline/network/tcp/mux/client.rs
  • lib/runtime/src/pipeline/network/tcp/server.rs

Comment thread benchmarks/frontend/scripts/run_perf.sh Outdated
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
@jthomson04
jthomson04 force-pushed the jthomson04/multiplexed-tcp-response-v2 branch from c616b8e to 06d2282 Compare July 22, 2026 17:25
@jthomson04
jthomson04 requested a review from a team as a code owner July 22, 2026 17:25
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
@jthomson04

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/runtime/src/pipeline/network/tcp/server.rs`:
- Around line 782-792: Bound the handshake prefix acquisition in the server
handshake flow around the stream.peek loop: use a single buffered read such as
read_exact or fill_buf and wrap it in the established handshake timeout
mechanism. Preserve the connection-closed error for incomplete input, while
ensuring peers that send only 1–4 bytes and stall cannot keep the loop polling
indefinitely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: e1c87ba6-0f8a-4848-a01b-9cd5aaaceb2b

📥 Commits

Reviewing files that changed from the base of the PR and between 7dd6253 and 4cae994.

📒 Files selected for processing (15)
  • docs/design-docs/request-plane.md
  • lib/runtime/src/component/endpoint.rs
  • lib/runtime/src/config/environment_names.rs
  • lib/runtime/src/distributed.rs
  • lib/runtime/src/metrics.rs
  • lib/runtime/src/metrics/response_mux.rs
  • lib/runtime/src/pipeline/network.rs
  • lib/runtime/src/pipeline/network/egress/addressed_router.rs
  • lib/runtime/src/pipeline/network/egress/tcp_client.rs
  • lib/runtime/src/pipeline/network/ingress/push_handler.rs
  • lib/runtime/src/pipeline/network/tcp.rs
  • lib/runtime/src/pipeline/network/tcp/client.rs
  • lib/runtime/src/pipeline/network/tcp/mux.rs
  • lib/runtime/src/pipeline/network/tcp/mux/client.rs
  • lib/runtime/src/pipeline/network/tcp/server.rs

Comment on lines +782 to +792
let mut prefix = [0_u8; 5];
loop {
let read = stream.peek(&mut prefix).await?;
if read == 0 {
anyhow::bail!("connection closed before its handshake");
}
if read == prefix.len() {
break;
}
tokio::task::yield_now().await;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant area of the server implementation.
file='lib/runtime/src/pipeline/network/tcp/server.rs'
wc -l "$file"
sed -n '720,840p' "$file"

# Look for other handshake-prefix handling or timeout patterns in the repo.
rg -n "peek\\(&mut prefix\\)|HANDSHAKE|handshake timeout|yield_now\\(\\)" lib/runtime/src -S

Repository: ai-dynamo/dynamo

Length of output: 6414


🌐 Web query:

tokio AsyncReadExt peek buffered data immediate return partial prefix busy loop yield_now semantics

💡 Result:

In Tokio, the peek operation—typically accessed via TcpStream::peek (or ReadHalf::peek)—is designed to retrieve data from a socket without removing it from the kernel's receive queue [1]. Regarding your specific technical concerns: Partial Prefix and Immediate Return: The peek method will return as soon as it can retrieve any amount of data up to the requested buffer size [1]. If data is already available in the socket's receive buffer, it returns immediately with that data [1]. If no data is currently available, it will asynchronously wait (suspend the task) until data arrives or the connection is closed [2]. It is not guaranteed to fill the entire provided buffer; it returns the number of bytes actually peeked [1]. Busy Loop and yield_now: Using a loop that repeatedly calls peek without awaiting it or reacting to its asynchronous nature can lead to a busy loop (consuming 100% CPU) [3]. To prevent this, you must await the peek call. If you are implementing a custom polling mechanism or need to ensure other tasks get a chance to run, tokio::task::yield_now() can be used to yield execution back to the runtime, though the runtime's scheduler does not guarantee that other tasks will run before the current task is polled again [3]. Common Pitfalls: A known issue exists where peek on certain platforms (e.g., Windows) may behave unexpectedly or block after a read because the underlying I/O state is not correctly re-registered when peek would have returned WouldBlock [2]. If you encounter hanging or inefficient behavior, wrapping your reader in a BufReader and using fill_buf()/consume() is the recommended, more robust alternative to manual peek operations [2][4]. BufReader provides a buffered, predictable interface that avoids the complexities of raw socket peek semantics [2]. It is important to remember that peek does not consume data [1]. If you rely on peek to make decisions (like framing), you must subsequently use read to advance the stream position; otherwise, subsequent read calls will return the same data, leading to infinite loops if not handled correctly [1].

Citations:


Bound the handshake prefix read peek() returns immediately once any bytes are buffered, so a peer that sends 1–4 bytes and then stalls can keep this loop alive indefinitely and make it repeatedly yield/poll. Add a handshake timeout, and prefer a single buffered read (read_exact/fill_buf) instead of repeated peeks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/runtime/src/pipeline/network/tcp/server.rs` around lines 782 - 792, Bound
the handshake prefix acquisition in the server handshake flow around the
stream.peek loop: use a single buffered read such as read_exact or fill_buf and
wrap it in the established handshake timeout mechanism. Preserve the
connection-closed error for incomplete input, while ensuring peers that send
only 1–4 bytes and stall cannot keep the loop polling indefinitely.

@jthomson04

Copy link
Copy Markdown
Contributor Author

Review

Overview: This hard-replaces the per-request dedicated worker→frontend response socket with the versioned tcp_response_mux_v1 transport: 4 persistent connections per worker-process/frontend pair, logical streams keyed by UUID with a 21-byte binary header, per-stream byte credits, bounded admission, and ~1 ms data batching. The legacy prologue/Sentinel JSON machinery and its client-side reader/writer/monitor tasks are deleted, replaced by tcp/mux.rs (codec + config), tcp/mux/client.rs (worker pool), and a rewritten frontend accept path in tcp/server.rs. Coordinated cutover, no fallback.

I read the full diff and verified the load-bearing details against the codebase. Things that check out:

  • Protocol sniffing is sound. The listener dispatches on peeked byte 4 == ConnectionHello (8). A legacy TwoPartCodec frame's byte 4 is bits 24–31 of the big-endian header_len u64, which would require a ≥128 MB handshake header to collide — impossible in practice.
  • Credit accounting is symmetric. Worker debits MUX_HEADER_LEN + payload.len() (clamped to the window); frontend returns the same encoded_len per consumed item, chunked to ≤ window per WindowUpdate, and the worker clamps replenishment. The mailbox is sized to window / MUX_HEADER_LEN, so a credit-compliant worker can never hit mailbox_full.
  • Frame ordering is correct. Prologue (urgent) is enqueued before any data (ordered) with happens-before via mpsc::send, and the writer drains urgent first; pending_ordered is always consumed before ordered_rx.try_recv(), so the ordered lane stays FIFO. End terminates the batch and shares its physical write, so completion isn't delayed by the batch interval.
  • Reset loops are avoided (worker ignores unknown-stream Resets instead of replying), and the frontend-UUID check in ConnectionHello protects against address/port reuse cross-wiring.
  • The hand-rolled LinuxTcpInfoThroughDataSegments layout is correct — 8-byte header + 24 u32s + 4 u64s (offset 104, 8-aligned) + 6 u32s through tcpi_data_segs_out, matching Linux uapi; the len < guard handles old kernels.
  • No lock-order inversion between the State mutex and the response_directory DashMap (no path holds a shard guard while taking the state lock).

Issues are posted as inline comments. In severity order: (1) busy-wait on a partial handshake prefix in process_connection, (2) immortal host pools that reconnect to permanently-gone frontends every second forever, (3)/(4) try_send overflow escalating to whole-connection teardown on both sides, plus two nits (mailbox-sizing doc drift, ignored prologue send result).

Design & operational risks

  • Breaking, coordinated cutover. Mixed-version fleets fail response handshakes outright. The PR body and docs say this clearly, but it needs to be loud in release notes, and Kubernetes rolling upgrades of frontend/worker pools need an ordering story (or an accepted window of failed requests). This is the PR's biggest deployment risk.
  • RESPONSE_MUX_POOL_SIZE = 4 (and queue depths, connect timeout) are hardcoded while batching/window are env-tunable. Fine for now; this is the first knob someone will ask for if a workload wants more physical parallelism per pair.
  • +≤1 ms per-token latency at low concurrency is inherent to the default batch interval (a lone data frame waits the full interval). The benchmarks show net TTFT/p95 wins at high concurrency and =0 exists for opportunistic batching; the request-plane doc covers the tradeoff.

Test coverage

Good breadth: codec round-trips/validation, config parsing (100 ms cap, malformed rejection), ordering under batching, slow-consumer isolation across streams on one connection, failure scoping + pool repair, 1,000 streams over exactly 4 connections, pipelined raw-wire handshake with malformed-prologue isolation, legacy-protocol rejection. Gaps worth adding:

  • a slow/partial handshake prefix (would have caught the busy-wait),
  • worker credit violation (data beyond window) triggering the frontend reset path,
  • batch_max_bytes overflow exercising the pending_ordered stash,
  • pool behavior against a permanently-gone frontend.

Verdict

Implementation quality is high — the concurrency design (single writer owning the socket, permits carried inside OrderedCommand, atomic Pending→Active replacement, kill/stop via StreamReceiver hooks) is careful, and the deleted trait-object/scheduler machinery is a real simplification with benchmarked wins. I'd fix the handshake busy-wait before merge and treat host-pool eviction as a fast-follow with an issue filed; the fanout findings need at most comments. The coordinated-upgrade requirement is the main thing to socialize outside this PR.

🤖 Generated with Claude Code

if read == prefix.len() {
break;
}
tokio::task::yield_now().await;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Busy-wait on partial handshake prefix. peek returns Ready immediately whenever ≥1 byte is buffered, so a peer that sends 1–4 bytes and then stalls turns this loop into a hot spin (peek/yield_now forever), pinning CPU per such connection. Combined with the (pre-existing) lack of a handshake timeout, this is a cheap resource-exhaustion vector on an exposed port.

At minimum, wrap the prefix wait + handshake in a tokio::time::timeout; better, read_exact the 5 bytes and seed them into the FramedRead via read_buffer_mut() so there's no polling loop at all.

pool
}

fn start_maintenance(pool: &Arc<Self>) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Host pools are immortal and retry dead frontends forever. hosts never evicts, and this 1 s maintenance loop calls warm() on every under-filled pool indefinitely. A frontend that goes away permanently (rolling restarts mint a new server UUID and usually a new address) leaves a stale HostKey whose pool attempts a TCP connect roughly every second, forever — per stale frontend. Over weeks of frontend churn this accumulates unbounded connect churn, warn-log spam from warm(), and memory.

Suggest an idle policy: evict (or stop warming) a host pool after N minutes with zero active streams, or exponential backoff that gives up until a new stream references the host again.

.map_err(|_| anyhow!("response mux urgent writer stopped"))
}

fn try_send_urgent(&self, frame: MuxFrame) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Urgent-queue overflow escalates to whole-connection teardown. If the urgent queue (4,096) fills — e.g. a mass-cancellation burst where thousands of MuxResponseStreamSender::drops each enqueue a Reset while the writer is TCP-blocked — this fails the connection and kills every stream on it, and those kills produce further resets.

If the escalation is intentional (it is observable via connection_lost_streams_total), a comment here would help; also, the reader_task call sites are async and could use send_urgent(...).await instead, reserving the lossy path for Drop, which cannot await.

});
}

fn try_send_control(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fanout tradeoff as try_send_urgent on the worker side: one try_send overflow on a 4,096-deep channel cancels control_failed and fails the connection plus all of its streams. Fine as a deliberate bounded-admission simplification, but worth a comment stating that this blast radius is intended.

};
crate::metrics::response_mux::SETUP_SECONDS
.observe(pending.registered_at.elapsed().as_secs_f64());
let mailbox_frames = pending.send_buffer_count.max(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nit: data_plane_channel's doc comment still implies send_buffer_count sizes the response mailbox, but this makes it a floor, not a capacity (window / MUX_HEADER_LEN ≈ 12,483 by default). That floor is required for credit compliance — a credit-compliant worker must never see mailbox_full — so a one-line comment here would prevent someone "fixing" it back and reintroducing spurious resets.


self.pump_response_stream(stream, &publisher, payload_codec)
.await;
if let Err(err) = publisher.finish().await {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nit: the success path above still ignores the prologue result (let _result = publisher.send_prologue(None).await;). Connection-failure cases end up killed and are handled gracefully here, but a non-kill send failure only surfaces later as the misleading "response mux stream finished before its prologue". Worth logging the prologue error at its point of failure.

@github-actions

Copy link
Copy Markdown
Contributor

This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.

@github-actions github-actions Bot added the Stale label Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation feat size/XXL Stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant