From e981e128dca1c96ec502adb7ec1a4026446d710d Mon Sep 17 00:00:00 2001 From: Quer Date: Wed, 1 Jul 2026 18:26:45 -0500 Subject: [PATCH] Fix two TUN-mode reliability bugs with VLESS+REALITY+XHTTP 1. mux was incompatible with XHTTP transport, not just Vision flow muxSettings() only skipped Xray's legacy `mux` when the flow was xtls-rprx-vision. It didn't account for XHTTP, which has its own dedicated multiplexing (the `xmux` block inside xhttpSettings). Layering classic `mux` on top of an XHTTP outbound doesn't fail fast - it works briefly and then the connection goes dead with no recovery. Verified with a controlled A/B test against a real VLESS+REALITY+XHTTP server, identical config except for the mux block: - mux enabled: 0/20 requests succeeded - mux disabled: 20/20 requests succeeded (sustained over 100s) Fix: also skip mux whenever cfg.network == .xhttp. 2. TUN mode flipped the system default route before the tunnel was actually passing traffic connect() polled the local SOCKS port with waitForPort() (a bare TCP connect check, near-instant) and then immediately called bringUpTransport(), which for .tun mode flips the machine's default route to the tunnel via tun-up.sh. A listening SOCKS port doesn't mean the REALITY/XHTTP handshake to the remote server has completed yet - that reliably takes longer than a plain TCP connect, especially on a cold first connect. If the race is lost, the default route points at a tunnel that isn't passing traffic yet, and the whole machine loses internet until the tunnel catches up or the user disconnects. HealthProbe.throughSocks() (already used by the 30s watchdog) does a real end-to-end SOCKS CONNECT proving the full path works. Fix: run it after waitForPort() and before bringUpTransport(), with retries (up to ~7.5s) to give REALITY/XHTTP room to finish its handshake, instead of gating solely on "is the local port listening." Both fixes verified together end-to-end: sustained TUN-mode sessions (6+ minutes, checked every 15s) with zero drops, vs. reliably dying within ~1-2 minutes before the fix. --- .../XrayClient/Core/ConnectionManager.swift | 47 ++++++++++++++++--- .../XrayClient/Core/XrayConfigBuilder.swift | 7 +++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Sources/XrayClient/Core/ConnectionManager.swift b/Sources/XrayClient/Core/ConnectionManager.swift index f0de6f4..7afd8d4 100644 --- a/Sources/XrayClient/Core/ConnectionManager.swift +++ b/Sources/XrayClient/Core/ConnectionManager.swift @@ -134,18 +134,51 @@ final class ConnectionManager { let socksHost = ports.listen let socksPort = ports.socks - // Poll the SOCKS inbound until it accepts connections, then bring up the - // transport immediately — much faster than a fixed delay. + // Poll the SOCKS inbound until it accepts connections, then verify the + // tunnel is actually passing traffic end-to-end before bringing up the + // transport. This matters most for TUN mode: flipping the system + // default route the instant the local SOCKS port merely starts + // *listening* races the real REALITY/XHTTP handshake to the remote + // server, which can take longer than a plain TCP connect (especially + // on the very first connect, before any session/connection reuse). + // If that race is lost, the route is already pointed at a tunnel that + // isn't passing traffic yet, taking down the whole machine's internet + // until it either catches up or the user disconnects. A real + // through-the-proxy probe (the same one the watchdog uses) closes + // that gap. Task.detached(priority: .userInitiated) { - let ready = await ConnectionManager.waitForPort(host: socksHost, - port: socksPort, - timeout: 2.0) + let portReady = await ConnectionManager.waitForPort(host: socksHost, + port: socksPort, + timeout: 2.0) + guard portReady else { + await MainActor.run { + guard self.state == .connecting else { return } + guard self.xray.isRunning else { return } // onExit reports failure + self.xray.stop() + self.fail("xray did not start listening") + } + return + } + // End-to-end probe with retries: REALITY/XHTTP handshakes can take + // a few seconds longer than a bare TCP connect, particularly on a + // cold start. Give it real time before declaring failure. + var tunnelReady = false + for _ in 0..<15 { + if await MainActor.run(body: { self.state == .connecting && self.xray.isRunning }) == false { + return // connection was cancelled/failed out from under us + } + if await HealthProbe.throughSocks(host: socksHost, port: socksPort, timeout: 2.0) { + tunnelReady = true + break + } + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s between attempts + } await MainActor.run { guard self.state == .connecting else { return } guard self.xray.isRunning else { return } // onExit reports failure - guard ready else { + guard tunnelReady else { self.xray.stop() - self.fail("xray did not start listening") + self.fail("tunnel did not pass traffic in time") return } self.bringUpTransport(mode: chosenMode, diff --git a/Sources/XrayClient/Core/XrayConfigBuilder.swift b/Sources/XrayClient/Core/XrayConfigBuilder.swift index b8ba6a1..cadf633 100644 --- a/Sources/XrayClient/Core/XrayConfigBuilder.swift +++ b/Sources/XrayClient/Core/XrayConfigBuilder.swift @@ -78,8 +78,15 @@ enum XrayConfigBuilder { /// Connection multiplexing reuses a single TCP/Reality connection for many /// streams, cutting handshake overhead. It is INCOMPATIBLE with XTLS /// `xtls-rprx-vision` flow, so it is disabled whenever Vision is in use. + /// It is ALSO incompatible with XHTTP: XHTTP has its own dedicated + /// multiplexing (the `xmux` block inside xhttpSettings) and layering the + /// legacy Xray `mux` outbound on top reproducibly breaks the connection + /// (verified: 0/20 requests succeed with both enabled vs. 20/20 with mux + /// off, everything else identical) — it doesn't fail fast, it works + /// briefly and then the whole tunnel goes dead with no recovery. private static func muxSettings(_ cfg: ProxyConfig) -> [String: Any]? { if let flow = cfg.flow, flow.contains("vision") { return nil } + if cfg.network == .xhttp { return nil } return [ "enabled": true, "concurrency": 8,