Skip to content

fix(tun): preserve half-close semantics in TUN client - #1672

Draft
ruib-7f79mj44pz wants to merge 1 commit into
HyNetworks:masterfrom
ruib-7f79mj44pz:fix/tun-half-close-rst-race
Draft

fix(tun): preserve half-close semantics in TUN client#1672
ruib-7f79mj44pz wants to merge 1 commit into
HyNetworks:masterfrom
ruib-7f79mj44pz:fix/tun-half-close-rst-race

Conversation

@ruib-7f79mj44pz

Copy link
Copy Markdown

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.Copy finishes. 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:

Component Environment
Client Windows, Hysteria 2.12.2
Client mode TUN mode using the sing-tun system stack
Server Linux Hysteria server
Network Long-haul, high-latency client-to-VPS path
Comparison SOCKS5 mode did not reproduce the issue

A reliable reproducer in the tested environment was repeatedly downloading the following HTTPS resource through TUN mode:

1..50 | ForEach-Object {
    curl.exe -4 -sS -o NUL `
      -w "$($_) HTTP=%{http_code} SIZE=%{size_download} TOTAL=%{time_total}s`n" `
      --resolve "www.duke.edu:443:152.3.70.87" `
      "https://www.duke.edu/wp-includes/blocks/cover/style.min.css?ver=7.0.4"

    Start-Sleep -Milliseconds 300
}

The expected response body size is 20262 bytes.

Failed requests typically looked like:

curl: (56) Recv failure: Connection was reset
HTTP=200 SIZE=7935

or:

curl: (56) Recv failure: Connection was reset
HTTP=200 SIZE=15935

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:

  • the VPS-side TCP connection received the complete response from the target;
  • the target connection closed normally with FIN, with no target-side RST explaining the truncation;
  • the Windows TUN-side TCP flow acknowledged the received TCP data;
  • the local application nevertheless received only 7935 or 15935 response-body bytes before observing a reset.

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.Copy returned.

The result was:

original client:
23 failures / 50 requests

client with 100 ms teardown delay:
0 failures / 50 requests

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:

50 / 50 successful
0 resets
all responses SIZE=20262

Additional A/B testing was performed to isolate whether the relevant behavior was on the client or server side:

Client Server Result
Original Original 38/50 successful, 12 resets
Modified Original 50/50 successful, 0 resets
Modified Modified server half-close experiment 50/50 successful, 0 resets
Original Modified server half-close experiment 36/50 successful, 14 resets

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.Copy goroutines 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:

  1. Both forwarding directions continue to use io.Copy.
  2. A copy error still terminates the handler and uses the existing full cleanup behavior.
  3. On clean EOF, the destination side is checked for CloseWrite() support.
  4. If supported, CloseWrite() propagates directional EOF without destroying the opposite direction.
  5. After a successful half-close, the handler waits for the opposite forwarding direction to finish.
  6. Final full cleanup occurs after both directions have completed.
  7. If 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, passed git 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:

50 / 50 successful
0 resets
all responses SIZE=20262

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.

@ruib-7f79mj44pz
ruib-7f79mj44pz marked this pull request as draft September 6, 2026 16:14
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