Skip to content

fix(wind-core): reap idle half-closed relays#50

Merged
Itsusinn merged 2 commits into
mainfrom
fix/relay-half-close-reap
Jun 25, 2026
Merged

fix(wind-core): reap idle half-closed relays#50
Itsusinn merged 2 commits into
mainfrom
fix/relay-half-close-reap

Conversation

@Itsusinn

Copy link
Copy Markdown
Member

Problem

Proxied TCP connections through tuic-server are never terminated until the server is killed (Ctrl+C). They accumulate in CLOSE_WAIT.

All TCP relays funnel through copy_io, which delegates to tokio::io::copy_bidirectional_with_sizes — that only returns once both directions reach EOF. When the outbound peer half-closes (e.g. an origin server FINs after its response while the downstream TUIC client leaves its upload half of the QUIC bi-stream open), the inbound→outbound direction never EOFs. So:

  • the outbound TCP socket sits in CLOSE_WAIT, and
  • the relay task hangs for the entire lifetime of the long-lived QUIC connection (TUIC keeps the tunnel alive via heartbeats).

These half-open relays accumulate and are only reaped on full connection teardown. #49 wired spawn_connection_child so children are cancelled on connection teardown, but nothing reaps an individual stream that half-closed while the connection stays up.

Both QUIC backends (quinn/quiche) propagate EOF correctly, so this is purely copy_bidirectional's "wait for both" semantics, not a missing-EOF bug.

Fix

Keep copy_bidirectional's correct half-close data handling (it still shuts down the opposite writer and drains in-flight bytes), but arm an idle reaper once the outbound peer closes:

  • A thin Tracked wrapper counts per-direction throughput and observes the half-close (copy_bidirectional shuts down the inbound writer only after the outbound reader hit EOF).
  • The reaper arms only when the outbound side closes. After RELAY_HALF_CLOSE_TIMEOUT (30s) with no bytes moved, the half-open relay is torn down, dropping both streams and closing the lingering sockets.
  • Any byte moved resets the window → a slow-but-live download is never cut off. A fully-open idle tunnel (keep-alive / long-poll) is never reaped because the timer only arms after the outbound side has closed.

The asymmetry (arm on "outbound closed, inbound lingering"; stay disarmed on "inbound closed first, wait for a slow outbound response") is correct at all five copy_io call sites — the convention is a = inbound, b = outbound everywhere.

The public copy_io signature is unchanged; the timeout is injected via a private copy_io_with_timeout so tests don't wait the production grace period.

Tests

Three new unit tests in wind-core::io:

  • clean_full_close_completes — normal exchange returns correct byte counts, reaper does not fire.
  • half_open_idle_relay_is_reaped — the regression: outbound closes, inbound lingers silent; copy_io returns instead of hanging.
  • fully_open_idle_relay_is_not_reaped — an idle but fully-open tunnel is left running.

wind-core (129) + wind-base + wind-tuic (18) suites pass; clippy and fmt clean.

🤖 Generated with Claude Code

Itsusinn and others added 2 commits June 26, 2026 03:09
`copy_io` delegates to `copy_bidirectional`, which only returns once BOTH
directions reach EOF. When the outbound peer half-closes — e.g. an origin
server FINs after its response while the downstream TUIC client leaves its
upload half of the QUIC bi-stream open — the inbound→outbound direction never
EOFs, so the outbound TCP socket sits in CLOSE_WAIT and the relay task hangs
for the entire lifetime of the long-lived QUIC connection. Proxied TCP
connections then only get cleaned up when the connection itself tears down
(client disconnect or server kill). #49 reaps connection children on
connection teardown only, not per stream.

Arm an idle reaper once the outbound peer closes (detected when
`copy_bidirectional` shuts down the inbound writer): after
RELAY_HALF_CLOSE_TIMEOUT (30s) with no traffic the half-open relay is torn
down, dropping both streams and closing the lingering sockets. Any byte moved
resets the window, so a slow-but-live transfer is never cut off; a fully-open
idle tunnel (keep-alive / long-poll) is never reaped because the timer only
arms after the outbound side has closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap `RelayMeters::activity` and the reaper match per the repo's nightly
rustfmt config (`wrap_comments`, `imports_granularity`, …). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Itsusinn Itsusinn merged commit 58505cf into main Jun 25, 2026
16 checks passed
@Itsusinn Itsusinn deleted the fix/relay-half-close-reap branch June 25, 2026 19:24
@codspeed-hq

codspeed-hq Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 22 untouched benchmarks
⏩ 11 skipped benchmarks1


Comparing fix/relay-half-close-reap (9e8dbcb) with main (bae4657)

Open in CodSpeed

Footnotes

  1. 11 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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