perf(net): set TCP_NODELAY on latency-sensitive TCP hops#2220
Conversation
The sandbox tunnel added ~44 ms of latency to every small request/response because no socket in the path disabled Nagle's algorithm, so sub-MSS writes waited on delayed ACKs at each hop. Set TCP_NODELAY on every latency-sensitive TCP socket: - gateway: accepted connections on the public listener (gRPC relay frames and WS tunnel writes) - CLI: edge tunnel local accept + underlying WebSocket TCP stream, insecure TLS connector (tonic's default connector already does this), and service-forward accepted sockets - supervisor: direct-tcpip connect into the sandbox netns, TCP relay target dials, egress proxy accepted connections, and all upstream CONNECT/HTTP dials (via a new connect_upstream helper) Setting TCP_NODELAY on connect is best-effort: a failure only costs latency, so we log and continue rather than fail the connection. The sandbox SSH transport rides a unix domain socket and gRPC client channels use tonic defaults (nodelay on), so no change is needed there. Fixes NVIDIA#2219 Signed-off-by: Jim Meyer <jim@meyer4hire.com>
Address review feedback on the TCP_NODELAY change: - Move the shared set-nodelay helper out of supervisor_session into a new crate-private `net` module in openshell-supervisor-process, so ssh and supervisor_session no longer reach across modules through a pub(crate) item. - Make the best-effort comments at each call site terse and consistent. No behavior change; the benchmark ladder reproduces the same numbers. Signed-off-by: Jim Meyer <jim@meyer4hire.com>
Resolve conflicts from NVIDIA#2162 (fail closed when credential placeholders cannot be rewritten), which restructured handle_tcp_connection to validate addresses, run the fail-closed TLS gate, then connect upstream: - proxy.rs: keep main's validate-then-connect ordering and route the post-gate dial through our connect_upstream helper, so TCP_NODELAY is still set without connecting before the fail-closed gate. - ssh.rs: keep both our TCP_NODELAY regression test and main's new socket-permission tests in the same mod tests block. Signed-off-by: Jim Meyer <jim@meyer4hire.com>
|
Still in progress. I've been using this to learn more about OpenShell, and what I've learned is that this fix is not central enough yet. I think one more good iteration from me and Claude will get us to a decent place. |
|
Sounds good @purp ! |
Move the best-effort TCP_NODELAY helpers into the shared openshell_core::net module so every crate dials and configures sockets the same way: - Add set_tcp_nodelay_best_effort (accepted/existing streams) and connect_tcp_nodelay_best_effort (dial + set) with unit tests. - Migrate all call sites in openshell-cli, openshell-server, and the supervisor crates to the shared helpers. - Remove the crate-private net module from openshell-supervisor-process. - Document socket guidance in AGENTS.md (Network Sockets). Signed-off-by: Jim Meyer <jim@meyer4hire.com>
|
Okay, I think I'm happy with this for the moment. Initial implementation was a bit too shallow and copy-paste-y across many files, so we drove the change down into Some notes: The Claude's take:
It's trivial to imagine someone adding a new Claude's take:
Ready for review. |
|
Thanks for the PR! I'm taking a look at this one now. |
russellb
left a comment
There was a problem hiding this comment.
🤖 Agent-assisted code review — produced by an AI coding agent (Claude) reviewing this PR at its head commit. Findings were verified by tracing production TCP accept/connect sites on the branch.
1. Missed hop: the gateway's exec loopback bridge
start_single_use_ssh_proxy_over_relay (crates/openshell-server/src/grpc/sandbox.rs:1846) binds a loopback TcpListener, and copy_bidirectionals the accepted client_conn (:1853) against the relay DuplexStream; the russh client dials into it at :1725 and :1896. This is the production ExecSandbox transport for both non-interactive (run_exec_with_russh, called :1573) and interactive (run_interactive_exec_with_russh, called :1652) exec.
None of the three sockets gets TCP_NODELAY. russh::client::connect_stream takes a pre-connected socket and sets no socket options, so the caller owns this. It's a loopback TCP hop — which this PR explicitly argues still suffers the stall — carrying the most tinygram-heavy traffic in the system (keystrokes, line-buffered PTY output). A ~40 ms per-roundtrip stall here is directly perceptible at an interactive prompt.
The PR note "the sandbox SSH transport rides a unix domain socket… so no change is needed there" holds for the supervisor's SSH daemon (ssh.rs:83 is a UnixListener, correctly untouched) but does not cover this gateway-side TCP bridge.
Fix: set_tcp_nodelay_best_effort(&client_conn) after the accept at :1853, and on the stream after each connect at :1725/:1896. If exec is intentionally deferred, narrow the "all tunnel and proxy TCP hops" claim to say so — exec is a first-class latency path.
2. Uncovered production accept: sandbox metadata server
crates/openshell-sandbox/src/metadata_server.rs:71 accepts production TCP for the IMDS-style credential/identity endpoint. Low-frequency, so likely out of scope — but it's a sandbox-side small-request path an agent hits. Either cover it or add a one-line "intentionally excluded" note.
3. edge_tunnel.rs silently skips unknown TLS variants
The MaybeTlsStream match falls through _ => None, skipping nodelay for any variant that isn't Plain/Rustls. Correct today (rustls is the only backend), but a debug! in that arm would surface a silent miss if the TLS backend ever changes.
Everything else in the relay path checks out: proxy.rs dials (:1083, :4344) and accept (:274), the gateway listener (server/lib.rs:550), the supervisor connect paths, and the UDS-based drivers are all handled correctly; the remaining connect/accept sites are test-only.
|
/ok to test 98354f3 |
|
All three confirmed and addressed in the incoming revision. 1. Exec loopback bridge — fixed. 2. Metadata server — fixed rather than excluded. 3. Independent re-sweep. I re-traced every production TCP accept/connect site in the workspace to confirm nothing else was missed. The exec bridge and the metadata server were the only remaining hops. For completeness:
|
The gateway-side single-use SSH-over-relay loopback bridge and the sandbox IMDS metadata server were missed latency-sensitive TCP hops. Set TCP_NODELAY on the accepted client connection and both russh client dials of the exec bridge — interactive keystrokes and line-buffered PTY output are the most tinygram-heavy traffic in the system — and on the metadata server's accepted connections. Also log unrecognized MaybeTlsStream variants in the edge tunnel so a future TLS-backend change surfaces a silent TCP_NODELAY miss instead of skipping it quietly. Signed-off-by: Jim Meyer <jim@meyer4hire.com>
Summary
Every small request/response through the sandbox request path paid a fixed ~44 ms penalty because no socket in the path disabled Nagle's algorithm, so sub-MSS ("tinygram") writes stalled waiting on TCP delayed ACKs at each hop. This sets
TCP_NODELAYon every latency-sensitive TCP socket in the tunnel, egress-proxy, interactive-exec, and metadata paths — cutting the marginal cost of a small governed request from ~44 ms to ~0.3–0.5 ms (within a few hundred microseconds of the host loopback floor) with no regression on bulk transfers.Opening as a draft to invite discussion of the approach before it's marked ready.
Related Issue
Fixes #2219
Changes
TCP_NODELAYon accepted connections on the public listener (gRPC relay frames and WS tunnel writes).TCP_NODELAYon the single-use SSH-over-relay loopback bridge — the accepted client connection plus both russh client dials (interactive and non-interactive exec). This is the most tinygram-heavy path in the system: interactive keystrokes and line-buffered PTY output.MaybeTlsStreamvariant, so a future TLS-backend change can't silently skip nodelay.connect_tcp_target), egress-proxy accepted connections, and all upstream CONNECT/HTTP dials (via a newconnect_upstreamhelper).TCP_NODELAYon accepted connections to the IMDS-style metadata server — the credential/identity endpoint an agent polls.TCP_NODELAYis best-effort: a failure only costs latency, so we log and continue rather than fail the connection.TCP_NODELAYis set on the proxy upstream, direct-tcpip, and TCP-relay connect paths. The exec-bridge and metadata sites route through the same already-tested helpers (set_tcp_nodelay_best_effort/connect_tcp_nodelay_best_effort), so their coverage is by construction.The sandbox SSH transport rides a unix domain socket and gRPC client channels use tonic defaults (nodelay on), so no change is needed there.Coverage re-sweep
After the review I independently re-traced every production TCP accept/connect site in the workspace. Conclusion: the exec bridge and the metadata server were the only remaining missed hops, both now fixed. Also confirmed:
hyper::serve_connectionover an accepted loopbackTcpStream) but are one-shot, human-in-the-loop redirects — the ~40 ms stall is imperceptible next to browser + human latency, so they're intentionally left as-is..serve(); tonic 0.14 defaults servertcp_nodelaytotrue, so they were already covered.Testing
mise run pre-commitpassesA/B benchmark ladder (medians over 30 reps/cell; macOS arm64, Docker compute driver). Tools and full results are on a temporary branch:
relay-latency-benchmarking.Keep-alive marginal cost per small request:
No regression (worst cases): 10 MB response 58.6 → 59.0 ms (unchanged); 10 MB request 90.4 → 46.0 ms (Nagle-throttled uploads got ~2× faster); L7 body-rewrite rung 1 KB 63.7 → 14.9 ms.
The exec bridge and metadata server were not separately benchmarked; both are loopback TCP hops subject to the same delayed-ACK stall the ladder measures.
Checklist