Skip to content

perf(gateway): reverse proxy throughput optimizations#825

Merged
kvinwang merged 49 commits into
masterfrom
gateway-perf-clean
Jul 27, 2026
Merged

perf(gateway): reverse proxy throughput optimizations#825
kvinwang merged 49 commits into
masterfrom
gateway-perf-clean

Conversation

@kvinwang

@kvinwang kvinwang commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Performance work on dstack-gateway's reverse proxy path, developed and benchmarked in session a31279f8, with a follow-up round of measurement and review in session 6de7fae5.

Rebased onto master with only the gateway proxy commits (no test-plan / full-plan history).

Gateway proxy changes

  • TCP_NODELAY on inbound and upstream sockets
  • Default proxy buffer_size → 64 KiB (documented: ~52 KB RSS per userspace-relayed connection)
  • rustls TLS 1.3 session ticketer for resumption
  • Per-connection proxy logs demoted info → debug; peer disconnects at debug
  • Opt-in splice(2) zero-copy relay for TLS passthrough (Linux), gated on bytes and/or elapsed time
  • Opt-in kTLS offload on the terminate path, same gating, with a startup TLS-ULP probe
  • thread-per-core runtime + SO_REUSEPORT listeners (default on Linux; probe fallback)
  • Higher listen backlog / unified bind path
  • Per-operation timers and BiLock replaced by one connection-level progress watchdog
  • Eager splice attempt before readiness wait
  • Connection rebalancing across cores (default on with thread-per-core): hand new connections to a less loaded core; load-scaled threshold; bounded handoff queue
  • Skip JoinSet when only one upstream candidate
  • Per-thread splice pipe pool, recycled from both directions, optionally released while idle
  • Effective-state reporting: ProxyAccelStatus on the Status RPC and a Data Path panel on the dashboard

Config ([core.proxy])

tcp_splice and ktls are optional sections, not boolean flags. Omitting the section disables the optimisation; an empty section engages it from the first byte; after_bytes and after_duration are independent gates and whichever fires first engages it.

Key Default Notes
buffer_size 65536 raised from 8192; costs 2 × buffer_size per userspace-relayed connection
thread_per_core true Linux SO_REUSEPORT path, falls back with a warning
connection_rebalance true requires thread_per_core
[core.proxy.tcp_splice] absent after_bytes, after_duration, release_idle_pipes
[core.proxy.ktls] absent after_bytes, after_duration; gated mode also requires tcp_splice

Behaviour changes worth reviewing

  • Idle timeout semantics. The old per-read idle timeout killed a connection when one direction went quiet; the watchdog now requires both to stall. More correct for a proxy, but it changes when connections are reaped.
  • timeouts.write is inert. Superseded by the watchdog; the key still parses (the CVM entrypoint sets it from TIMEOUT_WRITE) but has no effect. Retiring it is a separate call.
  • Defaults on. thread_per_core and connection_rebalance ship enabled.

Benchmark notes

Validated on dedicated GCP machines against HAProxy where applicable; both TLS passthrough and terminate paths exercised.

  • Small-request keep-alive: rebalancing improves multi-core fairness under SO_REUSEPORT skew (passthrough small-req c=50, roughly 166k → 216k rps).
  • Handover / epoll_ctl cost is ~0 in keep-alive windows; ~0.04–0.15% of gateway CPU in the CPS worst case.
  • LLM-style long-lived streaming: splice stays beneficial past the knee; budget 2× connections + 4× concurrent splicing streams for descriptors, or set release_idle_pipes.

kTLS: measured trade-off, opt-in on purpose

Re-measured locally on a Xeon Gold 6530 (kernel 6.8), gateway pinned to 4 cores, 3 interleaved rounds:

bulk throughput connection setup small requests RSS @ 2k streaming conns
immediate −24% −30% ±0 −64% (123 → 44 MB)
adaptive (64 KiB / 5 s) −22% −17% ±0 −42% (123 → 72 MB)

The connection-setup cost reproduces the numbers in the commit messages. The
bulk figure did not, so it was re-measured on three GCP VMs of identical shape
(c4-standard-16, Xeon Platinum 8581C, Emerald Rapids -- same microarchitecture
as the local host), varying only the kernel:

kernel highest-priority gcm(aes) driver kTLS immediate kTLS adaptive
6.1 (Debian 12) generic-gcm-aesni (prio 400) -18.3% -18.6%
6.12 (Debian 13) generic-gcm-vaes-avx10_512 (prio 800) -2.1% -1.1%
6.17 (Ubuntu 24.04) generic-gcm-vaes-avx10_512 (prio 800) +20.3% +20.7%

(MB/s; MB per gateway CPU-second tracks it to within 0.5 points in every row.
3 interleaved rounds, gateway pinned to 4 cores, same static musl binary on all
three.)

So the direction was right and the detail was not. The regression is real on a
pre-VAES kernel -- 6.1 reproduces the -24% measured locally on 6.8 -- and the
VAES driver does remove it. But 6.12 only reaches parity: the +20% needs 6.17,
so something past the driver table matters too, and "6.11 fixes it" was too
simple a claim to have made from the driver tables alone.

For dstack this lands well: the guest OS builds 6.18, past every row here.

Two things worth recording from the measurement itself. perf on the local
6.8 host puts crypto at 13.8% of gateway cycles with rustls
(aes_gcm_encrypt_avx512) against 36.7% with kTLS (key_256_enc_update4),
which is the mechanism. And the first run of this experiment measured a
meaningless +-0.5% on both Debian VMs because those images ship no tls
module: the startup probe correctly disabled kTLS and the arms were rustls
against itself -- an unplanned end-to-end check that the probe degrades
cleanly rather than truncating.

The substantive, portable win is memory, not throughput. Off by default on all counts.

Vendored ktls

ktls 6.0.2 does not compile for x86_64-unknown-linux-musl (the CVM app image target): it builds libc::cmsghdr/msghdr with struct literals, and musl's ABI has private padding members. It is the latest release, so dstack/vendor/ktls/ carries 6.0.2 plus rustls/ktls#70 and nothing else. dstack/vendor/README.md records the provenance and the condition for deleting it.

Guest kernel

CONFIG_TLS was absent from both OS backends, and the 0.5.11 image ships 67
modules without tls.ko -- so a gateway inside a CVM could never accept the
offload and always fell back. This PR enables it (plus CONFIG_CRYPTO_GCM,
AES, AES_NI_INTEL, CHACHA20POLY1305) in os/yocto/.../dstack.cfg and
os/mkosi/components/kernel/kernel.config, built in rather than as a module
because the two backends package modules differently. CONFIG_TLS_DEVICE stays
off: it would hand session keys to the NIC.

Nothing is turned on by this. kTLS remains opt-in, and the startup probe still
falls back with a warning on images built before the change.

Test plan

  • cargo check --all-features, cargo clippy with the CI lint set, cargo fmt --check --all, ./run-tests.sh
  • musl release build, and the static binary exercised for kTLS offload + 100 MiB checksum
  • Smoke: TLS passthrough + terminate with defaults (thread_per_core + rebalance)
  • Every tcp_splice / ktls combination, verified against traffic of a known shape via the new accel counters
  • TLS ULP probe falls back cleanly with the tls module blocked
  • Review default-on behaviour (thread_per_core, connection_rebalance) for production risk
  • Decide whether timeouts.write / TIMEOUT_WRITE should be retired

Comment thread tools/dstack-test/render.py Fixed
@kvinwang
kvinwang force-pushed the gateway-perf-clean branch from 84c0238 to 8be5386 Compare July 26, 2026 08:34
@kvinwang
kvinwang marked this pull request as ready for review July 27, 2026 05:49
Copilot AI review requested due to automatic review settings July 27, 2026 05:49

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread .github/workflows/gateway-proxy-tests.yml Fixed
Comment thread dstack/gateway/test-run/proxy/probe.py Dismissed
kvinwang added 23 commits July 26, 2026 23:45
With TCP_NODELAY enabled, an 8 KiB copy buffer emits many small tail
segments and caps bulk throughput. A 64 KiB buffer keeps writes at full
segment size; measured ~2x passthrough/terminate throughput on a 4-core
gateway with no latency regression. 128 KiB adds <5% for double the
per-connection memory, so 64 KiB is the chosen knee.
TLS 1.2 resumption already works via the in-memory session-ID cache, but
TLS 1.3 resumption needs a ticketer. Without one, every TLS 1.3 reconnect
pays a full handshake (RSA-2048 signing). Measured on tdxlab (4-core
gateway, wrk Connection: close): TLS 1.3 reconnect CPS 6.2k -> 9.6k (+53%)
with the ticketer, and TLS 1.2 CPS unchanged (~20k). Default TLS version is
unchanged.
The accept/handle hot path logged three info-level events plus an
info-level span per connection. At the default info log level this cost
~4-8% of TLS-terminate CPS (measured ~19.2k vs ~20.8k req/s on a 4-core
gateway). Downgrading them to debug (and the span to debug_span) removes
the per-connection logging cost at info while keeping the detail available
when debug logging is enabled.
The TLS-passthrough path is a pure TCP relay (the gateway never sees
plaintext), so it can move bytes kernel-side with splice(2) through a pipe
instead of copying through a userspace buffer. Gated behind the new
proxy.tcp_splice_enabled flag (default off, Linux only).

Measured on a 4-core gateway (tdxlab): bulk passthrough throughput
7.60 -> 8.52 GB/s (+12%) with lower tail latency under load (p99 38 -> 21
ms). Small-request passthrough latency regresses (extra pipe hop per tiny
message), so it stays opt-in. Data integrity verified with a 100 MiB
checksum round-trip.
Adds proxy.ktls_enabled (default off, Linux only). The handshake still runs
in rustls; afterwards the negotiated keys are installed into the kernel TLS
ULP so record crypto happens there, and when combined with
tcp_splice_enabled the payload is relayed with splice and never enters
userspace.

Measured on a dedicated 44-vCPU GCP VM with the gateway pinned to 4 cores
(isolated A/B, interleaved rounds):

  rustls (userspace)  32226 CPS   7.69 GB/s
  kTLS alone          20598 CPS   5.72 GB/s   (-36% / -26%)
  kTLS + splice       22640 CPS   9.63 GB/s   (-30% / +25%)

kTLS alone is a loss: without NIC TLS offload (TlsTxDevice = 0) the kernel's
software AES-GCM is slower than rustls with aws-lc-rs, plus per-connection
setup cost. Combined with splice it gives the highest terminate throughput
measured, so it is worth having for bulk-transfer deployments -- but it stays
off by default because connection setup rate drops ~30%.

Security note: enabling this extracts session keys into the kernel via
dangerous_extract_secrets. Inside a CVM the kernel is part of the measured
TCB; on a normal host this widens key exposure. Hence opt-in.

Verified with a 100 MiB checksum round-trip and TlsDecryptError = 0.
kTLS' cost and benefit sit on opposite sides of the connection-reuse axis:
setup is paid per connection (~34% of connection rate) while the win is per
byte (+29% bulk throughput). Offloading at handshake time therefore penalises
short request/response connections that never earn it back.

With the new proxy.ktls_offload_after_bytes, a connection starts in userspace
rustls and is handed to the kernel only once it has moved that many bytes:
flush, let CorkStream stop at a TLS record boundary, extract the current
traffic secrets (they carry the record sequence numbers, which is what makes a
mid-stream handover sound), install them into the kernel, splice the rest.
0 keeps the previous immediate-offload behaviour.

Measured on a dedicated 44-vCPU GCP VM, gateway pinned to 4 cores, threshold
1 MiB (2 interleaved rounds):

  rustls              32170 CPS   7.70 GB/s   164459 small-req/s
  kTLS immediate      21212 CPS   9.95 GB/s   193208 small-req/s   (-34% CPS)
  kTLS adaptive       26303 CPS   9.93 GB/s   194720 small-req/s   (-18% CPS)

So it recovers about half the lost connection rate while keeping the full
throughput gain, and long-lived small-request connections gain ~18% at lower
CPU because they cross the threshold and then run zero-copy.

Verified: with a 1 MiB threshold a small request does not offload (kernel
TlsTxSw unchanged) while a 100 MiB transfer does (counter +1), checksum
matches and TlsDecryptError stays 0.
Profiling the small-request workload showed the gateway leaving ~0.5 of 4
cores idle while HAProxy pinned all four. The cause was scheduling, not the
data path: the gateway performed ~0.617 context switches per request against
HAProxy's ~0.000. Two sources, both structural:

  * accept runs on its own current_thread runtime and hands every connection
    to a separate multi-threaded worker runtime -- one cross-runtime wakeup
    per connection;
  * tokio's work-stealing scheduler then migrates those tasks between worker
    threads, costing further wakeups and cache locality.

With proxy.thread_per_core = true the proxy instead runs `workers` threads,
each with its own current_thread runtime and its own SO_REUSEPORT listener.
The kernel load-balances new connections across the listeners and a connection
is accepted and served entirely on one thread, so nothing is handed off.

Measured on a dedicated 44-vCPU GCP VM, 4 cores, vs the default model and
vs HAProxy given matching settings:

                        default        thread-per-core   HAProxy
  terminate small-req   158k @3.55     267k @4.03        204k @4.03
  terminate CPS          32.8k @3.65    45.0k @3.96       28.2k @4.00
  passthrough small-req 175k @3.56     312k @4.02        355k @4.02
  terminate throughput  7.5 GB/s       8.3 GB/s          6.0 GB/s

Context switches per request drop to ~0.000 and the proxy now saturates its
cores. That turns two of the three deficits against HAProxy into wins
(terminate small-request +31%, terminate CPS +60%) and closes most of the
third (passthrough small-request now -12%, was -63%).

Off by default: it changes the process model, needs SO_REUSEPORT (Linux), and
per-core accept queues mean a slow connection can only be served by the core
that accepted it. Verified with 100 MiB checksum round-trips on both paths.
Creating a pipe per direction per connection costs two pipe2 calls plus four
descriptor closes. That is pure overhead for short connections and it showed:
enabling splice raised passthrough connection-setup CPU from 134 to 142 us per
connection, while HAProxy -- which pools its pipes -- moved the other way.

Pipes now come from a thread-local pool (cap 64), and are only returned once
the transfer has drained them; a pipe with unknown residue is closed rather
than risk feeding stale bytes to the next connection. Thread-local means no
locking, and under thread_per_core a connection stays on the thread that
borrowed the pipe.

Measured on the 4-core GCP bed (passthrough, 2 interleaved rounds):

  small-request rate   317.8k -> 335.9k rps  (+5.7%, HAProxy 336.8k)
  bulk throughput      unchanged at ~13 GB/s
  connection setup     unchanged

So the win is on keep-alive small-request traffic, where it brings the gateway
level with HAProxy. Verified with repeated 100 MiB checksum round-trips and
interleaved small/large requests, which is what would expose a dirty pipe.
connect_multiple_hosts always built a JoinSet and spawned a task per candidate
address so it could race them. With a single candidate -- the common case for
an app with one instance -- there is nothing to race, and the allocation plus
task spawn was paid on every connection.

Take the address directly in that case.

Measured on the 4-core GCP bed (passthrough connection setup, 2 interleaved
rounds): 139.7 -> 130.7 us of CPU per connection (-6.4%), narrowing the gap to
HAProxy's 123.1 us from 13.5% to 6.2%.
The pool only recycled a pipe when its direction observed EOF, so the other
direction's pipe was closed and recreated on every connection -- syscall
counting showed a steady 1.00 pipe2 per connection instead of ~0.

The pipe is in fact empty at every point between chunks, because each chunk is
fully drained before the next splice from the socket. Track that directly:
mark the pipe recyclable after each complete drain, and only un-mark it while a
chunk is in flight. A pipe is still discarded rather than pooled if the
connection dies mid-drain.

Measured on the 4-core GCP bed (passthrough):

  pipe2 per connection        1.00 -> 0.00
  connection-setup CPU     132.4 -> 120.1 us median (HAProxy 123.4)

That closes the last connection-setup deficit against HAProxy; the two now
overlap within run-to-run spread. Re-verified with repeated 100 MiB checksum
round-trips and interleaved small/large requests, which is what a dirty pooled
pipe would corrupt.
splice costs ~17 syscalls per connection to move a small response -- fill the
pipe, drain the pipe, plus readiness retries -- where a read/write pair needs
two. Its benefit is per byte but its cost is per connection, the same shape as
kTLS, so short request/response connections paid for zero-copy they never used.

With the new proxy.tcp_splice_after_bytes a passthrough connection is relayed
with plain reads/writes until it has moved that many bytes, then switches to
splice for the remainder. 0 keeps the previous behaviour.

The relay buffers come from a thread-local pool for the same reason the pipes
do: a first cut that allocated two buffer_size buffers per connection was
*worse* than the pipe setup it replaced (148.6 us vs 133.5 us per connection).
Pooled, and sized for this phase rather than for bulk copying, it wins.

Measured on the 4-core GCP bed (passthrough, medians of 3 interleaved rounds,
threshold 64 KiB):

                        always splice   adaptive      HAProxy
  connection setup      123.0 us        117.2 us      123.5 us
  small-request rate    300.0k rps      295.3k rps    344.6k rps
  bulk throughput       12.6 GB/s       12.4 GB/s     10.2 GB/s

splice syscalls per connection drop from 16.86 to 0.00 on the connection-churn
workload, and connection setup costs slightly less CPU than HAProxy. Keep-alive
and bulk traffic are unchanged within run-to-run spread (~2%), since those
connections cross the threshold and end up spliced anyway.

Verified with checksum round-trips at 1 MiB and 100 MiB plus payloads sized
just under and just over the threshold, which is where the handover happens.
The thread-per-core path built its listener with socket2 and a 4096-deep accept
queue; the default path used TcpListener::bind, i.e. tokio's default backlog of
1024. That is where SYN drops begin under connection bursts, and it meant the
two modes behaved differently for no reason.

Both paths now share one socket2-based bind, differing only in whether
SO_REUSEPORT is set, with the backlog in a named constant.
A 40-minute soak produced 379 error-level lines and not one real fault. They
were all the peer hanging up: connection reset while bridging, while splicing,
or during the TLS handshake. That is routine -- a browser navigating away, a
mobile network dropping, a load generator ending its run -- and logging it at
error level buries the failures that are actually the gateway's fault.

Classify the error chain: if any cause is a ConnectionReset, BrokenPipe,
UnexpectedEof or ConnectionAborted io::Error, log at debug instead.

Also records what the soak found about the splice pipe pool's descriptor cost
(PIPE_POOL_MAX * 2 * workers, so ~512 for 4 workers and ~4096 for 32), which is
why that cap is not larger.
… probe

Thread-per-core measured 22-77% better on every metric, on loopback and again
over a real network, and it is what turned the deficits against HAProxy into
wins. The only thing holding it back was that it had never run longer than a
benchmark.

A soak of 8 x 60s of mixed load (small-request, bulk, connection-churn) says it
is stable:

  RSS       21.9 MB cold -> ~44 MB steady, flat across all rounds
  fds       31 -> 543, plateauing at the pipe pool cap, then flat
  threads   11, constant
  drift     small-request -2.3%, connection rate -1.3% (both within noise)

Flipping the default needs a safety net, because the model cannot work without
SO_REUSEPORT and failing to serve is much worse than losing the optimisation.
`start` now probes a SO_REUSEPORT bind first and falls back to the shared-runtime
proxy with a warning if it fails. Verified by occupying the port with a
non-REUSEPORT socket: the gateway logs the fallback and keeps serving.

Deployments that want the old process model can still set
proxy.thread_per_core = false. The one behavioural difference to be aware of is
that per-core accept queues mean a connection is served by the core that
accepted it, so a slow core cannot be helped by its neighbours.
…bridge

Two costs the small-request passthrough profile put at the top of userspace,
both in io_bridge:

  * `tokio::io::split` wraps the stream in a `BiLock`, and its `poll_read`
    showed at 1.1% of total time. Passthrough always has a plain socket on both
    sides, so it can use `TcpStream::split`, which hands out borrowed halves
    with no lock. Added `bridge_tcp` for that case.
  * every read, write and flush was wrapped in `tokio::time::timeout`, i.e.
    three timer arm/disarm pairs per request; `TimerEntry` drop plus
    poll_elapsed came to 2.9%. Replaced with one watchdog per connection that
    samples per-direction progress counters -- the hot path now costs an integer
    increment instead of touching the timer wheel, and no clock is read.

The watchdog is coarser than the old per-operation timeouts: it fires within
`idle/4`, so a stalled connection is closed in up to 1.25x `idle` rather than
exactly at it. Verified with `idle = 3s`: old path closed at 3.0s, new at 3.7s.

Measured on the 4-core GCP bed with the corrected harness (physical-core
disjoint pinning, warmed up, n=5, sd<1%):

  passthrough small-request, default config   257 151 -> 290 440 rps  (+12.9%)
  cpu per request                             15.7 us -> 13.9 us
  p99                                         221 us  -> 197 us

Note this only moves the default path: with `tcp_splice_enabled` the relay is
`splice_bidirectional`, which never used either mechanism.

An earlier attempt to answer the same question by toggling
`data_timeout_enabled` reported "no effect" -- that was wrong, because the bench
config generator wrote the key under `[core.proxy]` instead of
`[core.proxy.timeouts]`, so serde silently ignored it and both arms ran with
timers on.
`splice_one` awaited `readable()` / `writable()` before every splice, even when
the socket was already known to be ready. That builds, polls and drops a
`Readiness` future each time; on the small-request passthrough profile
`ScheduledIo::poll_readiness` plus `Readiness::drop` came to 2.4% of total time.

tokio's `try_io` already decides with a single atomic read of the cached
readiness flag and returns WouldBlock without calling the closure if the socket
is not ready, so the wait is only needed on that path. Reordered to attempt the
syscall first and await readiness only when it blocks.

Measured (4-core GCP bed, corrected harness, n=5, sd 0.3%):

  passthrough small-request, splice enabled   291 546 -> 294 583 rps  (+1.0%)
  cpu per request                             13.9 us -> 13.7 us

Smaller than the profile share suggested, but reproducible and above noise.
100 MiB checksum round-trip re-verified.
Each thread-per-core worker used to bind its own SO_REUSEPORT listener, so the
order sockets joined the group was whatever the thread scheduler produced. Bind
them all up front instead and hand listener i to worker i: the setup leaves the
per-thread path, and group order becomes ours.

That order matters for anything that steers connections by listener index, which
is what motivated this. Recording the result of that attempt here, because it
was a dead end worth not repeating:

SO_REUSEPORT's default 4-tuple hash distributes moderate numbers of long-lived
connections unevenly, and thread-per-core cannot rebalance afterwards. At 16
connections over 4 cores the per-core utilisation came out like [99, 42, 101,
101] and throughput swung 46% between restarts purely on how the hash fell.

SO_ATTACH_REUSEPORT_CBPF was tried to replace the hash. Classic BPF has no maps,
so round-robin is not expressible; the only useful signal it exposes is the CPU
handling the packet, so the program steered by `cpu % n`. Measured over 6
restarts each at 16 connections:

  default hash    245 539 rps   4.0 of 4 cores active   lowest core 73%
  cpu steering    167 331 rps   2.3 of 4 cores active   lowest core 100%

It collapses the distribution instead of evening it out: connections are
established in a burst, so only the one or two client CPUs live at that moment
get sampled, and every connection lands on the same one or two listeners. The
knob was removed rather than shipped -- a setting that costs 32% in the only
scenario measured is worse than no setting.

A real fix needs per-connection state, i.e. SO_ATTACH_REUSEPORT_EBPF with a
counter map, or accepting from a shared queue as HAProxy does.
SO_REUSEPORT picks a listener by hashing the connection's 4-tuple, and
thread-per-core cannot move a connection afterwards, so a core that draws few
connections idles while its neighbours saturate. At 16 connections over 4 cores
utilisation came out like [99, 42, 101, 101] and throughput varied 46% between
restarts on nothing but how the hash fell.

Steering the kernel's choice with SO_ATTACH_REUSEPORT_CBPF was tried first and
measured worse (see the previous commit). This fixes it on our side instead: the
accepting core compares its own connection count against the least loaded core
and, if it is ahead by more than a small slack, sends the connection over a
channel for that core to serve. The cost is one channel send per *rebalanced
connection* -- never per connection, never per request -- so everything already
in flight keeps the thread-per-core locality that made this model fast.

Measured over 6 restarts per arm, 4-core gateway, passthrough small-request:

  connections   off                          on
  16            236 110 rps, worst core 72%  251 840 rps, worst core 85%  (+6.7%)
  50            291 932 rps                  287 440 rps                 (-1.5%)
  CPS           17 636                       17 557                      (-0.4%)

The worst case improves more than the average: no core dropped below 76% with
rebalancing, against a low of 47% without it. At high connection counts the hash
is already even, so the balancer only costs its own bookkeeping.

Off by default (`connection_rebalance`) since it trades a little at the high end
for a lot at the low end, and which matters depends on the deployment.
…fer-pool wiring

`Balancer::slot_for_self` became dead once handoffs carried their slot with
them, and `PooledBufs::pair` once the relay borrowed the buffer fields
directly.
…s reactor

`TcpListener::poll_accept` hands back a `TcpStream` already registered with the
accepting core's reactor. Moving that object to another core left the
registration behind, so every read and write on a rebalanced connection went
through the accepting core's epoll and woke the owning core across the channel
-- for the whole life of the connection, not just at handover.

That turned the rebalance into a net loss whenever the hash was already even.
Measured on passthrough small-request at 50 connections, where only ~50 accepts
happen and migrations should be invisible:

    rebalance off                        295 444 rps
    rebalance on, code path but no       295 033 rps   (+0.2%)
      migrations (slack raised to 999)
    rebalance on, migrating              288 825 rps   (-1.9%)

The middle arm is what isolates it: every line of the balancer runs, and it
costs nothing. The cost was per *migrated connection*, and scaled with how
aggressively the threshold migrated -- which is what a permanently misplaced
reactor registration looks like, not what a channel send looks like.

Handing over the plain `std::net::TcpStream` deregisters on the way out and
re-registers on the way in. The penalty disappears: +0.4% at 50 connections
(was -2.5%), +0.8% on TLS terminate (was -2.1%), and the gain at 16 connections
grows from +4.0% to +10.7%.
…ng a fixed slack

A fixed slack of 2 is the right amount of hysteresis at 4 connections per core
and far too little at 12: it keeps migrating on differences that are noise at
higher load. Comparing thresholds on passthrough small-request:

    connections            16        50
    slack 2           236 646   290 542
    slack 1           240 190   285 047
    slack 0           239 335   284 847
    proportional      246 216   287 714

and the worst-loaded core at 16 connections goes 81% (slack 2) -> 95%
(proportional). `least + 1 + least/4` migrates on relative imbalance, so it
still reacts when one core has 6 connections and another has 1, and stops
reacting when they have 13 and 12.

Also tried and dropped: capping accepts per scheduler turn the way HAProxy's
`maxaccept` does, and pushing every connection through the handoff channel even
when it stays local (HAProxy does this deliberately, for cache locality). Both
measured within noise here once the threshold was right.
This was opt-in because handing a connection over cost 2-3% wherever the
`SO_REUSEPORT` hash was already even. That cost was a bug, not a trade -- the
migrated socket kept its readiness registration on the accepting core's reactor
-- and with it fixed there is no longer a workload where the rebalance loses.

Measured on a 4-core gateway, 3-5 runs per arm after warmup, with bulk
throughput re-run interleaved (off/on/off/on, n=5 each) because a single pass
had suggested a 2% regression that turned out to be noise:

| workload                              |        off |         on |        |
|---------------------------------------|-----------:|-----------:|-------:|
| passthrough small-request, 8 conns    |    113 350 |    143 341 | +26.5% |
| passthrough small-request, 16 conns   |    231 043 |    258 787 | +12.0% |
| passthrough small-request, 50 conns   |    294 937 |    295 311 |  +0.1% |
| TLS terminate small-request, 50 conns |    244 304 |    246 228 |  +0.8% |
| TLS terminate, connections/s          |     41 024 |     43 019 |  +4.9% |
| passthrough, connections/s            |     17 431 |     17 319 |  -0.6% |
| passthrough, bulk throughput          | 12.10 GB/s | 12.16 GB/s |  +0.5% |

The gain concentrates where the hash has fewest connections to spread: at 16
connections the quietest core goes from 63% to 97% busy. At 8 and 16
connections this also moves us from behind HAProxy to level with it (143 341 vs
140 278, and 258 787 vs 261 407).
kvinwang added 20 commits July 26, 2026 23:45
The byte gate was a proxy for "is this connection worth the per-connection
setup cost", and it works for request/response traffic where byte volume and
message size move together. LLM token streaming is where the proxy breaks:
4000 tokens of ~64 B is ~256 KB total, so the connection is classified bulk,
while its actual shape -- many tiny writes -- is exactly what the gate exists
to exclude. Worse, the classification arrives late: 64 KiB at 40 tok/s is
~25 s, so every stream spends its first quarter on the copy path and short
conversations never promote at all, making the path taken depend on prompt
length.

Add an independent duration gate. The two are complementary rather than
alternatives: bytes catches high-rate connections within milliseconds but is
blind to long-lived low-rate streams; duration catches those but is blind to
short high-rate ones that finish before it fires. Whichever fires first
engages.

Check the gate only at the tail of the relay loop, where both directions have
completed their write_all and nothing is buffered in userspace -- the same
point the byte check already used, so the handover boundary is unchanged. A
timer arm inside the select! would also be safe but would promote merely-idle
connections, allocating a pipe for a stream with nothing to move.

Replace the four flat fields with two optional sections. `0` previously had to
mean both "engage immediately" and, once a second gate exists, "this gate is
disabled"; with an Option per gate and the whole section optional, every state
has exactly one representation: absent section = disabled, empty section =
engage immediately, either gate set = engage when it fires. This also folds
away the separate `*_enabled` booleans, which would otherwise be a second
spelling of "never engage". None of these fields are upstream yet, so there is
no compatibility cost.

Also add serde_duration::option for Option<Duration> config fields, with tests.
splice needs a pipe because the kernel has no socket-to-socket path: the
zero-copy is page-reference passing and the pipe is the container those
references live in. A bidirectional relay therefore holds one pipe per
direction, and since a pipe is unidirectional and stateful the two cannot be
shared, so a spliced connection pins four descriptors.

Held for the connection's lifetime that is proportional to connections rather
than to work: LLM token streaming moves a ~64 B record every 25 ms and spends
over 99% of the connection idle, yet keeps all four. Measured on the streaming
bench, 50k connections with 10k active streams reached 140 031 descriptors,
and every connection eventually crosses the engage gate, so the steady state
for 50k streams is 300k descriptors.

Park the pipe while the source is dry. The release point is provably safe: the
relay only waits for readability once the previous chunk has been fully drained
into the destination and the fill attempt that just returned WouldBlock added
nothing, so the pipe is empty exactly where it is handed back.

The saving is sharing, not elimination -- parked pipes stay open in the pool.
The bound moves from 4 * connections to 2 * PIPE_POOL_MAX * workers plus
whatever is genuinely in flight, so a 32-worker gateway tops out near 4k
descriptors instead of 300k. Pipes are only created when the pool is empty, so
the live count converges on peak concurrent in-flight chunks rather than
churning pipe2/close.

Behind release_idle_pipes so the two behaviours can be benchmarked against each
other.

Tests cover it in descriptors rather than through the pool as a proxy, counting
this thread's live pipes exactly (process-wide /proc/self/fd races with tests on
other threads). Eight idle connections cost 2 descriptors with release on and 32
with it off. Also covered: payload integrity across repeated park/reacquire
cycles, which is where a stale or half-drained pipe would corrupt the stream.
Both knobs now carry the numbers they were introduced on, in the same shape as
the other measured config docs in this file.

The descriptor result is the exact one: 2000 spliced streaming connections cost
8000 pipe descriptors with release off and 8 with it on, reproduced on every
run. Latency is recorded as the null result it is -- the fastest single run of
seven was a release-off run, so the only defensible claim is steadier tails,
not throughput.
…on ones

The earlier numbers were taken at 2000 connections because the bench client was
being invoked without RAMP, which leaves its ramp batch size at a small default
and dribbles out ~40 connections/s. With RAMP/RAMP_MS set as a pair the client
reaches 50k in 6.6 s, so the descriptor claim can now be checked at the scale it
was made for.

It holds, and it is flat: 8 pipe descriptors for the whole gateway at 2k
connections and at 50k alike, against 53 864 with release off. Latency is
recorded as the wash it is rather than the win the first single run suggested.

kTLS is measured for the first time here. It does nothing for latency on 64 B
records, which was the prediction, but it cuts RSS 349 -> 206 MB because a
spliced kTLS connection never brings the payload into userspace and so needs no
relay buffer. That was not predicted and is the only real argument for it on
this traffic.
The one failure mode this change could plausibly have introduced was never
measured: a connection that is never idle never reaches the release point, so
bulk traffic could hold every pooled pipe and force each token to pay a
pipe2/close pair.

Measured with both classes on the same gateway, 10k token streams alongside 256
bulk connections saturating passthrough at 12.8 GB/s. The pool holds at 8
descriptors against 9024 with release off, bulk throughput is identical, and
both classes come out marginally faster with release on. The reason it does not
degenerate is that even a flat-out bulk connection idles ~62 us between chunks
and releases in that window.

Getting the saturated case took two attempts: at 96 bulk connections the
gateway was only asked for 6.1 GB/s against a ~12 GB/s ceiling, so nothing was
backlogged and the test proved less than it appeared to.
A kernel built without CONFIG_TLS accepts the `[core.proxy.ktls]` section and
then rejects every offload, and the two modes fail differently. Immediate
offload fails at the handshake, so the gateway serves nothing -- bad, but loud.
The gated offload is worse: the connection is served from userspace up to the
threshold and only fails at the gate, so the client gets HTTP 200 with the body
truncated at exactly the gate size, and the gateway logs it as a per-connection
error rather than a misconfiguration.

Measured on a host with the tls module blocked, `after_bytes = 65536`:

  before   GET /100m -> 200, 65536 of 104857600 bytes, silent truncation
  after    GET /100m -> 200, 104857600 bytes, one warning at startup

So probe once at startup instead: establish a throwaway loopback connection and
set TCP_ULP on it (the ULP is only accepted on an established socket, so probing
a fresh one would report ENOTCONN either way). If the kernel refuses, clear the
section and warn. This runs before the acceptor is built, which is also what
decides whether rustls extracts session secrets at all -- so the fallback does
not leave secret extraction enabled for an offload that can never happen.

This is not hypothetical for dstack: the guest OS image ships no tls.ko and no
CONFIG_TLS, so a gateway running inside a CVM hits exactly this path today.

Verified on Linux 6.8 with the tls module present (kTLS still engages,
TlsTxSw +1 for a 100 MiB transfer, no warning) and blocked (both modes fall back
to userspace rustls, full body delivered, zero per-connection offload errors).
Neither acceleration option is simply on or off, and nothing surfaced that.
kTLS can be cleared at startup by the capability probe, and both kTLS and splice
engage per connection only once their gate fires, so the config file does not
say what is running -- and the difference is worth 2-4x in per-connection
memory. The only signals available were one startup warning and the host-global
/proc/net/tls_stat, which is not per-gateway and has no splice equivalent.

Adds ProxyAccelStatus to the Status RPC and a Data Path panel to the dashboard:
the effective mode of each option, plus three counters -- connections offloaded
to the kernel, offloads that failed, and connections that entered a splice
relay. Failed offloads are separate from "not accelerated" on purpose: non-zero
there means connections are being dropped or truncated, not just running slower.

Counters are relaxed atomics bumped once per connection, on the same order as
the existing NUM_CONNECTIONS. Splice is counted in splice_bidirectional, the one
funnel both the passthrough gate and the post-kTLS handover pass through.

Verified against traffic with a known shape (2 small terminate requests under
the gate, 1 large one over it, 1 large passthrough), across every state:

  ktls=off          ktls=off                 splice=after 64 KiB or 5s  0/0/1
  ktls=adaptive     ktls=after 64 KiB or 5s  splice=after 64 KiB or 5s  1/0/2
  ktls=immediate    ktls=immediate           splice=after 64 KiB or 5s  3/0/4
  splice=off        ktls=off                 splice=off                 0/0/0
  no TLS ULP        ktls=disabled (...)      splice=after 64 KiB or 5s  0/0/1

(offloaded/failed/spliced). The adaptive arm offloads only the connection that
crossed the gate; the immediate arm offloads all three; splice counts one extra
for the passthrough connection in every arm that has splice on.
The gateway CVM app image is built for x86_64-unknown-linux-musl, and ktls 6.0.2
does not compile for it:

  error[E0063]: missing field `__pad1` in initializer of `cmsghdr`
  error: cannot construct `msghdr` with struct literal syntax due to private fields

The crate builds both structs with struct literal syntax. That is fine on glibc,
where they have exactly the members it names, but musl declares msg_iovlen and
msg_controllen as int/socklen_t followed by explicit padding, and libc models
that with private __pad1/__pad2 members a literal cannot name. So this is a
permanent property of musl's ABI rather than a libc regression, and 6.0.2 is the
latest published release -- there is no version to bump to.

rustls/ktls#70 fixes it by building both from std::mem::zeroed() field by field.
It has been open since 2026-05-04. Rather than point the dependency at the
contributor's fork -- a moving, non-crates.io source for the crate that installs
session keys into the kernel, in a build that otherwise pins everything down to
package versions and image digests -- the 6.0.2 release is vendored with only
that patch applied. Each hunk is marked, dev-dependencies are dropped, and
vendor/README.md records the provenance and the condition for deleting it again.

ktls was the only thing blocking the musl build (confirmed with --keep-going).

Verified by running both binaries against the same backend: a 100 MiB transfer
through the terminate path checksums correctly, kTLS actually engages
(ktls_offloaded=2, ktls_offload_failed=0) and TlsDecryptError stays at 0 for the
glibc and the static musl build alike. The patched code is on the close_notify
path, so compiling was not the interesting half.
rust-checks runs clippy with `-D clippy::expect_used -D clippy::unwrap_used`,
which the perf work tripped in four places, plus one field left dead by the
timer removal.

  * PooledPipe::rd/wr: the ends are Option only so Drop can move them into the
    pool, so both are always present for a borrow. Uses or_panic, the helper
    this crate already uses for the same situation, and says why in a comment.
  * PooledBufs::get: min().max() is a clamp; write it as one.
  * connect_multiple_hosts: the single-candidate fast path checked len() == 1
    and then unwrapped next(). Take the first candidate up front and branch on
    whether any remain, which drops the unwrap and the second length check;
    the racing path now iterates once(first).chain(rest).
  * Timeouts::write: dead since the per-operation timers were replaced by the
    connection-level progress watchdog, which catches a stalled write through
    `idle` instead. The field is kept so existing configs -- and the CVM
    entrypoint's TIMEOUT_WRITE -- still parse, with a doc comment saying it no
    longer does anything. Whether that knob should be retired is a separate
    call from making CI green.

The vendored ktls is a path dependency, so cargo does not cap its lints the way
it would for a crates.io one; it carries a crate-level allow for the same two
lints, since it is vendored to be patched minimally rather than rewritten.

`cargo clippy -- -D warnings -D clippy::expect_used -D clippy::unwrap_used
--allow unused_variables` is now clean across the workspace, `cargo fmt --check`
passes, and the 43 gateway tests pass. Re-ran the acceleration verification
after the change -- the passthrough fast path is on it -- and every arm reports
identical counters to before.
CI runs `cargo test --all-features`, which applies to workspace *members*.
Making vendor/ktls a member therefore enabled its `ring` feature alongside
`aws_lc_rs`, which the crate declares mutually exclusive:

  error[E0252]: the name `cipher_suite` is defined multiple times
  error: The 'ring' and 'aws_lc_rs' features are mutually exclusive

As a crates.io dependency its features came from the dependency declaration and
nothing could turn `ring` on, so vendoring changed feature resolution rather
than anything about the crate. Excluding it restores that: it stays a path
dependency and still builds, but `--all-features` no longer reaches it.

Dropping the `ring` feature from the vendored manifest would also compile, but
the gateway exposes `tls_crypto_provider = "ring"` and ktls matches rustls'
cipher suites against whichever provider it was built with, so that would
quietly break kTLS for ring deployments.

Verified with the commands CI actually runs, not the narrower ones I used the
first time: `cargo check --all-features`, `cargo clippy -- -D warnings -D
clippy::expect_used -D clippy::unwrap_used --allow unused_variables`,
`cargo fmt --check --all`, `./run-tests.sh`, and the musl release build.
…e reply

Both gated relays treated one direction reaching EOF as the end of the
connection, and the splice one also shut down the wrong half:

    if n == 0 { finish_one!(br, aw, bufs.b); }
    // macro: aw.shutdown(); then loop { br.read() -> aw.write_all() }

`ar` reaching EOF means the *client* stopped sending. The EOF owes to the app,
via `bw`. Shutting down `aw` instead tells the client the response is over and
then writes the app's reply into the half just closed, so a client that ends its
request with `shutdown(SHUT_WR)` gets a successful, empty response. Line 347 had
the mirror image of it, losing a request instead of a reply.

`adaptive_ktls::relay_until` had the same shape without the swap: the first EOF
from either side broke straight to `Phase::Eof` and the caller dropped both
sockets, so anything still in flight the other way went with them.

Both now propagate the EOF to the peer of the direction that ended, drain the
opposite direction, and only then close. Nothing changes once a connection is
spliced or offloaded -- `splice_one` already half-closes correctly -- so this is
confined to the pre-gate phase.

Four tests, each of which fails without the change:

  response_survives_a_client_half_close_before_the_gate  (splice, ktls)
  request_survives_a_backend_half_close_before_the_gate  (splice)
  request_survives_an_app_half_close_before_the_gate     (ktls)

`relay_until` being generic over the client stream is what lets the kTLS ones
run a plain socket in place of the TLS one and exercise the same logic without a
handshake.

Reachable whenever a gate is configured, i.e. exactly the configuration
gateway.toml recommends (`after_bytes = 65536`, `after_duration = "5s"`). The
existing splice tests covered pipe accounting and EOF propagation on a fully
closed connection, which is why this got through.
…recovered

Two problems in the rebalancer, both about what happens when a core stops
behaving.

The handoff channel was unbounded. A core only receives connections while it is
the least loaded, so in steady state it never backs up -- but if one stops
draining, every other core keeps succeeding at `send`, and each handed-over
connection then sits unserved until `timeouts.total` (5h by default) while its
`CoreSlot` keeps that core looking busier than it is. The queue and the memory
behind it only grow. It is now bounded at 1024 with `try_send`, so a full queue
means "this core is not taking work" and the sender keeps the connection.

Two failure paths also had comments claiming the opposite of the code:

    // Cannot deregister: keep it here rather than lose the connection.
    Err(_) => return None,

`TcpStream::into_std` takes `self`, so on error the socket is already closed and
`None` tells the caller it was handed away -- the connection is lost, not kept.
`TcpStream::from_std(raw).ok()` did the same under "keep the connection rather
than drop it". Neither is recoverable, so both now log what actually happened
instead of describing a fallback that was never there. The paths that *are*
recoverable -- a full or closed queue -- really do keep the connection now.

Also: the slot for the target core is claimed after `into_std` succeeds rather
than before, so the failure path no longer briefly accounts a connection to a
core that will never see it, and `start_thread_per_core`'s comment no longer
justifies ordered binding by the CBPF steering program that `reuseport`'s own
docs say was abandoned.
…LS handover

`buffer_size` is the one field this PR changes a default on (8 KiB -> 64 KiB) and
was the only `ProxyConfig` field with no doc comment at all. It now records what
the 8x costs: `2 * buffer_size` of address space per userspace-relayed
connection, measured at ~52 KB RSS per connection at 2 000 concurrent streaming
connections, against ~12 KB on the kTLS path where the payload never enters the
process.

The kTLS handover flushed two different kinds of data through one loop:

    for chunk in [drained.unwrap_or_default(), buffered] { ... }

`drained` is plaintext rustls decrypted past the handshake and owes to the app.
`buffered` is whatever raw *ciphertext* was left in the SNI sniff buffer --
forwarding it would hand the app TLS records to interpret as application data.
A completed handshake always consumes the sniff buffer, so this is unreachable
rather than merely unlikely, which is exactly why it should fail loudly instead
of being discovered as a corrupted stream.
`s.split_at(s.len() - 1)` is a byte index, so a multi-byte unit lands inside a
character and panics in `core::str`: `parse("5分")` aborted the config load
rather than reporting a typo. Splits on the last character now.

The unit multiply was also unchecked, so `18446744073709551615d` wrapped to a
short duration in release builds -- a timeout far tighter than the operator
asked for, which is the worst way for this to fail. Checked, with an error.

Both are pre-existing, but this PR restructured the function and added five
tests around it without covering either.
The gated fast paths bypass `io_bridge`, and `io_bridge` was where both the idle
watchdog and the TLS-aware close lived, so enabling splice or kTLS silently
dropped two behaviours the config still claims to control.

**Idle timeout.** Measured with `idle = 3s`, a 1 MiB response read to
completion, then 15s of silence:

  terminate, buffered            reaped
  terminate + kTLS + splice      ALIVE
  passthrough, buffered          reaped
  passthrough + splice           ALIVE

And it did not need the gate to fire: with the gate at 10 MiB and a 1 KiB
response -- so the connection never left the pre-splice phase -- both gated
paths still stayed alive, because `splice_bidirectional_after` and
`relay_with_adaptive_offload` route around `io_bridge` from the first byte.
Merely *configuring* a section disabled the idle timeout for that whole path.
The only remaining bound was `timeouts.total`, five hours by default -- on
exactly the long-lived streaming connections the gates exist to capture.

The watchdog is now a shared `proxy::idle::IdleWatchdog` that all three relay
shapes use: the buffered bridge and the two pre-gate relays poll it as one more
`select!` arm, and a spliced connection -- which has no loop of its own, since
its bytes never enter this process -- races it against the transfer. `splice_one`
bumps a relaxed counter once per chunk, not per byte, which is nothing next to
the splice syscall it accompanies.

**close_notify.** `splice_one` ends a direction with a bare
`shutdown(fd, SHUT_WR)`. On a kTLS socket that is a FIN with no TLS close, which
is indistinguishable from a truncation attack to a client that checks:

  userspace rustls   client saw: clean_eof
  kTLS + splice      client saw: TRUNCATED (no close_notify)

`KtlsStream::poll_shutdown` would have sent it, but the offload path takes the
raw descriptor and never goes through that type again. The destination's close
is now described by a `CloseKind`, and the kTLS side sends `close_notify` first.
That needed a re-export in the vendored crate, which upstream keeps private
because only `KtlsStream` uses it -- marked as a dstack patch like the other one.

Verified: every arm above now reaps (`bridge error: idle timeout` in the log) and
reports `clean_eof`, byte-for-byte identical payloads. `data_timeout_enabled =
false` still opts out, and the accel counters and 100 MiB checksums are unchanged
on both the glibc and musl builds.
`connection_rebalance` is only read inside `start_thread_per_core`, so with
`thread_per_core = false` -- or after the `SO_REUSEPORT` probe falls back, where
the existing warning mentions only `thread_per_core` -- it is silently ignored.
The config docs say "Requires `thread_per_core`" and nothing enforced it, so an
operator who set one but not the other got no signal either way.

Says so now, on the shared-runtime path. Not an error: a shared work-stealing
runtime genuinely has nothing to rebalance, so the right outcome is to serve
traffic and say the knob is inert.
The gateway had substantial tests -- `test_suite.sh` is 65 KB -- and no
workflow referenced any of them. Nothing in CI ran a gateway process at all,
which is how a response-dropping half-close, a silently disabled idle timeout
and a missing close_notify all reached review.

`test_proxy.sh` starts a real gateway and asserts on what reaches the wire,
across every combination of the two gated optimisations and both proxy paths:

  data path         payloads survive byte-for-byte, under and over each gate,
                    single and concurrent
  close             the app closing arrives as an orderly TLS shutdown, kTLS
                    offload included
  timeouts.idle     the watchdog reaps on buffered, spliced and kTLS relays,
                    and `data_timeout_enabled = false` still opts out
  kTLS engagement   offload happens, only once the gate fires, no decrypt errors
  fallbacks         a kernel without the TLS ULP warns and serves untruncated;
                    an inert connection_rebalance warns
  runtime modes     every thread_per_core / connection_rebalance combination
  Status RPC        the accel counters reflect the traffic that ran

48 assertions locally. Every capability it cannot get -- no TLS ULP, no
privileges for `wg show` -- is reported as SKIP with the reason rather than
quietly passing, since a test suite that reports success for work it did not do
is worse than one that is missing.

Three things the design had to get right, each learned by getting it wrong:

  * The gateway only needs *a link* with its configured wg interface name to
    start, not a working WireGuard device, so the suite falls back to a dummy
    link and runs the whole data path without the wireguard module. Only the
    Status RPC needs the real thing, and that group probes the RPC rather than
    guessing from the link kind -- it also needs root for `wg show`.
  * Restarting the gateway ~25 times races its own listeners, so teardown waits
    for the ports to come back. A preflight check refuses to start when they are
    already busy: a stale listener does not fail cleanly, it answers the probes
    with the wrong config and scatters unrelated assertion failures.
  * Half-close is *not* covered here. Every client of this gateway speaks TLS,
    and a TCP half-close mid-TLS is a truncation rather than an orderly one --
    confirmed by pointing the same probe at the origin with no gateway in the
    path, where it fails identically. It stays a unit test, and the script says
    so.

The workflow is path-filtered to the gateway and the vendored ktls, records the
kernel's TLS and AES-GCM capabilities before running so a skipped group is
visible in the log, and uploads the work dir on failure.
dstack-gateway can offload TLS record encryption to the kernel, but no dstack
guest image could accept it: the 0.5.11 image ships 67 modules and `tls.ko` is
not one of them, and no fragment in either backend set `CONFIG_TLS`. A gateway
running inside a CVM therefore always fell back to userspace rustls.

Adds it to both kernel configs, plus the crypto the record layer needs:

  CONFIG_TLS                     the `tcp_ulp = "tls"` upper-layer protocol
  CONFIG_CRYPTO_GCM              the AES suites the gateway negotiates
  CONFIG_CRYPTO_AES              ditto
  CONFIG_CRYPTO_AES_NI_INTEL     accelerated GCM (already set for mkosi)
  CONFIG_CRYPTO_CHACHA20POLY1305 so a client that picks it fails at
                                 configuration time, not per connection

Built in rather than a module. The two backends package modules differently --
mkosi runs `modules_install` and ships everything built, while the yocto image
lists them one by one (`kernel-module-tun`, `kernel-module-fuse`, ...) -- so
`=m` would need a matching `kernel-module-tls` entry in
`dstack-rootfs-base.inc`, and forgetting it fails at runtime rather than at
build time. That is the failure this commit exists to remove, so it is not a
good thing to reintroduce one level down.

`CONFIG_TLS_DEVICE` is deliberately left off: it hands session keys to the NIC,
which a confidential guest has no reason to trust and no CVM NIC offers.

This does not turn anything on. kTLS stays opt-in in `[core.proxy.ktls]`, and
the gateway still probes for the ULP at startup and falls back with a warning
if it is absent -- which is what keeps this safe for images built before this
change.

AES-NI matters more than it looks: measured on Emerald Rapids across three
kernels on identical hardware, kTLS bulk throughput is -18.3% on 6.1 (only
`generic-gcm-aesni` registered), -2.1% on 6.12 and +20.3% on 6.17 (both
register `generic-gcm-vaes-avx10_512`). The guest kernel is 6.18.

mkosi's `check-kernel-config.sh` asserts every symbol in the fragment against
the generated `.config`, so all five are now build-time gated there.
@kvinwang
kvinwang force-pushed the gateway-perf-clean branch from 262751b to 3a9b6f3 Compare July 27, 2026 06:49
kvinwang and others added 6 commits July 27, 2026 00:01
…sponse

`splice()` on a kTLS socket refuses any record that is not application data and
reports it as EINVAL. That is how a client's `close_notify` arrives once the
socket belongs to the kernel, and `splice_one` treated it as fatal:

  connection error: ktls splice error: splice src->pipe failed: Invalid argument

`try_join!` then cancelled the *other* direction, so a client ending its request
threw away the response the app had already written. Same shape as the pre-gate
half-close bug, one layer down and only once kTLS has actually engaged, which is
why the earlier fix did not cover it.

A non-data record is the end of the data stream for a byte relay, not a failure,
so it now breaks the loop like any other EOF and the opposite direction drains.
`CloseKind` describes both endpoints rather than just the destination, since
whether EINVAL means "control record" depends on which side is kTLS.

Found by `proxy/tlsclient.py`, added here: `ssl.SSLSocket` cannot half-close a
TLS connection at all -- `unwrap()` waits for the peer's `close_notify`, which
the peer will not send until it has finished replying, and `shutdown(SHUT_WR)`
sends a bare FIN that mid-TLS is a truncation the peer is right to reject.
Driving the state machine over memory BIOs does it properly: `unwrap()` writes
the alert into the outgoing BIO before it raises, so flushing that and declining
to finish the handshake is exactly a half-close.

The half-close group is back in the suite, 5 arms x 2 paths. Before this fix
`ktls-immediate / terminate` returned 0 bytes and everything else passed, which
is what pointed at the offload path.

Verified the fix does not truncate legitimate traffic: 1 KiB, 1 MiB and 8 MiB
through kTLS+splice all arrive complete with matching digests and zero splice
errors. Suite is 58 passed, 0 failed, 2 skipped; 49 unit tests pass.
`take_sni` reads raw wire bytes off the socket before the proxy knows where the
connection is going, so those bytes live in a `Vec` that splice cannot see. Each
path has to account for them, and one did not.

Passthrough is fine: it replays the buffer to the app with `write_all` before
splice takes the sockets. The terminate paths feed it to rustls through
`MergedStream`, which yields it ahead of the socket and empties itself once
consumed -- so at the kTLS handover the remainder is normally empty.

"Normally" was doing real work there. The immediate offload path checks it; the
adaptive one reached the socket through

    impl From<MergedStream> for TcpStream {
        fn from(s: MergedStream) -> Self { s.into_parts().1 }
    }

which drops the remainder on the floor, with a doc comment asserting the
invariant instead of enforcing it -- and an `IO: Into<TcpStream>` bound that
made it look unremarkable at the call site.

That conversion is gone. Both paths now go through a `SocketParts` trait that
hands back `(remainder, socket)`, and both refuse a non-empty remainder. There
is no safe alternative to refusing: the bytes are ciphertext, so they cannot go
to an app expecting plaintext, and once the socket belongs to the kernel they
cannot go back either. Forwarding them would hand the app TLS records to parse
as application data, which is a much worse thing to debug than a failed
connection.

Still unreachable in practice -- rustls consumes the whole ClientHello during
the handshake, and the shipped TLS 1.2 config has no early data for a client to
get ahead with. The point is that it is now unreachable *by construction*
rather than by convention.

Two tests pin it: a non-empty remainder is surfaced, and a consumed buffer
leaves none. 51 unit tests and 58 integration assertions pass.
…ntain permissions'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Reported by Codex review, confirmed and reproduced.

Every relay leaves its main loop when one direction hits EOF and drains the
other in a plain loop with no timeout on the reads. `timeouts.idle` therefore
stopped applying at exactly the point a connection becomes cheapest to abuse:
a client sends a request, half-closes, and a backend that accepts it and stays
silent holds the connection until `timeouts.total` -- five hours by default,
against a ten minute idle -- with its relay buffers still allocated.

This is a regression from this PR, not pre-existing. Before the watchdog
replaced the per-operation timers, `step()` read through
`timeout(self.cfg.timeouts.idle, ...)`, which covered the drain too. Moving
coverage into the `select!` loop left the phase that runs after it bare.

Three sites, all of them:

  io_bridge.rs      the `Rest::A2b` / `Rest::B2a` drain after the select loop
  splice.rs         `finish_one!` in the pre-gate relay
  adaptive_ktls.rs  `finish_one!` in the pre-gate relay

The drain now polls the same watchdog. `io_bridge` freezes the finished
direction's progress and keeps adding it, since the watchdog samples a
monotonic counter and switching to the survivor alone would look like the
counter went backwards.

`splice_one` needed nothing: `splice_bidirectional` races the whole `try_join!`
against `wait_until_stalled`, so one direction ending leaves the join pending
and the watchdog still fires.

Measured with `idle = 3s`, a half-closed request against a backend that never
replies:

                        before          after
  buffered              open at 25s     reaped at 3.8s
  splice engaged        open at 25s     reaped at 3.7s
  splice gated          open at 25s     reaped at 3.7s
  ktls gated            open at 25s     reaped at 3.8s

Four integration assertions cover it, against a new `/blackhole` origin
endpoint that drains the request and then holds the connection open. The suite
is 62 passed, 0 failed, 2 skipped; 51 unit tests pass.
Second round of the same Codex finding, and the first fix only covered half of
it. The drain loop put `$r.read()` in the `select!` with the watchdog but left

    $w.write_all(&$buf[..n]).await?;

outside it. A backend that floods while the client stops reading fills the
send buffer, `write_all` blocks, and the watchdog goes unpolled again -- the
mirror image of the silent-backend stall and just as good for holding a
connection to `timeouts.total`.

`write_all` cannot simply move into the `select!`: it is not cancel-safe, so
losing the race would drop an unknown number of already-written bytes. Single
`write` calls are cancel-safe -- tokio guarantees nothing was written when
another branch wins -- so the partial-write loop is driven here instead, with
the watchdog racing each call. `moved` advances per partial write, so a peer
that drains slowly still counts as making progress and is not reaped for being
slow.

`io_bridge` needed nothing, for the reason Codex gave: `OneDirection::step()`
is a cancel-safe state machine that performs one operation per call, so racing
`step()` already covered its writes. That difference is what the measurement
showed.

Measured with `idle = 3s`, a half-closed request against a backend flooding
64 MiB while the client reads nothing:

                        before                    after
  buffered              reaped, 4.1 MB drained    unchanged
  splice engaged        open, 67 MB drained       reaped, 4.1 MB
  splice gated          open, 67 MB drained       reaped, 4.2 MB
  ktls gated            open, 67 MB drained       reaped, 4.1 MB

The read-stall case still reaps at 3.8s and an 8 MiB transfer is still intact,
so this does not reap connections that are merely slow.

Four more assertions against a new `/flood/<n>` origin endpoint, one per arm.
Suite is 66 passed, 0 failed, 2 skipped.
@kvinwang
kvinwang merged commit 9459265 into master Jul 27, 2026
19 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.

3 participants