From d4511a29756f2af6ebc461c0e1e57944783a81ff Mon Sep 17 00:00:00 2001 From: Sanjay-doppalapudi Date: Thu, 27 Aug 2026 13:46:46 -0500 Subject: [PATCH 1/4] fix(desktop): resolve WSL distro IP from the default route, not hostname -I order The WSL backend hung on "Connecting to WSL..." whenever Docker bridge networks existed in the distro: `hostname -I` lists bridge addresses first, and the first IPv4 was assumed to be the reachable eth0 address, so the renderer polled an unreachable 172.x bridge IP forever. Resolve the address from the `src` field of `ip -4 route get 1.1.1.1` instead (a pure routing-table lookup; bridge interfaces never own the default route), keep `hostname -I` as the fallback for distros without a default route, and log the probe output and the chosen address. This also repairs mirrored-mode detection in the same scenario: the probe now reports the mirrored host IP, which isLocalHostIpv4 matches, so the renderer URL correctly falls back to loopback. Fixes pingdotgg/t3code#5211 --- .../src/wsl/DesktopWslEnvironment.test.ts | 39 ++++++++++++++++ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 46 ++++++++++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 895d246e3689..92f4f6108823 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -15,6 +15,7 @@ import { formatMissingToolsReason, formatNodePtyProbeFailureReason, formatWslShellTransportFailureReason, + parseDistroIp, parseNodePath, parseNodeVersion, parseResolvedPath, @@ -88,6 +89,44 @@ describe("probeWslDistros", () => { }); }); +describe("parseDistroIp", () => { + it("prefers the default-route src address over hostname -I ordering", () => { + // Docker bridge networks sort before eth0 in `hostname -I`; the route + // lookup must win so an unreachable bridge IP is never picked (#5211). + const stdout = [ + "route:1.1.1.1 via 192.168.1.1 dev eth0 src 192.168.1.219 uid 1000", + "all:172.22.0.1 172.19.0.1 172.17.0.1 192.168.1.219", + ].join("\n"); + expect(parseDistroIp(stdout)).toBe("192.168.1.219"); + }); + + it("resolves the NAT-mode eth0 address from the route src field", () => { + const stdout = [ + "route:1.1.1.1 via 172.27.0.1 dev eth0 src 172.27.5.44 uid 1000", + "all:172.27.5.44", + ].join("\n"); + expect(parseDistroIp(stdout)).toBe("172.27.5.44"); + }); + + it("falls back to the first hostname -I address when there is no default route", () => { + expect(parseDistroIp("route:\nall:172.27.5.44 fe80::1")).toBe("172.27.5.44"); + }); + + it("falls back when the route line has no valid IPv4 src token", () => { + const stdout = ["route:1.1.1.1 dev eth0 src fdcc::2 metric 256", "all:172.27.5.44"].join("\n"); + expect(parseDistroIp(stdout)).toBe("172.27.5.44"); + }); + + it("accepts CRLF output", () => { + expect(parseDistroIp("route:\r\nall:172.27.5.44\r\n")).toBe("172.27.5.44"); + }); + + it("returns null when neither probe produced an IPv4 address", () => { + expect(parseDistroIp("route:\nall:")).toBeNull(); + expect(parseDistroIp("")).toBeNull(); + }); +}); + describe("formatNodePtyProbeFailureReason", () => { it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { const reason = formatNodePtyProbeFailureReason(4); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 164117727eaa..bece8a3d1e79 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -686,19 +686,48 @@ const windowsToWslPathImpl = ( const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; +const DISTRO_IP_ROUTE_PREFIX = "route:"; +const DISTRO_IP_ADDRESSES_PREFIX = "all:"; + +// `ip -4 route get 1.1.1.1` is a pure routing-table lookup (no packet is +// sent): its `src` field is the address the distro would use to reach an +// external host, which is the eth0/mirrored interface Windows can reach. +// Docker bridge interfaces never own the default route, so they cannot be +// picked — unlike `hostname -I`, which lists them first and made the app +// poll an unreachable 172.x bridge address forever (#5211). `hostname -I` +// stays as the fallback for distros without a default route. +const DISTRO_IP_SCRIPT = `printf "${DISTRO_IP_ROUTE_PREFIX}%s\\n" "$(ip -4 route get 1.1.1.1 2>/dev/null)"; printf "${DISTRO_IP_ADDRESSES_PREFIX}%s\\n" "$(hostname -I 2>/dev/null)"`; + +export const parseDistroIp = (stdout: string): string | null => { + let fallback: string | null = null; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith(DISTRO_IP_ROUTE_PREFIX)) { + const tokens = trimmed.slice(DISTRO_IP_ROUTE_PREFIX.length).trim().split(/\s+/); + const srcIndex = tokens.indexOf("src"); + const candidate = srcIndex === -1 ? undefined : tokens[srcIndex + 1]; + if (candidate !== undefined && IPV4_PATTERN.test(candidate)) return candidate; + } + if (fallback === null && trimmed.startsWith(DISTRO_IP_ADDRESSES_PREFIX)) { + fallback = + trimmed + .slice(DISTRO_IP_ADDRESSES_PREFIX.length) + .split(/\s+/) + .find((part) => IPV4_PATTERN.test(part)) ?? null; + } + } + return fallback; +}; + const getDistroIpImpl = ( distro: string | null, ): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => Effect.scoped( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // `hostname -I` prints a space-separated list of all non-loopback - // IPs the distro has bound. The first entry on the WSL2 default - // network is always the eth0 vEthernet address Windows can reach - // directly (no wslhost forwarding required). const command = ChildProcess.make( "wsl.exe", - [...buildDistroArgs(distro), "--", "sh", "-c", "hostname -I"], + [...buildDistroArgs(distro), "--", "sh", "-c", DISTRO_IP_SCRIPT], { stdin: "ignore", stdout: "pipe", @@ -712,8 +741,11 @@ const getDistroIpImpl = ( const exitCode = yield* handle.exitCode; if ((exitCode as unknown as number) !== 0) return Option.none(); const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); - const candidate = raw.split(/\s+/).find((part) => IPV4_PATTERN.test(part)); - return candidate ? Option.some(candidate) : Option.none(); + const candidate = parseDistroIp(raw); + yield* Effect.log( + `[wsl] distro IP probe chose ${candidate ?? "none"} from: ${raw.replaceAll("\n", " ")}`, + ); + return candidate === null ? Option.none() : Option.some(candidate); }), ).pipe( Effect.timeoutOption(USER_HOME_TIMEOUT), From 3fb1ece0dfdab3859e96151736c8ac060e147bc1 Mon Sep 17 00:00:00 2001 From: Sanjay-doppalapudi Date: Thu, 27 Aug 2026 13:57:41 -0500 Subject: [PATCH 2/4] fix(desktop): validate distro IP candidates against Windows interfaces Trusting the Internet route's `src` regressed distros where a VPN, tunnel, or VRF owns the route to 1.1.1.1: its src is a tunnel address Windows cannot reach, even though eth0 is available. Collect candidates from both probes (route src first, then the `hostname -I` list) and pick the first one that is Windows-reachable: either equal to a Windows interface address (mirrored networking) or inside a Windows interface's subnet (the NAT-mode WSL vEthernet adapter). Docker bridges and in-distro tunnels match neither. When no candidate matches, fall back to the first one, preserving the previous behavior, and log the full candidate list with the chosen address. --- .../src/wsl/DesktopWslEnvironment.test.ts | 71 ++++++++---- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 104 ++++++++++++++---- 2 files changed, 132 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 92f4f6108823..7466308b6dd8 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -15,11 +15,12 @@ import { formatMissingToolsReason, formatNodePtyProbeFailureReason, formatWslShellTransportFailureReason, - parseDistroIp, + parseDistroIpCandidates, parseNodePath, parseNodeVersion, parseResolvedPath, parseToolchainReport, + pickDistroIp, probeWslDistros, } from "./DesktopWslEnvironment.ts"; @@ -89,41 +90,65 @@ describe("probeWslDistros", () => { }); }); -describe("parseDistroIp", () => { - it("prefers the default-route src address over hostname -I ordering", () => { - // Docker bridge networks sort before eth0 in `hostname -I`; the route - // lookup must win so an unreachable bridge IP is never picked (#5211). - const stdout = [ - "route:1.1.1.1 via 192.168.1.1 dev eth0 src 192.168.1.219 uid 1000", - "all:172.22.0.1 172.19.0.1 172.17.0.1 192.168.1.219", - ].join("\n"); - expect(parseDistroIp(stdout)).toBe("192.168.1.219"); - }); - - it("resolves the NAT-mode eth0 address from the route src field", () => { +describe("parseDistroIpCandidates", () => { + it("orders the route src ahead of the hostname -I list and dedupes", () => { const stdout = [ "route:1.1.1.1 via 172.27.0.1 dev eth0 src 172.27.5.44 uid 1000", - "all:172.27.5.44", + "all:172.17.0.1 172.27.5.44", ].join("\n"); - expect(parseDistroIp(stdout)).toBe("172.27.5.44"); + expect(parseDistroIpCandidates(stdout)).toEqual(["172.27.5.44", "172.17.0.1"]); }); - it("falls back to the first hostname -I address when there is no default route", () => { - expect(parseDistroIp("route:\nall:172.27.5.44 fe80::1")).toBe("172.27.5.44"); + it("collects only hostname -I addresses when there is no default route", () => { + expect(parseDistroIpCandidates("route:\nall:172.27.5.44 fe80::1")).toEqual(["172.27.5.44"]); }); - it("falls back when the route line has no valid IPv4 src token", () => { + it("skips a route line without a valid IPv4 src token", () => { const stdout = ["route:1.1.1.1 dev eth0 src fdcc::2 metric 256", "all:172.27.5.44"].join("\n"); - expect(parseDistroIp(stdout)).toBe("172.27.5.44"); + expect(parseDistroIpCandidates(stdout)).toEqual(["172.27.5.44"]); }); it("accepts CRLF output", () => { - expect(parseDistroIp("route:\r\nall:172.27.5.44\r\n")).toBe("172.27.5.44"); + expect(parseDistroIpCandidates("route:\r\nall:172.27.5.44\r\n")).toEqual(["172.27.5.44"]); + }); + + it("returns no candidates when neither probe produced an IPv4 address", () => { + expect(parseDistroIpCandidates("route:\nall:")).toEqual([]); + expect(parseDistroIpCandidates("")).toEqual([]); + }); +}); + +describe("pickDistroIp", () => { + const wslVEthernet = { address: "172.27.0.1", netmask: "255.255.240.0" }; + const wifi = { address: "192.168.1.219", netmask: "255.255.255.0" }; + + it("picks the eth0 address in the WSL vEthernet subnet over Docker bridges (#5211)", () => { + // Docker bridge networks sort first in `hostname -I` but are internal to + // the distro; only eth0 shares a subnet with a Windows interface. + expect(pickDistroIp(["172.17.0.1", "172.19.0.1", "172.27.5.44"], [wslVEthernet, wifi])).toBe( + "172.27.5.44", + ); + }); + + it("skips a VPN tunnel src that owns the Internet route inside the distro", () => { + // A full-tunnel VPN inside WSL makes `ip route get 1.1.1.1` report the + // tunnel address, which Windows cannot reach; eth0 must still win. + expect(pickDistroIp(["10.8.0.5", "172.17.0.1", "172.27.5.44"], [wslVEthernet, wifi])).toBe( + "172.27.5.44", + ); + }); + + it("picks the mirrored-mode address that equals a Windows interface IP", () => { + expect(pickDistroIp(["192.168.1.219"], [wifi])).toBe("192.168.1.219"); + }); + + it("falls back to the first candidate when nothing is provably reachable", () => { + expect(pickDistroIp(["10.8.0.5", "172.17.0.1"], [wifi])).toBe("10.8.0.5"); + expect(pickDistroIp(["172.27.5.44"], [])).toBe("172.27.5.44"); }); - it("returns null when neither probe produced an IPv4 address", () => { - expect(parseDistroIp("route:\nall:")).toBeNull(); - expect(parseDistroIp("")).toBeNull(); + it("returns null with no candidates", () => { + expect(pickDistroIp([], [wslVEthernet])).toBeNull(); }); }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index bece8a3d1e79..52442a6d6b64 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -689,34 +691,95 @@ const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; const DISTRO_IP_ROUTE_PREFIX = "route:"; const DISTRO_IP_ADDRESSES_PREFIX = "all:"; -// `ip -4 route get 1.1.1.1` is a pure routing-table lookup (no packet is -// sent): its `src` field is the address the distro would use to reach an -// external host, which is the eth0/mirrored interface Windows can reach. -// Docker bridge interfaces never own the default route, so they cannot be -// picked — unlike `hostname -I`, which lists them first and made the app -// poll an unreachable 172.x bridge address forever (#5211). `hostname -I` -// stays as the fallback for distros without a default route. +// Emits the distro's IPv4 candidates: the `src` of `ip -4 route get 1.1.1.1` +// (a pure routing-table lookup, no packet is sent) followed by everything +// `hostname -I` reports. Neither source alone is trustworthy — `hostname -I` +// lists Docker bridge addresses before eth0 (#5211), and the Internet route's +// src is a tunnel address when a VPN/VRF inside the distro owns that route — +// so pickDistroIp validates the candidates against the Windows-side +// interfaces instead of trusting either ordering. const DISTRO_IP_SCRIPT = `printf "${DISTRO_IP_ROUTE_PREFIX}%s\\n" "$(ip -4 route get 1.1.1.1 2>/dev/null)"; printf "${DISTRO_IP_ADDRESSES_PREFIX}%s\\n" "$(hostname -I 2>/dev/null)"`; -export const parseDistroIp = (stdout: string): string | null => { - let fallback: string | null = null; +export const parseDistroIpCandidates = (stdout: string): ReadonlyArray => { + const candidates: string[] = []; + const push = (ip: string) => { + if (!candidates.includes(ip)) candidates.push(ip); + }; for (const line of stdout.split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed.startsWith(DISTRO_IP_ROUTE_PREFIX)) { const tokens = trimmed.slice(DISTRO_IP_ROUTE_PREFIX.length).trim().split(/\s+/); const srcIndex = tokens.indexOf("src"); const candidate = srcIndex === -1 ? undefined : tokens[srcIndex + 1]; - if (candidate !== undefined && IPV4_PATTERN.test(candidate)) return candidate; + if (candidate !== undefined && IPV4_PATTERN.test(candidate)) push(candidate); + } else if (trimmed.startsWith(DISTRO_IP_ADDRESSES_PREFIX)) { + for (const part of trimmed.slice(DISTRO_IP_ADDRESSES_PREFIX.length).split(/\s+/)) { + if (IPV4_PATTERN.test(part)) push(part); + } + } + } + return candidates; +}; + +export interface WindowsIpv4Interface { + readonly address: string; + readonly netmask: string; +} + +const ipv4ToInt = (ip: string): number | null => { + const parts = ip.split("."); + if (parts.length !== 4) return null; + let value = 0; + for (const part of parts) { + const octet = Number(part); + if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null; + value = value * 256 + octet; + } + return value; +}; + +const inSameSubnet = (a: string, b: string, netmask: string): boolean => { + const aInt = ipv4ToInt(a); + const bInt = ipv4ToInt(b); + const maskInt = ipv4ToInt(netmask); + if (aInt === null || bInt === null || maskInt === null) return false; + return (aInt & maskInt) === (bInt & maskInt); +}; + +// A candidate is Windows-reachable when it IS a Windows interface address +// (mirrored networking, where DesktopBackendConfiguration then swaps to +// loopback) or when it sits inside a Windows interface's subnet (NAT mode, +// where the distro's eth0 shares the WSL vEthernet adapter's subnet). Docker +// bridges and in-distro VPN tunnels match neither. When nothing matches, +// fall back to the first candidate, preserving the pre-validation behavior. +export const pickDistroIp = ( + candidates: ReadonlyArray, + windowsInterfaces: ReadonlyArray, +): string | null => { + for (const candidate of candidates) { + for (const iface of windowsInterfaces) { + if (candidate === iface.address) return candidate; + if (inSameSubnet(candidate, iface.address, iface.netmask)) return candidate; } - if (fallback === null && trimmed.startsWith(DISTRO_IP_ADDRESSES_PREFIX)) { - fallback = - trimmed - .slice(DISTRO_IP_ADDRESSES_PREFIX.length) - .split(/\s+/) - .find((part) => IPV4_PATTERN.test(part)) ?? null; + } + return candidates[0] ?? null; +}; + +const windowsIpv4Interfaces = (): ReadonlyArray => { + const interfaces: WindowsIpv4Interface[] = []; + for (const list of Object.values(NodeOS.networkInterfaces())) { + if (!list) continue; + for (const entry of list) { + // Same family normalization as isLocalHostIpv4 in + // DesktopBackendConfiguration: Electron's Node reports the string + // "IPv4", some Node builds report the numeric 4. + const family = String(entry.family); + if (family === "IPv4" || family === "4") { + interfaces.push({ address: entry.address, netmask: entry.netmask }); + } } } - return fallback; + return interfaces; }; const getDistroIpImpl = ( @@ -741,11 +804,12 @@ const getDistroIpImpl = ( const exitCode = yield* handle.exitCode; if ((exitCode as unknown as number) !== 0) return Option.none(); const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); - const candidate = parseDistroIp(raw); + const candidates = parseDistroIpCandidates(raw); + const chosen = pickDistroIp(candidates, windowsIpv4Interfaces()); yield* Effect.log( - `[wsl] distro IP probe chose ${candidate ?? "none"} from: ${raw.replaceAll("\n", " ")}`, + `[wsl] distro IP probe chose ${chosen ?? "none"} from candidates [${candidates.join(", ")}]`, ); - return candidate === null ? Option.none() : Option.some(candidate); + return chosen === null ? Option.none() : Option.some(chosen); }), ).pipe( Effect.timeoutOption(USER_HOME_TIMEOUT), From 302442938aef58b31b648a7f47a77d95d33c38e2 Mon Sep 17 00:00:00 2001 From: Sanjay-doppalapudi Date: Thu, 27 Aug 2026 14:06:37 -0500 Subject: [PATCH 3/4] refactor(desktop): read Windows interfaces via DesktopNetworkInterfaces The distro-IP probe called node:os networkInterfaces() imperatively inside the service implementation, hiding the dependency from the layer's requirements and making it unsubstitutable in tests. Acquire the existing DesktopNetworkInterfaces service in the layer, pass its read effect into the probe, and keep the IPv4 flattening as a pure helper over the returned NetworkInterfaces map. --- .../DesktopBackendConfiguration.test.ts | 6 ++++ .../src/wsl/DesktopWslEnvironment.test.ts | 19 ++++++++++++ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 30 ++++++++++++------- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 2bbde73abaa2..024ce758a4e6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -14,6 +14,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -965,6 +966,11 @@ describe("DesktopBackendConfiguration", () => { Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layer), + Layer.provideMerge( + Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, { + read: Effect.succeed({}), + }), + ), // isAvailable on win32 only touches the filesystem, never the spawner, // so a die-stub is enough to satisfy the layer's deps. Layer.provideMerge( diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 7466308b6dd8..fa1bf027c760 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -22,6 +22,7 @@ import { parseToolchainReport, pickDistroIp, probeWslDistros, + windowsIpv4Interfaces, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); @@ -152,6 +153,24 @@ describe("pickDistroIp", () => { }); }); +describe("windowsIpv4Interfaces", () => { + it("flattens IPv4 entries across adapters, accepting string and numeric family", () => { + expect( + windowsIpv4Interfaces({ + "vEthernet (WSL)": [ + { address: "172.27.0.1", family: "IPv4", internal: false, netmask: "255.255.240.0" }, + { address: "fe80::1", family: "IPv6", internal: false, netmask: "ffff:ffff:ffff:ffff::" }, + ], + "Wi-Fi": [{ address: "192.168.1.219", family: 4, internal: false }], + Disconnected: undefined, + }), + ).toEqual([ + { address: "172.27.0.1", netmask: "255.255.240.0" }, + { address: "192.168.1.219", netmask: undefined }, + ]); + }); +}); + describe("formatNodePtyProbeFailureReason", () => { it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { const reason = formatNodePtyProbeFailureReason(4); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 52442a6d6b64..ce3497678504 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -15,6 +13,7 @@ import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopNetworkInterfaces from "../backend/DesktopNetworkInterfaces.ts"; import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; const PROCESS_TERMINATE_GRACE = Duration.seconds(1); @@ -723,7 +722,7 @@ export const parseDistroIpCandidates = (stdout: string): ReadonlyArray = export interface WindowsIpv4Interface { readonly address: string; - readonly netmask: string; + readonly netmask: string | undefined; } const ipv4ToInt = (ip: string): number | null => { @@ -759,15 +758,19 @@ export const pickDistroIp = ( for (const candidate of candidates) { for (const iface of windowsInterfaces) { if (candidate === iface.address) return candidate; - if (inSameSubnet(candidate, iface.address, iface.netmask)) return candidate; + if (iface.netmask !== undefined && inSameSubnet(candidate, iface.address, iface.netmask)) { + return candidate; + } } } return candidates[0] ?? null; }; -const windowsIpv4Interfaces = (): ReadonlyArray => { - const interfaces: WindowsIpv4Interface[] = []; - for (const list of Object.values(NodeOS.networkInterfaces())) { +export const windowsIpv4Interfaces = ( + interfaces: DesktopNetworkInterfaces.NetworkInterfaces, +): ReadonlyArray => { + const flattened: WindowsIpv4Interface[] = []; + for (const list of Object.values(interfaces)) { if (!list) continue; for (const entry of list) { // Same family normalization as isLocalHostIpv4 in @@ -775,15 +778,16 @@ const windowsIpv4Interfaces = (): ReadonlyArray => { // "IPv4", some Node builds report the numeric 4. const family = String(entry.family); if (family === "IPv4" || family === "4") { - interfaces.push({ address: entry.address, netmask: entry.netmask }); + flattened.push({ address: entry.address, netmask: entry.netmask }); } } } - return interfaces; + return flattened; }; const getDistroIpImpl = ( distro: string | null, + readNetworkInterfaces: Effect.Effect, ): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => Effect.scoped( Effect.gen(function* () { @@ -805,7 +809,8 @@ const getDistroIpImpl = ( if ((exitCode as unknown as number) !== 0) return Option.none(); const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); const candidates = parseDistroIpCandidates(raw); - const chosen = pickDistroIp(candidates, windowsIpv4Interfaces()); + const interfaces = yield* readNetworkInterfaces; + const chosen = pickDistroIp(candidates, windowsIpv4Interfaces(interfaces)); yield* Effect.log( `[wsl] distro IP probe chose ${chosen ?? "none"} from candidates [${candidates.join(", ")}]`, ); @@ -914,6 +919,7 @@ export const layer = Layer.effect( const environment = yield* DesktopEnvironment.DesktopEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; + const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; const windir = process.env.WINDIR ?? "C:\\Windows"; const provideSpawner = ( @@ -960,7 +966,9 @@ export const layer = Layer.effect( }).pipe(Effect.withSpan("desktop.wsl.getUserHome")); const getDistroIp = (distro: string | null) => - provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp")); + provideSpawner(getDistroIpImpl(distro, networkInterfaces.read)).pipe( + Effect.withSpan("desktop.wsl.getDistroIp"), + ); const probeDistros = provideSpawner(probeWslDistros).pipe( Effect.withSpan("desktop.wsl.probeDistros"), From 646b8cb408d47947fc40550c099191e349025432 Mon Sep 17 00:00:00 2001 From: Sanjay-doppalapudi Date: Thu, 27 Aug 2026 14:42:26 -0500 Subject: [PATCH 4/4] fix(desktop): rank distro IP matches so overlapping Windows nets can't win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subnet test accepted a candidate against any Windows adapter, so a Windows-side VPN whose 10.x/172.x space overlaps an in-distro tunnel or Docker bridge could validate the wrong candidate — and the route src being first in candidate order made it win, recreating the hang this change prevents. Select in ranked passes instead: an exact interface-address match (mirrored mode) first, then a subnet match on a WSL-named vEthernet adapter (NAT mode), and only then a subnet match on any other adapter (renamed/custom switches). The flattening now carries the adapter name to make that possible. --- .../src/wsl/DesktopWslEnvironment.test.ts | 31 +++++++++++-- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 44 +++++++++++++------ 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index fa1bf027c760..e5203ae6aa98 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -120,8 +120,12 @@ describe("parseDistroIpCandidates", () => { }); describe("pickDistroIp", () => { - const wslVEthernet = { address: "172.27.0.1", netmask: "255.255.240.0" }; - const wifi = { address: "192.168.1.219", netmask: "255.255.255.0" }; + const wslVEthernet = { + name: "vEthernet (WSL (Hyper-V firewall))", + address: "172.27.0.1", + netmask: "255.255.240.0", + }; + const wifi = { name: "Wi-Fi", address: "192.168.1.219", netmask: "255.255.255.0" }; it("picks the eth0 address in the WSL vEthernet subnet over Docker bridges (#5211)", () => { // Docker bridge networks sort first in `hostname -I` but are internal to @@ -143,6 +147,25 @@ describe("pickDistroIp", () => { expect(pickDistroIp(["192.168.1.219"], [wifi])).toBe("192.168.1.219"); }); + it("outranks a Windows VPN whose address space overlaps an in-distro tunnel", () => { + // A corporate VPN adapter on Windows can share 10.x/172.x space with an + // in-distro tunnel or Docker bridge; the WSL adapter match must win even + // though the tunnel src is the first candidate. + const corporateVpn = { name: "Ethernet 3", address: "10.8.44.7", netmask: "255.255.0.0" }; + expect( + pickDistroIp(["10.8.0.5", "172.17.0.1", "172.27.5.44"], [corporateVpn, wslVEthernet]), + ).toBe("172.27.5.44"); + }); + + it("still accepts a subnet match on a custom-named switch when no WSL adapter matches", () => { + const customSwitch = { + name: "vEthernet (Custom)", + address: "192.168.100.1", + netmask: "255.255.255.0", + }; + expect(pickDistroIp(["192.168.100.44"], [customSwitch])).toBe("192.168.100.44"); + }); + it("falls back to the first candidate when nothing is provably reachable", () => { expect(pickDistroIp(["10.8.0.5", "172.17.0.1"], [wifi])).toBe("10.8.0.5"); expect(pickDistroIp(["172.27.5.44"], [])).toBe("172.27.5.44"); @@ -165,8 +188,8 @@ describe("windowsIpv4Interfaces", () => { Disconnected: undefined, }), ).toEqual([ - { address: "172.27.0.1", netmask: "255.255.240.0" }, - { address: "192.168.1.219", netmask: undefined }, + { name: "vEthernet (WSL)", address: "172.27.0.1", netmask: "255.255.240.0" }, + { name: "Wi-Fi", address: "192.168.1.219", netmask: undefined }, ]); }); }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index ce3497678504..14dc33e63966 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -721,6 +721,7 @@ export const parseDistroIpCandidates = (stdout: string): ReadonlyArray = }; export interface WindowsIpv4Interface { + readonly name: string; readonly address: string; readonly netmask: string | undefined; } @@ -745,22 +746,37 @@ const inSameSubnet = (a: string, b: string, netmask: string): boolean => { return (aInt & maskInt) === (bInt & maskInt); }; -// A candidate is Windows-reachable when it IS a Windows interface address -// (mirrored networking, where DesktopBackendConfiguration then swaps to -// loopback) or when it sits inside a Windows interface's subnet (NAT mode, -// where the distro's eth0 shares the WSL vEthernet adapter's subnet). Docker -// bridges and in-distro VPN tunnels match neither. When nothing matches, -// fall back to the first candidate, preserving the pre-validation behavior. +const inInterfaceSubnet = (candidate: string, iface: WindowsIpv4Interface): boolean => + iface.netmask !== undefined && inSameSubnet(candidate, iface.address, iface.netmask); + +const isWslAdapterName = (name: string): boolean => name.toLowerCase().includes("wsl"); + +// Ranked selection, strongest signal first, so a weak match on an early +// candidate can never shadow a strong match on a later one: +// 1. A candidate equal to a Windows interface address is the mirrored-mode +// signature (DesktopBackendConfiguration then swaps the renderer URL to +// loopback). +// 2. A candidate inside the subnet of a WSL-named adapter ("vEthernet (WSL)", +// "vEthernet (WSL (Hyper-V firewall))") is the NAT-mode eth0 address. +// 3. A candidate inside any other Windows interface's subnet covers renamed +// or custom Hyper-V switches — ranked last so a Windows-side VPN whose +// 10.x/172.x space overlaps an in-distro tunnel or Docker bridge cannot +// capture the probe while the real WSL adapter has a match. +// Docker bridges and in-distro VPN tunnels normally match no pass. When +// nothing matches, fall back to the first candidate, preserving the +// pre-validation behavior. export const pickDistroIp = ( candidates: ReadonlyArray, windowsInterfaces: ReadonlyArray, ): string | null => { - for (const candidate of candidates) { - for (const iface of windowsInterfaces) { - if (candidate === iface.address) return candidate; - if (iface.netmask !== undefined && inSameSubnet(candidate, iface.address, iface.netmask)) { - return candidate; - } + const passes: ReadonlyArray<(candidate: string, iface: WindowsIpv4Interface) => boolean> = [ + (candidate, iface) => candidate === iface.address, + (candidate, iface) => isWslAdapterName(iface.name) && inInterfaceSubnet(candidate, iface), + (candidate, iface) => inInterfaceSubnet(candidate, iface), + ]; + for (const pass of passes) { + for (const candidate of candidates) { + if (windowsInterfaces.some((iface) => pass(candidate, iface))) return candidate; } } return candidates[0] ?? null; @@ -770,7 +786,7 @@ export const windowsIpv4Interfaces = ( interfaces: DesktopNetworkInterfaces.NetworkInterfaces, ): ReadonlyArray => { const flattened: WindowsIpv4Interface[] = []; - for (const list of Object.values(interfaces)) { + for (const [name, list] of Object.entries(interfaces)) { if (!list) continue; for (const entry of list) { // Same family normalization as isLocalHostIpv4 in @@ -778,7 +794,7 @@ export const windowsIpv4Interfaces = ( // "IPv4", some Node builds report the numeric 4. const family = String(entry.family); if (family === "IPv4" || family === "4") { - flattened.push({ address: entry.address, netmask: entry.netmask }); + flattened.push({ name, address: entry.address, netmask: entry.netmask }); } } }