Skip to content

fix(gateway): a stalled reader holds a relay for five hours, the connection limit does not limit, and a zero-length write spins a core - #1245

Merged
kvinwang merged 7 commits into
nextfrom
fix/gateway-proxy-limits
Sep 24, 2026
Merged

kvinwang merged 7 commits into
nextfrom
fix/gateway-proxy-limits

Conversation

@kvinwang

@kvinwang kvinwang commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Four availability defects in the gateway proxy data path, plus a small SNI parser fix.

Problems and fixes

  1. Idle watchdog not polled while a pre-gate relay writes. relay_until (splice and adaptive kTLS) awaited write_all outside the select!, so a peer that stops reading parked the write and held the connection until timeouts.total (5 h). The drain phase already used a watched partial-write loop; it is now a write_watched! macro used by the main loop too.
    Pre-fix: a_client_that_stops_reading_is_reaped_by_the_idle_timeout ... FAILED: the relay outlived its idle window.
  2. max_connections_per_app checked, then incremented later. A burst all passed on the same total: 32 simultaneous connections took 9–20 slots against a limit of 4. The check is now EnteredCounter::try_enter, a fetch_update on the first candidate's counter, so check and increment are one step. Other candidates' counts are still read outside it, so a multi-instance app can overshoot by at most connect_top_n - 1.
  3. Zero-length write in io_bridge spins. A write_buf returning 0 with a non-empty buffer re-entered at once and counted as progress. It now fails with write accepted no bytes, like the relay paths.
  4. SNI past 4 KiB was refused as "no sni found". The sniff buffer never grew. It now grows up to one TLS record (5 + 2^14, all extract_sni can parse) and reports the limit past that.
  5. extract_sni ignored the record content type, so a non-handshake record could yield an SNI. It now requires 0x16.

Verification

cargo test -p dstack-gateway (325 passed), cargo clippy -p dstack-gateway --all-features --all-targets -- -D warnings --allow unused_variables, cargo fmt --all -- --check. Each new test fails on next.

Conflicts with #1284 in gateway/src/proxy.rs tests only; see #1284 for the resolution.

Both pre-gate relays select! on the watchdog and the two reads, then await a
bare write_all in the winning arm. write_all is not cancel-safe, so it sits
outside the select! and nothing polls the watchdog for as long as it blocks. A
peer that stops reading fills the socket buffer and parks the write there, so
the connection survives until timeouts.total (5h by default) -- a relay each
for a client that sends a request and never reads the answer.

The drain phase below already had the fix; this lifts its partial-write loop
into a write_watched! macro and uses it in the main loop too, so there is one
watched write rather than two shapes.
The buffered bridge's Write step ignored what write_buf returned. A writer
that reports Ok(0) with a non-empty buffer leaves the direction in Write, the
loop re-enters immediately, and the zero-length write still bumped the
progress counter -- so the idle watchdog saw a busy connection and the bridge
span hot on a core until timeouts.total.

The two relay_until paths already bail on a zero-length write; this is the
third implementation of that loop agreeing with them.
take_sni read into a fixed 4 KiB buffer that never grew. Once full, the next
read landed in an empty slice and returned Ok(0), so the loop broke and the
connection was refused with "no sni found" -- the same message a client that
hung up gets, and nothing named a limit.

4 KiB is not the margin it looks like: a TLS 1.3 hello with a post-quantum key
share (X25519MLKEM768 is ~1.2 KiB), a session ticket and ECH is already past
2 KiB, and the client chooses extension order, so server_name can sit behind
all of it. The buffer now grows to one record's worth of handshake (2^14 bytes
plus the record header), which is as much as extract_sni can parse anyway, and
the refusal past that says which limit it hit.
extract_sni skipped the 5-byte record header without looking at it, so any
protocol whose sixth byte happens to be 1 was parsed as a ClientHello.

Records are still not reassembled, and the module now says so: a hello
fragmented across records parses differently here than in rustls, which
decides routing and never trust -- rustls re-parses the hello on the terminate
path and the app terminates the TLS itself on the passthrough path, and the
client picks both SNIs in any case.
check_connection_limit summed the instances' counters, compared the total to
max_connections_per_app, and left the increment to connect_multiple_hosts
below it. Connections that arrive together all read the same pre-increment
total and all pass: 32 connections released at once took 9-20 slots against a
limit of 4, over 8 runs. That is the one regime a connection limit exists for.

The reservation is now the check. fetch_update on the first candidate's
counter -- the same counter every connection of the app races from, so nothing
is counted twice -- decides and increments in one step, and hands two
connections racing for the last slot different values. The other candidates'
counts are still read outside that step, so a multi-instance app can overshoot
by up to connect_top_n - 1 at the boundary, bounded by the candidate count
rather than by arrival concurrency; a single-instance app is exact.

The winner's slot is a ConnectionSlot rather than an EnteredCounter because
that type increments when it is built, and here the count is already taken by
the time there is a guard to hold it.
@kvinwang
kvinwang merged commit 1e63f29 into next Sep 24, 2026
15 checks passed
@kvinwang
kvinwang deleted the fix/gateway-proxy-limits branch September 24, 2026 14:23
kvinwang added a commit that referenced this pull request Sep 25, 2026
…te abort

PR #1284 refuses an SNI that is not a DNS name without echoing it, PR
#1245 reads a ClientHello past the first 4096 bytes up to one record and
requires a handshake record, and PR #1278 fixes a parser abort on a
length byte that counts itself. tc-gw-proxy-prot-003 drives the first two
through the live proxy; tc-gw-internal-004 loads the parser with #[path],
since its inner doc comment broke include!, adds the self-counting and
non-handshake rows, and sends the abort probe to the live listener.

Signed-off-by: Kevin Wang <wy721@qq.com>
kvinwang added a commit that referenced this pull request Sep 25, 2026
PR #1245 made the per-app connection limit check take its slot
atomically, reaps relays whose client stops reading at the idle timeout,
and turns a zero-length write into an error. tc-gw-proxy-prot-006 now
opens its over-limit connections as one burst and runs the relay tests
that need a stalled reader.

Signed-off-by: Kevin Wang <wy721@qq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant