Skip to content

fix: pin real-latency speed test to physical NIC under TUN mode#92

Merged
PhoenixNil merged 1 commit into
mainfrom
fix/tun-speedtest-egress-pin
Jul 11, 2026
Merged

fix: pin real-latency speed test to physical NIC under TUN mode#92
PhoenixNil merged 1 commit into
mainfrom
fix/tun-speedtest-egress-pin

Conversation

@PhoenixNil

@PhoenixNil PhoenixNil commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • The "real delay" speed test spawns a throwaway second xray core on its own loopback socks ports. While the live core owns a full-route TUN, that helper core's outbound traffic was captured by the TUN and looped through the live proxy — the test either measured the wrong thing or timed out.
  • TunService.ResolveOutboundInterface resolves 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).
  • RealLatencyProbeService gates the pin on a live IsTunActive callback (wired from MainViewModel to ControlPanel.IsRunning && ControlPanel.IsTunMode) instead of the persisted settings.IsTunMode, since the persisted flag lags the UI toggle and can stay stuck true after a crash.
  • XrayConfigBuilder.BuildSpeedtestConfig applies the resolved interface as a sockopt.interface pin on each proxy outbound; the wireguard no-op (those outbounds carry no streamSettings) is now centralized inside ApplyOutboundInterface instead of duplicated at each call site.
  • The live TUN config's process-routing fallback gained a bare "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 shares TunService'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

  • Went through a max-effort /code-review pass (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 /simplify pass on top cleaned up a nullable-callback pattern and duplicate diagnostic logging.
  • Deliberately not included: using the real Windows routing table (via 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 dns block for the speed-test config to cover FakeDNS + domain-named nodes under TUNCodex 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 to inboundTag: ["tun-in"], and sockopt.interface pinning appears to also cover the helper outbound's own hostname resolution (Xray's sockopt.domainStrategy — which governs resolving an outbound's own server hostname — lives in the same sockopt object as interface), 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.
  • No automated tests exist in this repo; verified via dotnet build / BuildAndRun.ps1 -SkipRun only. 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

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Services/TunService.cs
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@PhoenixNil PhoenixNil merged commit 7f95a5f into main Jul 11, 2026
7 checks passed
@PhoenixNil PhoenixNil deleted the fix/tun-speedtest-egress-pin branch July 12, 2026 10:56
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