diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 07644a8af47c..d2bafe7ca65b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1096,10 +1096,8 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.bootstrap.tailscaleServeEnabled, false); assert.notProperty(config.bootstrap, "desktopTelemetryFd"); assert.notProperty(config.bootstrap, "resourceMonitorPath"); - // httpBaseUrl uses the resolved distro IP from the test stub, - // not localhost — the renderer reaches the backend directly to - // avoid relying on wslhost forwarding. - assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); + assert.equal(config.httpBaseUrl.href, "http://127.0.0.1:5050/"); + assert.equal(config.fallbackHttpBaseUrl?.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index b3bec8830ec6..f6989bdf42fa 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -499,7 +499,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f // True when the given IPv4 belongs to a Windows-side network // interface. In WSL2 mirrored mode the distro's eth0 IP equals the // host's, which is the signature we use to detect that mode and -// switch the renderer URL to loopback. +// skip the distro-IP fallback URL. const isLocalHostIpv4 = (ip: string): boolean => { const interfaces = NodeOS.networkInterfaces(); for (const list of Object.values(interfaces)) { @@ -508,8 +508,8 @@ const isLocalHostIpv4 = (ip: string): boolean => { // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on // the Node build Electron ships (41 / Node 22, verified), but some Node // builds report the numeric 4. Normalize to a string so a future runtime - // bump can't silently break mirrored-mode detection and leave the - // renderer pointed at the distro IP instead of loopback. + // bump can't silently break mirrored-mode detection and offer a + // host-interface address as the WSL fallback URL. const family = String(entry.family); if ((family === "IPv4" || family === "4") && entry.address === ip) return true; } @@ -616,8 +616,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // Windows. wslhost forwarding is unreliable on some Windows hosts: // the desktop's readiness probe and the renderer's saved-env-style // fetch both saw "Failed to fetch" when the backend only bound to - // 127.0.0.1 inside WSL. Binding to 0.0.0.0 plus advertising the - // WSL IP as the renderer-visible URL avoids that dependency. + // 127.0.0.1 inside WSL. Binding to 0.0.0.0 keeps the WSL IP available + // as the readiness fallback when forwarding fails. // Security-wise this is acceptable for the local-only WSL backend: // the network it exposes on is the WSL-vEthernet network, not the // LAN; the primary owns LAN exposure when the user opts in. @@ -700,18 +700,17 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; const distroForConfig = runningDistro ?? input.distro; - // Resolve the selected distro's IPv4 address. In mirrored mode the distro - // reports a host interface, so use loopback instead; a failed probe also - // falls back to loopback and preserves the previous behavior. + // Dial loopback first: endpoint security on some hosts drops HTTP to the + // NAT subnet. The NAT distro IP is the fallback when loopback does not + // answer; in mirrored mode the distro reports a host interface, so + // loopback is the only route. const distroIp = yield* wslEnvironment.getDistroIp(distroForConfig); - const usesSharedNetworkStack = Option.match(distroIp, { - onNone: () => false, - onSome: (ip) => isLocalHostIpv4(ip), - }); - const rendererHost = usesSharedNetworkStack - ? "127.0.0.1" - : Option.getOrElse(distroIp, () => "127.0.0.1"); - const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`); + const httpBaseUrl = new URL(`http://127.0.0.1:${input.port}`); + const fallbackHttpBaseUrl = distroIp.pipe( + Option.filter((ip) => !isLocalHostIpv4(ip)), + Option.map((ip) => new URL(`http://${ip}:${input.port}`)), + Option.getOrUndefined, + ); const distroArgs = distroForConfig ? ["-d", distroForConfig] : []; const forwardedEnv: Record = {}; @@ -753,6 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl bootstrap, bootstrapDelivery: "stdin" as const, httpBaseUrl, + ...(fallbackHttpBaseUrl !== undefined ? { fallbackHttpBaseUrl } : {}), captureOutput: true, ...(runningDistro !== null ? { runningDistro } : {}), }; diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 901d9f4a2708..d7939ef0feb6 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -807,6 +807,58 @@ describe("DesktopBackendManager", () => { ), ); + describe("fallback readiness URL", () => { + // Starts an instance with a loopback primary and a distro-IP fallback and + // returns the URL currentConfig settles on, which the renderer reads. + const readyUrl = (loopbackAnswers: boolean) => + Effect.gen(function* () { + const ready = yield* Deferred.make(); + const fallbackProbed = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + config: { + ...baseConfig, + fallbackHttpBaseUrl: new URL("http://172.27.0.99:3773"), + }, + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(makeProcess({ exitCode: Effect.never }))), + ), + httpClientLayer: httpClientLayer((request) => { + const loopback = request.url.startsWith("http://127.0.0.1:"); + return (loopback ? Effect.void : Deferred.succeed(fallbackProbed, void 0)).pipe( + Effect.as(responseForRequest(request, loopback && !loopbackAnswers ? 503 : 200)), + ); + }), + onReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), + }); + + yield* instance.start; + if (!loopbackAnswers) { + // Let the loopback re-check time out before the fallback is accepted. + yield* Deferred.await(fallbackProbed); + yield* TestClock.adjust(Duration.seconds(2)); + } + yield* Deferred.await(ready); + return Option.getOrThrow(yield* instance.currentConfig).httpBaseUrl.href; + }).pipe(Effect.provide(TestClock.layer())); + + it.effect("uses the distro IP when only it answers", () => + Effect.scoped( + Effect.gen(function* () { + assert.equal(yield* readyUrl(false), "http://172.27.0.99:3773/"); + }), + ), + ); + + it.effect("keeps loopback when both answer", () => + Effect.scoped( + Effect.gen(function* () { + assert.equal(yield* readyUrl(true), "http://127.0.0.1:3773/"); + }), + ), + ); + }); + it.effect("starts the configured backend and closes the scoped process on stop", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 6f1ea139bdca..b508da61eaea 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -95,6 +95,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { readonly bootstrap: DesktopBackendBootstrapValue; readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; readonly httpBaseUrl: URL; + // WSL NAT runs set this to the distro IP for hosts where wslhost loopback + // forwarding is unreliable. Readiness uses it only while httpBaseUrl stays + // unreachable; the manager then rewrites httpBaseUrl in currentConfig. + readonly fallbackHttpBaseUrl?: URL; readonly captureOutput: boolean; readonly preflightFailure: Option.Option; // Present for a WSL run after the configured/default distro has been @@ -229,7 +233,9 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly outputDrainTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onExitObserved?: () => Effect.Effect; - readonly onReady?: () => Effect.Effect; + // Receives the base URL that answered readiness: httpBaseUrl, or + // fallbackHttpBaseUrl when only the fallback is reachable. + readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, @@ -288,10 +294,10 @@ export interface BackendInstanceSpec { // bootstrap-token closure inside DesktopBackendConfiguration uses // crypto.randomBytes (Effect 4 beta.73 migration). readonly configResolve: Effect.Effect; - // Receives the *resolved* httpBaseUrl of the run that just became - // ready. The window service uses this to decide what URL to load - // (the WSL backend reports its distro IP, the Windows backend reports - // 127.0.0.1). Splitting this off from configResolve avoids races + // Receives the httpBaseUrl that answered readiness for the run that + // just became ready. The window service uses this to decide what URL + // to load (loopback, or the WSL distro IP when only that fallback + // answered). Splitting this off from configResolve avoids races // between "fired onReady" and "currentConfig already advanced". readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; @@ -576,15 +582,34 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( // on "Connecting to WSL…" forever even though the backend kept running // and became healthy. Each round gets a fresh budget, and the forked // loop is torn down with the run scope once the child exits. - const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => + const readinessTimeout = options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT; + const probeUrl = (httpBaseUrl: URL, timeout: Duration.Duration) => waitForHttpReady({ executablePath: options.executablePath, entryPath: options.entryPath, cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( - Effect.flatMap(() => options.onReady?.() ?? Effect.void), + httpBaseUrl, + timeout, + }).pipe(Effect.as(httpBaseUrl)); + // httpBaseUrl stays preferred: the fallback only wins when it answers and a + // fresh probe of httpBaseUrl still fails, so hosts that reach both keep + // using httpBaseUrl. + const probeReachableUrl = + options.fallbackHttpBaseUrl === undefined + ? probeUrl(options.httpBaseUrl, readinessTimeout) + : Effect.race( + probeUrl(options.httpBaseUrl, readinessTimeout), + probeUrl(options.fallbackHttpBaseUrl, readinessTimeout).pipe( + Effect.flatMap((fallbackUrl) => + probeUrl(options.httpBaseUrl, DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT).pipe( + Effect.orElseSucceed(() => fallbackUrl), + ), + ), + ), + ); + const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => + probeReachableUrl.pipe( + Effect.flatMap((readyUrl) => options.onReady?.(readyUrl) ?? Effect.void), Effect.as(true), Effect.catchTags({ BackendReadinessTimeoutError: (error) => @@ -923,7 +948,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...run, exitObserved: true, })), - onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { + onReady: Effect.fn("desktop.backendInstance.onReady")(function* (readyUrl) { + const usedFallback = readyUrl.href !== config.value.httpBaseUrl.href; const isCurrentRun = yield* Ref.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { @@ -936,6 +962,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...latest, restartAttempt: 0, ready: true, + // currentConfig feeds the renderer bootstraps and local + // auth, so they must dial the URL that actually answered. + config: Option.map(latest.config, (current) => ({ + ...current, + httpBaseUrl: readyUrl, + })), }, ] as const; }); @@ -943,7 +975,13 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } - yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; + if (usedFallback) { + yield* logInstanceWarning("backend unreachable at its primary URL; using fallback", { + httpBaseUrl: config.value.httpBaseUrl.href, + fallbackHttpBaseUrl: readyUrl.href, + }); + } + yield* spec.onReady?.(readyUrl) ?? Effect.void; if ( config.value.runningDistro !== undefined && config.value.wslRuntimeId !== undefined diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 2f922a48f4d8..67080de2865f 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -109,7 +109,7 @@ export class DesktopWslEnvironment extends Context.Service< readonly getUserHome: (distro: string | null) => Effect.Effect>; // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's - // httpBaseUrl so the renderer can reach it without relying on wslhost's + // fallbackHttpBaseUrl so the renderer can reach it without relying on wslhost's // localhost→WSL automatic forwarding, which is flaky in practice // (the backend can be listening for 30+ seconds before wslhost starts // forwarding 127.0.0.1:port to WSL-side localhost).