Skip to content

perf(net): set TCP_NODELAY on all tunnel and proxy TCP hops#2220

Open
purp wants to merge 5 commits into
NVIDIA:mainfrom
purp:2219-tcp-nodelay/purp
Open

perf(net): set TCP_NODELAY on all tunnel and proxy TCP hops#2220
purp wants to merge 5 commits into
NVIDIA:mainfrom
purp:2219-tcp-nodelay/purp

Conversation

@purp

@purp purp commented Jul 10, 2026

Copy link
Copy Markdown

Summary

Every small request/response through the sandbox relay 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_NODELAY on every latency-sensitive TCP socket in the tunnel
and egress-proxy path, 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

  • gateway: set TCP_NODELAY on accepted connections on the public
    listener (gRPC relay frames and WS tunnel writes).
  • CLI: edge-tunnel local accept + the WebSocket's underlying TCP
    stream, the insecure TLS connector (matching tonic's default connector),
    and service-forward accepted sockets.
  • supervisor: direct-tcpip connect into the sandbox netns, TCP relay
    target dials (connect_tcp_target), 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.
  • Regression tests assert TCP_NODELAY is set on the proxy upstream,
    direct-tcpip, and TCP-relay connect paths.

The sandbox SSH transport rides a unix domain socket and gRPC client
channels use tonic defaults (nodelay on), so no change is needed there.

Testing

  • mise run pre-commit passes
  • Unit tests added/updated (nodelay regression tests for the three
    connect hops)
  • E2E tests added/updated (if applicable) — not applicable; validated
    with a component-isolation benchmark ladder instead (below)

A/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:

Path baseline this PR host floor
L4 passthrough, 100-req conn 43.58 ms 0.26 ms 0.06 ms
L7 terminate+inspect, 100-req conn 44.49 ms 0.45 ms 0.06 ms

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.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable) — not applicable; this is a
    socket-option tuning with no change to subsystem boundaries or data flow

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>
@copy-pr-bot

copy-pr-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

purp added 2 commits July 11, 2026 09:54
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>
@purp

purp commented Jul 13, 2026

Copy link
Copy Markdown
Author

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.

@TaylorMutch

Copy link
Copy Markdown
Collaborator

Sounds good @purp !

purp added 2 commits July 13, 2026 13:50
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>
@purp

purp commented Jul 13, 2026

Copy link
Copy Markdown
Author

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 openshell-core and it seems much cleaner.

Some notes:

The _best_effort suffix: I don't love it, but I don't know Rust well enough to know if there's some convention I should use instead. Claude and I had an argument about this, and it convinced me that this was the best we could do. Read a fair bit about Rust error handling as control flow and I think I agree, at least for now.

Claude's take:

The helpers set TCP_NODELAY but deliberately swallow-and-log any failure — disabling Nagle is a latency optimization, and a failed set_nodelay still leaves a working connection, so there's nothing for the caller to act on. Rust has no punctuation convention for this (unlike Ruby's foo/foo!); fallibility is expressed in the type, and a function that returns () reads as "this succeeded." The _best_effort suffix is what tells a reader the flag is attempted, not guaranteed. Note the naming is deliberately asymmetric with the return types: connect_tcp_nodelay_best_effort returns io::Result because the connect can genuinely fail and must be checked, while the _best_effort part refers only to the nodelay tweak. We avoided try_set_nodelay, since try_ in Rust idiom means "returns a Result you must handle" — the opposite of what these do.

It's trivial to imagine someone adding a new TcpStream::connect call instead of connect_tcp_nodelay_best_effort (or without calling set_tcp_nodelay_best_effort on their stream just after). Claude and I discussed this and agreed to hold off on the obvious mechanism, clippy::disallowed_methods, as being a bit too broad and potentially impactful to ride along in this change; if that's the right thing to do, we'll put up a separate PR for the work and get on it. In the meantime we added a snippet to AGENTS.md to steer them towards it.

Claude's take:

It's the right tool for enforcement, but the blast radius makes it a separate, deliberate change. A repo-wide grep finds TcpStream::connect at ~30 sites — mostly legitimate test clients, plus several production server-side dials (server/src/tls.rs, grpc/sandbox.rs, lib.rs) that this performance work never touched. clippy.toml is workspace-global, so every one of those would need an #[allow] or a reroute, and turning it on forces a decision on whether those untouched dials should get nodelay — scope well beyond this change. It also only guards the dial path, not accepted sockets (which need nodelay too but don't go through connect), so it'd give a false sense of full coverage. What this PR does deliver is the prerequisite: a single central connect_tcp_nodelay_best_effort for dialing, which makes the lint possible to add later as its own reviewed change.

Ready for review.

@purp purp marked this pull request as ready for review July 13, 2026 21:01
@purp purp requested review from a team, derekwaynecarr, maxamillion and mrunalp as code owners July 13, 2026 21:01
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.

~45 ms added latency on sandbox tunnels/egress tinygrams due to Nagle × delayed-ACK issue

2 participants