Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
32 changes: 16 additions & 16 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, string> = {};
Expand Down Expand Up @@ -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 } : {}),
};
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
const fallbackProbed = yield* Deferred.make<void>();
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* () {
Expand Down
62 changes: 50 additions & 12 deletions apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PreflightFailure>;
// Present for a WSL run after the configured/default distro has been
Expand Down Expand Up @@ -229,7 +233,9 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig {
readonly outputDrainTimeout?: Duration.Duration;
readonly onStarted?: (pid: number) => Effect.Effect<void>;
readonly onExitObserved?: () => Effect.Effect<void>;
readonly onReady?: () => Effect.Effect<void>;
// Receives the base URL that answered readiness: httpBaseUrl, or
// fallbackHttpBaseUrl when only the fallback is reachable.
readonly onReady?: (httpBaseUrl: URL) => Effect.Effect<void>;
readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>;
readonly onOutput?: (
streamName: BackendProcessOutputStream,
Expand Down Expand Up @@ -288,10 +294,10 @@ export interface BackendInstanceSpec {
// bootstrap-token closure inside DesktopBackendConfiguration uses
// crypto.randomBytes (Effect 4 beta.73 migration).
readonly configResolve: Effect.Effect<DesktopBackendStartConfig, PlatformError.PlatformError>;
// 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<void>;
readonly onShutdown?: () => Effect.Effect<void>;
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) {
Expand All @@ -936,14 +962,26 @@ 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;
});
if (!isCurrentRun) {
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
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/wsl/DesktopWslEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export class DesktopWslEnvironment extends Context.Service<
readonly getUserHome: (distro: string | null) => Effect.Effect<Option.Option<string>>;
// 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).
Expand Down
Loading