diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts new file mode 100644 index 000000000000..41328483e7d1 --- /dev/null +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -0,0 +1,57 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as NetService from "@t3tools/shared/Net"; + +import { resolveDesktopBackendPort } from "./DesktopApp.ts"; + +// Every host probe answers "free", which is what the Electron main process +// really sees on Windows while a WSL-side listener holds the port. +const makeNetLayer = (probedPorts: number[]) => + Layer.succeed(NetService.NetService, { + canListenOnHost: (port) => + Effect.sync(() => { + probedPorts.push(port); + return true; + }), + isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), + reserveLoopbackPort: () => Effect.succeed(41_773), + findAvailablePort: (preferred) => Effect.succeed(preferred), + } satisfies NetService.NetService["Service"]); + +describe("resolveDesktopBackendPort", () => { + it.effect("takes the default port when nothing is known about a distro", () => + Effect.gen(function* () { + const selection = yield* resolveDesktopBackendPort(Option.none(), new Set()); + + assert.equal(selection.port, 3773); + assert.isTrue(selection.selectedByScan); + }).pipe(Effect.provide(makeNetLayer([]))), + ); + + it.effect("skips a port the WSL distro already listens on", () => { + const probedPorts: number[] = []; + + return Effect.gen(function* () { + const selection = yield* resolveDesktopBackendPort(Option.none(), new Set([3773])); + + assert.equal(selection.port, 3774); + assert.isTrue(selection.selectedByScan); + // 3773 is rejected on the distro's evidence alone; the Windows-side bind + // check would have called it free and handed it to the WSL backend. + assert.notInclude(probedPorts, 3773); + }).pipe(Effect.provide(makeNetLayer(probedPorts))); + }); + + it.effect("leaves an explicitly configured port alone", () => + Effect.gen(function* () { + const selection = yield* resolveDesktopBackendPort(Option.some(9999), new Set([9999])); + + assert.equal(selection.port, 9999); + assert.isFalse(selection.selectedByScan); + }).pipe(Effect.provide(makeNetLayer([]))), + ); +}); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f6..484e99c42041 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -28,6 +28,7 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -66,8 +67,14 @@ const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = const { logInfo: logStartupInfo, logError: logStartupError } = DesktopObservability.makeComponentLogger("desktop-startup"); -const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( +// `portsHeldInDistro` is non-empty only when the primary backend will bind +// inside a WSL distro. The host probes below run in the Electron main process, +// which is the Windows side, and WSL2's localhost forwarding does not reserve a +// WSL-side listener's port in the Windows port namespace — so a port that binds +// fine here can still be taken in the distro the backend actually runs in. +export const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( configuredPort: Option.Option, + portsHeldInDistro: ReadonlySet, ) { if (Option.isSome(configuredPort)) { return { @@ -78,6 +85,7 @@ const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(functio const net = yield* NetService.NetService; for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { + if (portsHeldInDistro.has(port)) continue; let availableOnEveryHost = true; for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { @@ -147,6 +155,7 @@ const bootstrap = Effect.gen(function* () { const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* logBootstrapInfo("bootstrap start"); @@ -154,7 +163,46 @@ const bootstrap = Effect.gen(function* () { return yield* new DesktopDevelopmentBackendPortRequiredError(); } - const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); + const settings = yield* desktopSettings.get; + // wsl-only mode hands this port to a backend that binds inside the distro, so + // the port has to be free on both sides. Ask the distro what it already holds + // before scanning; every consumer of the port (the renderer origin registered + // below, the advertised endpoints, the backend's own bind) derives from the + // single value chosen here, so this is the only place it can be corrected. + // Both steps are skipped once something has already asked the app to quit: + // the splash would paint a window on the way out, and the probe's wsl.exe + // cold start is seconds of work for a port nobody will bind. + const startingWslPrimary = + settings.wslOnly === true && + settings.wslBackendEnabled === true && + !(yield* Ref.get(state.quitting)); + if (startingWslPrimary) { + // In wsl-only mode the renderer is served by the WSL backend, which can be + // slow to cold-boot — show a "Connecting to WSL" splash instead of + // presenting no window until WSL is ready. (Dual mode opens fast off the + // Windows primary, so no splash there.) It goes up before the port probe + // below because that probe is what cold-starts the VM. + yield* desktopWindow.showConnectingSplash; + } + const distroPorts = + startingWslPrimary && (yield* wslEnvironment.isAvailable) + ? yield* wslEnvironment.probeListeningPorts(settings.wslDistro) + : Option.none>(); + if (startingWslPrimary && Option.isNone(distroPorts)) { + // Worth a warning rather than a quiet field on the info line below: the + // fallback is the Windows-only scan, which for a WSL primary is exactly the + // unsound check this probe replaces. If the backend then dies on + // EADDRINUSE, this is the line that explains why we did not know better. + yield* logBootstrapWarning( + "could not read the WSL distro's listening ports; falling back to a Windows-only port scan", + { distro: settings.wslDistro }, + ); + } + + const backendPortSelection = yield* resolveDesktopBackendPort( + environment.configuredBackendPort, + Option.getOrElse(distroPorts, () => new Set()), + ); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( backendPortSelection.selectedByScan @@ -163,10 +211,11 @@ const bootstrap = Effect.gen(function* () { { port: backendPort, ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), + // A failed probe is not a guarantee that the distro is clear; the child + // can still lose the race and hit EADDRINUSE at launch. + ...(startingWslPrimary ? { wslDistroPortsProbed: Option.isSome(distroPorts) } : {}), }, ); - - const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { mode: settings.serverExposureMode, @@ -201,13 +250,6 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - // In wsl-only mode the renderer is served by the WSL backend, which can be - // slow to cold-boot — show a "Connecting to WSL" splash immediately so the - // app feels responsive instead of presenting no window until WSL is ready. - // (Dual mode opens fast off the Windows primary, so no splash there.) - if (settings.wslOnly === true && settings.wslBackendEnabled === true) { - yield* desktopWindow.showConnectingSplash; - } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); // Bring up the WSL backend if the user previously enabled it. The diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index ed8911d40075..7f6392ddb357 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -155,6 +155,79 @@ describe("DesktopWslBackend", () => { ); }); + it.effect("allocates a secondary port the WSL distro is not already listening on", () => { + const probedDistros: (string | null)[] = []; + let resolveWslInput: { readonly port: number; readonly distro: string | null } | undefined; + const primary = makeStubInstance({ + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", + snapshot: primarySnapshot, + }); + const wsl = makeStubInstance({ + id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), + label: "WSL (Ubuntu)", + snapshot: primarySnapshot, + }); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + get: () => Effect.succeed(Option.none()), + list: Effect.succeed([primary]), + primary: Effect.succeed(primary), + register: () => Effect.succeed(wsl), + unregister: () => Effect.die("unexpected unregister"), + } satisfies DesktopBackendPool.DesktopBackendPool["Service"]); + const recordingConfigurationLayer = Layer.succeed( + DesktopBackendConfiguration.DesktopBackendConfiguration, + { + resolvePrimary: Effect.die("unexpected resolvePrimary"), + resolvePrimaryLabel: Effect.succeed("Windows"), + // startNew calls this while building the pool spec, so recording here + // captures the allocated port without running the returned effect. + resolveWsl: (input) => { + resolveWslInput = input; + return Effect.die("unexpected resolveWsl"); + }, + } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], + ); + + return Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + + yield* backend.reconcile; + + // The scan starts one above the primary's 3773; 3774 and 3775 look free + // from Windows but are already held inside the distro that will bind. + assert.deepEqual(resolveWslInput, { port: 3776, distro: "Ubuntu" }); + // One wsl.exe round trip for the whole scan, not one per candidate. + assert.deepEqual(probedDistros, ["Ubuntu"]); + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provideMerge(poolLayer), + Layer.provideMerge(recordingConfigurationLayer), + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(netLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: false, + }), + ), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + probeListeningPorts: (distro) => { + probedDistros.push(distro); + return Option.some(new Set([3774, 3775])); + }, + }), + ), + ), + ), + ); + }); + it.effect("retries an unchanged WSL instance when it is idle after failed preflight", () => { let startCount = 0; const primary = makeStubInstance({ diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 605f4e7a477f..2f8ac63cb19f 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -18,7 +18,8 @@ // avoid colliding with the primary or with a previously-registered WSL // instance that's still tearing down. The scan only checks loopback // (127.0.0.1) since the WSL backend is loopback-only — the primary -// owns LAN exposure when the user opts in. +// owns LAN exposure when the user opts in — and skips ports the distro +// itself already listens on, which the Windows-side check cannot see. import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -77,11 +78,15 @@ const buildLabel = (distro: string | null): string => // Loopback-only port scan starting one above the primary's port. The // WSL backend is reachable via 127.0.0.1 from Windows (wslhost // auto-forwards), so we only need to verify the IPv4 loopback can bind. +// `portsHeldInDistro` covers the other half: the backend binds inside the +// distro, whose listeners are invisible to a Windows-side bind check. const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( startPort: number, + portsHeldInDistro: ReadonlySet, ): Effect.fn.Return { const net = yield* NetService.NetService; for (let port = startPort; port <= MAX_TCP_PORT; port += 1) { + if (portsHeldInDistro.has(port)) continue; if (yield* net.canListenOnHost(port, "127.0.0.1")) { return port; } @@ -136,7 +141,14 @@ export const layer = Layer.effect( readonly distro: string | null; }) { const primaryConfig = yield* serverExposure.backendConfig; - const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( + // reconcile pre-warms the distro before calling us, so this is one extra + // round trip against an already-running VM. A failed probe (None) leaves + // the Windows-only scan in place rather than blocking the backend. + const distroPorts = yield* wslEnvironment.probeListeningPorts(input.distro); + const port = yield* scanForWslPort( + primaryConfig.port + 1, + Option.getOrElse(distroPorts, () => new Set()), + ).pipe( Effect.provideService(NetService.NetService, net), Effect.map((value) => Option.some(value)), Effect.catch((error) => diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 895d246e3689..d103c8bf0e7c 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -19,7 +20,9 @@ import { parseNodeVersion, parseResolvedPath, parseToolchainReport, + parseWslListeningPorts, probeWslDistros, + probeWslListeningPorts, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); @@ -259,3 +262,57 @@ describe("formatMissingToolsReason", () => { expect(reason).not.toContain("nvm"); }); }); + +describe("parseWslListeningPorts", () => { + const procNetTcp = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: FEFFFF0A:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 8206 1 0000000000000000 100 0 0 10 0 + 1: 00000000:0EBD 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 75100 1 0000000000000000 100 0 0 10 0 + 2: 0100007F:0EBD 0100007F:E03C 01 00000000:00000000 00:00000000 00000000 1000 0 57112 1 0000000000000000 20 4 31 10 -1 +`; + + const procNetTcp6 = ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000000000000:6066 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 201935 1 0000000000000000 100 0 0 10 0 +`; + + it("collects listening ports from both address families and ignores live connections", () => { + const ports = parseWslListeningPorts(`${procNetTcp}${procNetTcp6}`); + + expect(Option.isSome(ports)).toBe(true); + // 0EBD is the 3773 the background `t3` service holds; E03C is a client + // socket on the same port and must not be mistaken for a listener. + expect([...Option.getOrThrow(ports)].sort((a, b) => a - b)).toEqual([53, 3773, 24_678]); + }); + + it("reads an empty table as nothing listening", () => { + const header = procNetTcp.split("\n")[0] ?? ""; + + expect(parseWslListeningPorts(header)).toEqual(Option.some(new Set())); + }); + + it("reports unknown rather than empty when no table was read", () => { + expect(parseWslListeningPorts("")).toEqual(Option.none()); + expect(parseWslListeningPorts("wsl.exe: no such distribution\n")).toEqual(Option.none()); + }); +}); + +describe("probeWslListeningPorts", () => { + it.effect("gives a cold WSL VM the same ten seconds pre-warm allows", () => { + const layer = Layer.merge( + TestClock.layer(), + // Never exits, standing in for a distro still booting. + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})), + ); + return Effect.gen(function* () { + const fiber = yield* probeWslListeningPorts("Ubuntu").pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* TestClock.adjust(Duration.seconds(9)); + expect(fiber.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust(Duration.seconds(1)); + // Degrades to "unknown", never to "nothing is listening" -- an empty set + // would hand the scan a port the distro may already hold. + expect(yield* Fiber.join(fiber)).toEqual(Option.none()); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 164117727eaa..cafacac6294d 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -23,6 +23,13 @@ const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); const BUILD_TIMEOUT = Duration.minutes(5); const USER_HOME_TIMEOUT = Duration.seconds(5); +// Matches PRE_WARM_TIMEOUT rather than the 5s its neighbours use: in wsl-only +// mode this is the first wsl.exe call of the session, so it pays the VM's cold +// start, while getDistroIp and getUserHome always run after the VM is up. A +// warm round trip is ~100ms, so the extra headroom only ever applies to a distro +// that is genuinely slow to boot -- and timing out here silently drops back to +// the Windows-only scan this probe exists to correct. +const LISTENING_PORTS_TIMEOUT = PRE_WARM_TIMEOUT; const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; const BUILD_TRANSPORT_RETRY_LIMIT = 2; @@ -79,6 +86,14 @@ export class DesktopWslEnvironment extends Context.Service< // (the backend can be listening for 30+ seconds before wslhost starts // forwarding 127.0.0.1:port to WSL-side localhost). readonly getDistroIp: (distro: string | null) => Effect.Effect>; + // TCP ports that already have a listener inside the distro, or None when the + // probe could not run. A Windows-side bind check says nothing about these: + // WSL2's localhost forwarding does not reserve a WSL-side listener's port in + // the Windows port namespace, so a port can look free on Windows and still + // be taken in the distro the backend actually binds in. + readonly probeListeningPorts: ( + distro: string | null, + ) => Effect.Effect>>; readonly ensureNodePty: ( distro: string | null, windowsRepoRoot: string, @@ -721,6 +736,74 @@ const getDistroIpImpl = ( Effect.orElseSucceed(() => Option.none()), ); +// /proc/net/tcp{,6} rows are " : : ...", +// where st 0A is TCP_LISTEN. Reading the files beats `ss`/`netstat`, which +// minimal distro images often omit. Any listener blocks the WSL backend's +// 0.0.0.0 bind regardless of the address it holds, so the address is ignored. +const PROC_NET_TCP_LISTEN_STATE = "0A"; + +export const parseWslListeningPorts = (stdout: string): Option.Option> => { + const ports = new Set(); + let sawTable = false; + + for (const line of stdout.split("\n")) { + const fields = line.trim().split(/\s+/); + const localAddress = fields[1]; + if (localAddress === undefined) continue; + if (localAddress === "local_address") { + sawTable = true; + continue; + } + if (fields[3] !== PROC_NET_TCP_LISTEN_STATE) continue; + const separator = localAddress.lastIndexOf(":"); + if (separator === -1) continue; + const port = Number.parseInt(localAddress.slice(separator + 1), 16); + if (Number.isInteger(port) && port > 0) ports.add(port); + } + + // Without a header row we never read a real table (missing file, wsl.exe + // noise, truncated output). Report "unknown" rather than "nothing is + // listening", so callers keep their previous behavior instead of trusting an + // empty set. + return sawTable ? Option.some>(ports) : Option.none(); +}; + +export const probeWslListeningPorts = ( + distro: string | null, +): Effect.Effect< + Option.Option>, + never, + ChildProcessSpawner.ChildProcessSpawner +> => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make( + "wsl.exe", + [...buildDistroArgs(distro), "--", "sh", "-c", "cat /proc/net/tcp /proc/net/tcp6"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }, + ); + const handle = yield* spawner.spawn(command); + const stdoutBytes = yield* Stream.runCollect(handle.stdout); + // Deliberately not gated on the exit code: `cat` reports failure when a + // kernel without IPv6 has no /proc/net/tcp6, and the IPv4 table it did + // print is still worth reading. parseWslListeningPorts decides whether the + // output is a real table. + yield* handle.exitCode; + return parseWslListeningPorts(decodeUtf8(concatChunks(stdoutBytes))); + }), + ).pipe( + Effect.timeoutOption(LISTENING_PORTS_TIMEOUT), + Effect.map(Option.flatten), + Effect.orElseSucceed(() => Option.none>()), + ); + const getUserHomeImpl = ( distro: string | null, ): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => @@ -778,6 +861,7 @@ export interface DesktopWslEnvironmentTestStub { readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option; readonly getUserHome?: (distro: string | null) => Option.Option; readonly getDistroIp?: (distro: string | null) => Option.Option; + readonly probeListeningPorts?: (distro: string | null) => Option.Option>; readonly ensureNodePty?: ( distro: string | null, windowsRepoRoot: string, @@ -800,6 +884,8 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()), getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()), + probeListeningPorts: (distro) => + Effect.succeed(stub.probeListeningPorts?.(distro) ?? Option.none>()), ensureNodePty: (distro, windowsRepoRoot, options) => Effect.succeed( stub.ensureNodePty?.(distro, windowsRepoRoot, options) ?? { @@ -866,6 +952,11 @@ export const layer = Layer.effect( const getDistroIp = (distro: string | null) => provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp")); + const probeListeningPorts = (distro: string | null) => + provideSpawner(probeWslListeningPorts(distro)).pipe( + Effect.withSpan("desktop.wsl.probeListeningPorts"), + ); + const probeDistros = provideSpawner(probeWslDistros).pipe( Effect.withSpan("desktop.wsl.probeDistros"), ); @@ -882,6 +973,7 @@ export const layer = Layer.effect( windowsToWslPath, getUserHome, getDistroIp, + probeListeningPorts, ensureNodePty: (distro, windowsRepoRoot, options) => provideSpawner(ensureNodePtyImpl(distro, windowsRepoRoot, windowsToWslPath, options)).pipe( Effect.withSpan("desktop.wsl.ensureNodePty"),