Propagate TCP tunnel hard errors instead of waiting on QUIC idle timeout - #40
Merged
Conversation
`tunnel_tcp_stream` ran the two copy directions inside `tokio::join!` to preserve buffered bytes during graceful half-close, but had no error-path bridge between them: on a hard error in one direction (peer RST, kernel-level disconnect), the failing future returned Err while its `SendStream` lived on in the outer scope, and the other direction kept blocking on a QUIC read that would never produce data. The function couldn't return until both halves finished, so the local `tcp_send` write half wasn't dropped either — no FIN to the local socket. Everything sat wedged until QUIC's ~30 s idle timeout reaped the connection. Fix: keep `join!` for the graceful path (EOF → `shutdown().await`, unchanged), but on a hard error in either direction explicitly (1) reset that direction's `SendStream` so the peer's `RecvStream` errors immediately, (2) fire a shared `Notify` so the other local direction's `select!` exits its blocking read, and (3) the unblocked direction calls `tcp_send.shutdown()` so the local TCP peer sees a FIN. Both ends run the same code, so the reset chain propagates across the QUIC connection without either side needing originator knowledge. `biased` ordering on the `select!` guarantees the Notify waiter is registered before the copy is polled. Also adds five new integration test files covering proxy semantics that were previously uncovered: - tests/half_close.rs — target-initiated half-close (forward), reverse half-close in both directions, SOCKS5 forward half-close. - tests/abrupt_close.rs — RST mid-stream from target/originator on forward and reverse paths, plus reverse dead-target. The three RST tests are the regression targets the fix above unblocks. - tests/multi_client.rs — two clients sharing one server, plus isolation when one client disconnects. - tests/udp_semantics.rs — per-sender response routing, oversized datagram framing, silent target doesn't wedge the tunnel. - tests/concurrent.rs — per-connection failure isolation under multiplexing (one stream RST'd, siblings continue cleanly). - tests/disconnect_cleanup.rs — reverse UDP socket and reverse SOCKS listener cleanup on client disconnect, mirroring the existing reverse-TCP listener cleanup test. Suite goes from 109 to 124 tests; all green. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
tunnel_tcp_streamran the two copy directions insidetokio::join!to preserve buffered bytes during graceful half-close, but had no error-path bridge between them: on a hard error in one direction (peer RST, kernel-level disconnect), the failing future returned Err while itsSendStreamlived on in the outer scope, and the other direction kept blocking on a QUIC read that would never produce data. The function couldn't return until both halves finished, so the localtcp_sendwrite half wasn't dropped either — no FIN to the local socket. Everything sat wedged until QUIC's ~30 s idle timeout reaped the connection.Fix: keep
join!for the graceful path (EOF →shutdown().await, unchanged), but on a hard error in either direction explicitly (1) reset that direction'sSendStreamso the peer'sRecvStreamerrors immediately, (2) fire a sharedNotifyso the other local direction'sselect!exits its blocking read, and (3) the unblocked direction callstcp_send.shutdown()so the local TCP peer sees a FIN. Both ends run the same code, so the reset chain propagates across the QUIC connection without either side needing originator knowledge.biasedordering on theselect!guarantees the Notify waiter is registered before the copy is polled.Also adds five new integration test files covering proxy semantics that were previously uncovered:
Suite goes from 109 to 124 tests; all green.