Skip to content

fix: complete the cancellation chain across all binaries and crates#22

Merged
Itsusinn merged 3 commits into
mainfrom
fix/quiche-ctrlc-shutdown
Jun 11, 2026
Merged

fix: complete the cancellation chain across all binaries and crates#22
Itsusinn merged 3 commits into
mainfrom
fix/quiche-ctrlc-shutdown

Conversation

@Itsusinn

@Itsusinn Itsusinn commented Jun 11, 2026

Copy link
Copy Markdown
Member

Problem

Ctrl-C did not terminate tuic-server when running the quiche backend (backend.mode = "quiche"): the quiche inbound created its own standalone CancellationToken and its accept loop never checked any token, so the listen task and every live connection kept running until main's 10s drain timeout force-aborted them.

A follow-up audit of the cancellation mechanism across the whole workspace found several more components that ignored the cancel token, leaked detached tasks past shutdown, or risked pinning a runtime worker.

Changes

tuic-server (quiche backend)

  • TuicheInboundBuilder::cancel_token(): the accept loop now select!s the token; per-connection tokens are children of it, so one cancel() stops accepting and closes every live connection.
  • create_quiche_inbound wires ctx.cancel.child_token() into the builder, mirroring the quinn backend.
  • Connection handlers are tracked in a TaskTracker and awaited after the accept loop exits, so CONNECTION_CLOSE frames flush before the runtime drops.
  • wind-quic: the accept-forwarding task in bind_server exits once all QuicheAcceptors are dropped, releasing the listener socket/router.

tuic-server (quinn backend)

  • After the listen loop exits: endpoint.close() + wait_idle() so queued close frames flush; previously peers only learned about the shutdown via idle timeout.
  • Connection handlers are spawned into the shared ctx.tasks TaskTracker (previously created but unused).
  • The CertResolver file watcher takes a cancel token instead of looping forever.

tuic-client

  • Previously had no shutdown path at all — ctrl-c hard-killed the process and the server only noticed via idle timeout. Now: run_with_cancel(), ctrl-c → cancel → 10s drain in main (same structure as tuic-server), token-aware SOCKS5 accept loop and TCP/UDP forwarders, per-session tasks tracked in TaskTrackers.
  • wind-tuic (quinn outbound): on cancellation the heartbeat poll closes the QUIC connection ("client shutdown") so the server reaps it immediately.

wind / wind-socks

  • Per-connection SOCKS tasks get a child token and are tracked; the detached UDP association task is cancel-guarded; listen waits for in-flight sessions on shutdown.

wind-naive

  • Stop join()ing the bridge I/O thread from async — a parked blocking naive.read() would pin a tokio worker indefinitely. Drop the handle instead (same strategy as the UoT relay path).

Verification

Tested on Windows by sending real CTRL_C_EVENTs (via AttachConsole + GenerateConsoleCtrlEvent):

Scenario Before After
tuic-server quinn exits promptly exits < 1s, close frames flushed
tuic-server quiche hangs 10s, then force-aborted exits < 1s with graceful shutdown logs
tuic-client (relaying live traffic) hard kill, server waits idle timeout exits < 1s; server logs Connection closed immediately

cargo build / cargo clippy clean with the quiche feature; 164 unit tests pass across the modified crates.

🤖 Generated with Claude Code

Itsusinn and others added 3 commits June 12, 2026 01:01
The quiche inbound created its own root CancellationToken and its accept
loop never checked it, so the binary's ctrl-c handler had nothing to
cancel: the listen task and every live connection kept running until
main's 10s drain timeout force-aborted them ('Server did not drain
within 10s of Ctrl-C').

- wind-tuic (quiche): add TuicheInboundBuilder::cancel_token; the accept
  loop now selects on the token and per-connection tokens are children
  of it, so one cancel() stops accepting and closes every connection.
- tuic-server: wire ctx.cancel into the quiche builder, mirroring the
  quinn backend.
- wind-quic (quiche): the accept-forwarding task exits once all
  QuicheAcceptors are dropped, releasing the listener socket/router.
- wind-tuic (quinn): close the endpoint and wait_idle() after the listen
  loop exits so CONNECTION_CLOSE frames flush before the runtime drops.

Verified on Windows: pre-fix the quiche backend hung the full 10s after
Ctrl-C; post-fix both backends exit gracefully within a second.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the quiche ctrl-c fix: a full audit of the cancellation
mechanism found several more components that ignored the cancel token,
leaked detached tasks past shutdown, or risked blocking a runtime worker.

- tuic-client: had no shutdown path at all (ctrl-c hard-killed the
  process; the server only noticed via idle timeout). Add
  run_with_cancel(), wire ctrl-c -> cancel -> 10s drain in main (same
  structure as tuic-server), select the token in the SOCKS5 accept loop
  and TCP/UDP forwarders, and track per-session tasks in TaskTrackers so
  shutdown waits for them.
- wind-tuic (quinn outbound): on cancellation the heartbeat poll now
  closes the QUIC connection ("client shutdown") so the server reaps it
  immediately instead of waiting out its idle timeout.
- wind-tuic (quiche inbound): track serve_connection tasks and wait for
  them after the accept loop exits so CONNECTION_CLOSE frames flush
  before the caller drops the runtime.
- wind-tuic (quinn inbound): spawn connection handlers into the shared
  ctx.tasks TaskTracker (previously created but unused) so context
  owners can drain them.
- wind-socks: per-connection tasks get a child token and a TaskTracker;
  the detached UDP association task is cancel-guarded; listen waits for
  in-flight sessions on shutdown.
- wind-naive: stop join()ing the bridge I/O thread from async — a
  parked blocking read would pin a tokio worker indefinitely. Drop the
  handle instead (same strategy as the UoT relay).
- tuic-server: the CertResolver file watcher now takes a cancel token
  instead of looping forever.

Verified end-to-end on Windows: client/server pair relays traffic, then
ctrl-c on the client exits in <1s with the server logging the connection
close immediately; ctrl-c on the server exits in <1s on both the quinn
and quiche backends. 164 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Itsusinn Itsusinn changed the title fix(tuic): make Ctrl-C actually stop the quiche backend fix: complete the cancellation chain across all binaries and crates Jun 11, 2026
@Itsusinn Itsusinn merged commit 70e1180 into main Jun 11, 2026
13 checks passed
@Itsusinn Itsusinn deleted the fix/quiche-ctrlc-shutdown branch June 11, 2026 17:47
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