Skip to content

fix(tunnel): bound deferred handshakes by the TCP dial timeout - #3439

Open
polaris-arch wants to merge 5 commits into
MetaCubeX:Alphafrom
polaris-arch:fix/deferred-handshake-budget
Open

polaris-arch wants to merge 5 commits into
MetaCubeX:Alphafrom
polaris-arch:fix/deferred-handshake-budget

Conversation

@polaris-arch

@polaris-arch polaris-arch commented Nov 9, 2026 •

Copy link
Copy Markdown

Merge prerequisite: merge #3436 first. This change can be reviewed independently, but must not be merged ahead of the lazy-TFO lifetime fix: cancellation during the initial Write may call Close concurrently with lazy socket publication. The recorded combined validation includes that fix. No ready-to-merge claim is made for this PR in isolation.

Keep deferred first-write handshakes within the existing TCP dial/preparation budget. Use the existing connection-context interrupter during preparation, reject late successes, and detach it before relay ownership.

Concrete bug and normal-path reachability

handleTCPConn creates the dial-timeout context, calls proxy.DialContext, then performs an initial Write for NeedHandshake transports. WebSocket early-data construction returns a lazy connection whose own context is derived from Background. Its HTTP Upgrade runs on the first write, so a peer that accepts the request but never returns HTTP 101 can keep that write blocked after the tunnel's 5-second dial budget has expired.

This is reachable through ordinary configured transports: VMess/VLESS/Trojan WebSocket options with max-early-data > 0 reach StreamWebsocketConn, which selects the deferred wrapper. A stalled upgrade is an adverse network/peer condition, not the healthy-handshake fast path. No production incidence rate is established. The timeout begins at the existing preparation stage; DNS/sniffing and established relay lifetime are outside this change.

Source: old tunnel handler, WebSocket lazy context and first write, configured adapter example.

Executable reproduction and observed result

Run in a disposable checkout with Go 1.26.10 (the recorded validation toolchain):

git fetch https://github.com/polaris-arch/mihomo.git 4f6bd36b3231ab996fb232f3d46fdc06194ae613
git checkout --detach 4f6bd36b3231ab996fb232f3d46fdc06194ae613
go test -count=1 -timeout 2m -v -run '^TestDeferredHandshake' ./tunnel
CGO_ENABLED=1 go test -race -count=1 -timeout 3m -run '^TestDeferredHandshake' ./tunnel

To test the same relevant fixture against unchanged Alpha:

git worktree add --detach ../mihomo-3439-alpha 478af2a57c48bdb8e85f6ed93b2c3735e90ceabd
git show 4f6bd36b3231ab996fb232f3d46fdc06194ae613:tunnel/deferred_handshake_test.go > ../mihomo-3439-alpha/tunnel/deferred_handshake_test.go
(cd ../mihomo-3439-alpha && go test -count=1 -timeout 35s -v -run '^TestDeferredHandshakeSilentWebSocketBudget/buffered-early-data$' ./tunnel)

The baseline command is expected to fail an assertion, not fail to compile.

Controlled scenario Unchanged Alpha Fixed source
Buffered early-data HTTP Upgrade with no HTTP 101 Still blocked at the test's 7-second observation limit Handler finishes at the existing 5-second timeout in the recorded run
Same FIN-only frontend response control Passes Passes; reverse response remains usable

Fault injection and defensive coverage

  • The main reproducer uses the real WebSocket client over net.Pipe, a real loopback TCP frontend and handleTCPConn. Route/proxy selection is replaced with a test seam; the peer deliberately withholds HTTP 101. This is controlled peer-fault reproduction, not a complete configured-node or physical-TUN end-to-end test.
  • LateDialSuccessClosed and LateWriteSuccessClosed deliberately return success after cancellation/closure. These are noncooperative-adapter fault injection and defensive ownership checks, not proof that a normal adapter behaves that way.
  • Empty-early-data timeout, retry payload preservation, REJECT, FIN and relay survival past the dial deadline are boundary/positive controls. The empty case was not separately run as an old-Alpha negative control.
  • An adapter that ignores cancellation can still block until it returns; the late result is rejected. The patch does not forcibly terminate arbitrary noncooperative code.

Validation passes independently and with related fixes: focused/race tests, full default/with_gvisor suites, vet and Windows/macOS tunnel compilation. This is a timeout/ownership result, not an inferred whole-process resource saving.

Merge after #3436 for safe lazy-TFO closure; the old-TFO integration negative control is explicitly a late-success injected backend. #3437 handles cancellation attribution separately. The optional native API is not required; #3438 is the separate native frontend integration.

Head 4f6bd36b changes tests only relative to 28b58eb0; current standalone deferred-handshake focused/race suites pass in final frozen-batch validation.

Healthy WebSocket lifetime control

TestDeferredHandshakeWebSocketWinnerLifetime enters production handleTCPConn through a real TCP frontend, performs a real HTTP 101/early-data-header upgrade against a loopback WebSocket server, and checks framed data in both directions. It observes the actual preparation context expire with DeadlineExceeded, then writes and reads again on the accepted connection. This passes both independently and in the frozen integration.

go test -count=1 -timeout 30s -v -run '^TestDeferredHandshakeWebSocketWinnerLifetime$' ./tunnel

Route/proxy selection remains a fixture seam. This is WebSocket transport success, not a VMess authentication, REALITY or configured physical-TUN test. The context-recording channel is nonblocking on retries and fixture globals are restored even if bounded cleanup fails. No new runtime change was needed for this positive control.

Final frozen-batch validation also passes full root ./... default/with_gvisor, vet and Windows/macOS changed-package compilation in the declared combination: tunnel 98d8db81, TFO b32780aa, group e8ffb994, context completion 23285c8f, and native API 5b6706cc through a test-only modfile. These full-suite results bind that combination, not a new standalone full-suite run for every PR. SKIP_INTEROP_TEST=1; the nested test/ module is outside root ./.... Older validation links retain their original source identities. No physical-TUN, kernel Fast Open SYN/cookie, production incidence or whole-process resource claim is made.

@polaris-arch
polaris-arch marked this pull request as ready for review November 10, 2026 14:26
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