fix(tun): preserve half-close semantics in TUN client - #1672
Draft
ruib-7f79mj44pz wants to merge 1 commit into
Draft
fix(tun): preserve half-close semantics in TUN client#1672ruib-7f79mj44pz wants to merge 1 commit into
ruib-7f79mj44pz wants to merge 1 commit into
Conversation
ruib-7f79mj44pz
marked this pull request as draft
September 6, 2026 16:14
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.
Summary
Preserve TCP half-close semantics in the TUN TCP forwarding path instead of tearing down the entire proxied connection when either copy direction reaches a clean EOF.
The current TUN handler returns when either
io.Copyfinishes. In Windows TUN mode, this can race with delivery of already-received data to the local application, causing a TCP reset and truncated HTTPS responses.This PR adds write-side-only close support to the Hysteria TCP connection and changes the TUN forwarding path so that a clean EOF is propagated with
CloseWrite()when supported. After a successful half-close, the opposite forwarding direction is allowed to finish normally before final cleanup.If copying fails,
CloseWrite()fails, or the connection does not support write-side close, the handler falls back to the previous teardown behavior.Reproduction
Test environment:
A reliable reproducer in the tested environment was repeatedly downloading the following HTTPS resource through TUN mode:
The expected response body size is 20262 bytes.
Failed requests typically looked like:
or:
Repeated baseline runs produced failure rates between approximately 24% and 46%. The varying failure rate is consistent with a timing-sensitive race.
The truncation also repeatedly occurred at the same response sizes, corresponding to TLS plaintext record boundaries, rather than at arbitrary byte offsets.
Reproduction sensitivity
This appears to be a timing-sensitive race, so the reproduction rate is likely dependent on network topology and runtime conditions.
In the tested setup, the Hysteria client-to-server path was a long-haul, high-latency path. The VPS was located in North Carolina, while the fixed target address (
152.3.70.87) belongs to Duke University's network. The target-facing side of the proxy path could therefore be substantially shorter than the Hysteria client-to-server path.Factors that may affect the reproduction probability include client-to-Hysteria-server RTT, packet loss and jitter, QUIC loss recovery, the relative latency of the Hysteria-server-to-target path, client TUN/TCP stack behavior, application/TLS buffering, and target-side response and connection-close timing.
Therefore, the Duke resource should be considered a reliable reproducer in the tested environment rather than a requirement for triggering the bug. A low-latency or otherwise different network path may make the race substantially harder to reproduce.
One possible reason adverse network conditions increase the reproduction probability is that packet loss, retransmission, scheduling, or QUIC loss recovery can make stream delivery more bursty. Multiple recovered pieces of stream data may become readable within a short interval, narrowing the time between delivery of the final data and observation of stream EOF while the local application is still draining data already queued in the TCP stack.
This is a hypothesis about what increases the probability of the race, not the root cause itself.
The underlying correctness issue does not depend on a particular congestion or loss pattern: a clean EOF in one forwarding direction represents directional shutdown and should not require immediate destruction of the opposite TCP direction.
Packet capture observations
Packet captures were taken on both the VPS-side target TCP connection and the Windows TUN side.
In failed cases:
These observations suggest that data could already have reached the local TCP path while the application/TLS layer had not yet consumed all of it when the TUN forwarding connection was torn down.
Timing diagnostic
As a diagnostic experiment only, a 100 ms delay was added after the remote-to-local
io.Copyreturned.The result was:
The delay was not considered an acceptable fix, but eliminating the failures by delaying teardown strongly indicated that connection teardown timing was involved.
Half-close experiment
The artificial delay was then replaced with proper half-close handling.
With the modified client and an otherwise unmodified server:
Additional A/B testing was performed to isolate whether the relevant behavior was on the client or server side:
The server-side half-close experiment therefore did not independently fix the reproduced problem and is intentionally not included in this PR.
The client TUN forwarding path is sufficient to fix the reproduced reset/truncation issue.
Root cause
The current TUN TCP forwarding implementation starts two
io.Copygoroutines and returns when either one completes.A clean EOF from one direction therefore causes
NewConnection()to return and full connection cleanup to begin, even though EOF represents the end of only one direction of a TCP connection.For example, when the remote-to-local direction reaches EOF, the local write side should be half-closed so that the local TCP stack and application can observe a normal FIN and drain data that has already been queued for delivery.
Instead, immediately tearing down the whole proxied connection can race with that drain and surface to the local application as a reset before all buffered TLS data has been consumed.
Fix
QStream.Close()currently performs a full logical close by cancelling the read side and closing the QUIC stream write side.This PR adds a separate
QStream.CloseWrite()operation which closes only the QUIC stream write side.The client TCP wrapper exposes the same
CloseWrite()capability.The TUN forwarding handler then behaves as follows:
io.Copy.CloseWrite()support.CloseWrite()propagates directional EOF without destroying the opposite direction.CloseWrite()is unsupported or fails, the handler falls back to the previous teardown behavior.Testing
The final version of this patch was formatted with
gofmt, passedgit diff --check, built successfully, and passed the existing Go tests.The final candidate binary was then retested against an unmodified Hysteria server using the same 50-request reproduction loop.
Result:
Scope
This PR intentionally modifies only the client TUN TCP forwarding path.
A similar half-close model can be considered separately for the server relay path, but that changes broader proxy connection-lifetime semantics and is not required to fix the reproduced Windows TUN reset/truncation issue.