fix: pin real-latency speed test to physical NIC under TUN mode#92
Conversation
The "real delay" speed test spawns a throwaway second xray core. While the live core owns a full-route TUN, that helper's outbound traffic was being captured by the TUN and looped through the live proxy, so the test either measured the wrong thing or timed out. TunService.ResolveOutboundInterface picks the interface the helper core should bind to: an explicitly configured NIC (validated for existence, Up state, and not being the TUN adapter itself, falling back to auto- selection when stale), or an automatically chosen physical NIC with a real default gateway (IPv4 preferred; IPv6 link-local gateways only count when backed by a global IPv6 address). RealLatencyProbeService gates this on a live TUN-active callback (IsTunActive, wired from MainViewModel) rather than the persisted settings.IsTunMode flag, since the latter lags the UI toggle and can survive a crash as a stale true. XrayConfigBuilder.BuildSpeedtestConfig applies the resolved interface as a sockopt.interface pin per proxy outbound (ApplyOutboundInterface now centrally no-ops for wireguard, since those outbounds have no streamSettings). The live TUN config's process-routing fallback also gained a bare "xray" entry alongside the "self/"/"xray/" path sugars, as defense-in-depth for when the resolver finds no usable NIC. TunConfirmationDialog's adapter picker now shares TunService's TUN-adapter exclusion filter so it no longer offers interfaces (Tunnel type, xray-tun itself) that the resolver would silently reject later.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d1fc023a3c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var outbound = BuildProxyOutbound(server, outTag); | ||
| if (outboundInterface is not null) | ||
| { | ||
| ApplyOutboundInterface(outbound, outboundInterface); |
There was a problem hiding this comment.
Route helper DNS outside FakeDNS
When TUN is running with FakeDNS enabled and a tested server's host is a domain, pinning only the helper outbound socket still leaves the helper core's hostname lookup on system DNS. The live TUN rules send tun-in port 53 to dns-out before the xray process bypass, so the helper can receive a 198.18/15 FakeDNS address and then dial that synthetic IP on the physical NIC, making real-latency time out for common domain-based nodes. Add a DNS block/forced resolution path for the speed-test core before applying the interface pin.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Tested this live rather than reasoning about it further: connected via TUN with FakeDNS enabled, then ran the real-latency test against several domain-hostname nodes (e.g. vless-jp01.waimaosass.icu, vless-jp02.waimaosass.icu, hk01.poke-mon.xyz). All returned real, varied round-trip times (68–601ms) — no timeouts.
Checked the actual generated xray_config.json for the live core: the FakeDNS capture rule is scoped to inboundTag: ["tun-in"], so it only fires for traffic that physically arrives at the live core's own TUN inbound. For the helper core's DNS query to hit it, an unbound UDP:53 packet would first have to get swept into the xray-tun adapter by the OS's autoSystemRoutingTable default route.
Per Xray's docs, sockopt.domainStrategy — the field that governs resolving the outbound's own server hostname — lives in the same sockopt object as interface. That strongly suggests hostname resolution for the outbound's own address happens as part of the same pinned dial, not a separate unbound path, which matches what's observed: the helper's DNS query for its own proxy server's hostname appears to go out the pinned physical NIC directly, never reaching tun-in, never hitting the FakeDNS rule.
Given that, I don't think the dns block is needed here. Happy to revisit if anyone can reproduce a timeout with FakeDNS + a domain-hostname node under TUN — but it didn't reproduce after direct testing.
| // Prefer an IPv4 gateway because the speed-test target and most node endpoints | ||
| // are IPv4-capable. Speed provides a deterministic tie-break for multi-NIC PCs. | ||
| .OrderByDescending(HasIPv4Gateway) | ||
| .ThenByDescending(ni => ni.Speed) |
There was a problem hiding this comment.
Select the routed egress instead of fastest NIC
In auto mode with two up non-TUN adapters that both have default gateways, this tie-break can choose a faster but non-routed/isolated interface instead of the adapter Windows would actually use for the proxy server or test URL. The speed-test config then pins every helper outbound to that wrong NIC and reports timeouts even though the live TUN path works; resolve the interface from the route table/metric for the destination rather than link speed.
Useful? React with 👍 / 👎.
Summary
TunService.ResolveOutboundInterfaceresolves the physical NIC the helper core should bind to: an explicitly configured interface (validated for existence/Up state/not-the-TUN-adapter, with fallback to auto-selection when stale), or an automatically chosen NIC with a real default gateway (IPv4 preferred; an IPv6 link-local gateway only counts when the adapter also holds a global IPv6 address).RealLatencyProbeServicegates the pin on a liveIsTunActivecallback (wired fromMainViewModeltoControlPanel.IsRunning && ControlPanel.IsTunMode) instead of the persistedsettings.IsTunMode, since the persisted flag lags the UI toggle and can stay stucktrueafter a crash.XrayConfigBuilder.BuildSpeedtestConfigapplies the resolved interface as asockopt.interfacepin on each proxy outbound; the wireguard no-op (those outbounds carry nostreamSettings) is now centralized insideApplyOutboundInterfaceinstead of duplicated at each call site."xray"entry alongside the existing"self/"/"xray/"path sugars, as defense-in-depth for the rare case where the resolver finds no usable physical NIC.TunConfirmationDialog's adapter picker now sharesTunService's TUN-adapter exclusion filter, so it no longer lists interfaces (Tunnel-type adapters, the xray-tun adapter itself) that the resolver would later silently reject.Reviewer notes
/code-reviewpass (10 finder angles + verification) that surfaced several real gaps in the initial version — most notably a stale/unpinned explicit interface, IPv6 link-local gateways wrongly disqualifying IPv6-only networks, and duplicated wireguard-exclusion logic — all fixed in this branch. A/simplifypass on top cleaned up a nullable-callback pattern and duplicate diagnostic logging.GetIpForwardTable2) instead of the link-speed heuristic for automatic NIC selection when multiple non-TUN adapters have default gateways (flagged by Codex) — a real remaining gap on multi-NIC hardware, needs dedicated hardware to test rather than a blind fix.A— Codex flagged this as a plausible theoretical gap, but it doesn't reproduce: tested live with FakeDNS on + TUN connected, real-latency test against multiple domain-hostname nodes all returned correct, varied round-trip times. The live core's FakeDNS capture rule is scoped todnsblock for the speed-test config to cover FakeDNS + domain-named nodes under TUNinboundTag: ["tun-in"], andsockopt.interfacepinning appears to also cover the helper outbound's own hostname resolution (Xray'ssockopt.domainStrategy— which governs resolving an outbound's own server hostname — lives in the samesockoptobject asinterface), so the helper's DNS traffic for its own server address never reaches the TUN inbound in the first place. See reply on the review thread for detail.dotnet build/BuildAndRun.ps1 -SkipRunonly. TUN-mode speed test behavior itself was manually smoke-tested (connect via TUN with FakeDNS on, run "test all real latencies" against domain-hostname nodes — succeeded).🤖 Generated with Claude Code