diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index b9817552969..b05cd4e403c 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -12,7 +12,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; @@ -21,6 +21,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; 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"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -130,11 +131,13 @@ const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); const bootstrap = Effect.gen(function* () { - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; yield* logBootstrapInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { @@ -178,8 +181,13 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - yield* backendManager.start; + yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + // Bring up the WSL backend if the user previously enabled it. The + // primary is already starting; reconcile fires off the WSL register + // in parallel rather than blocking primary readiness on a possibly + // slow first wsl.exe spawn. + yield* Effect.forkScoped(wslBackend.reconcile); } }).pipe(Effect.withSpan("desktop.bootstrap")); @@ -226,10 +234,20 @@ const scopedProgram = Effect.scoped( yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); const shutdown = yield* DesktopLifecycle.DesktopShutdown; - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const pool = yield* DesktopBackendPool.DesktopBackendPool; yield* Effect.addFinalizer(() => - backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), + Effect.gen(function* () { + // Stop every backend in the pool, not just the primary. The + // electronApp.quit() path can race ahead of the layer-scope + // cascade, so leaving the WSL instance for its parent scope + // finalizer means it gets hard-killed by the OS instead of + // receiving SIGTERM + grace. Stops run concurrently. + const instances = yield* pool.list; + yield* Effect.forEach(instances, (instance) => instance.stop(), { + concurrency: "unbounded", + }); + }).pipe(Effect.ensuring(shutdown.markComplete)), ); yield* startup; diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index a78de48d5e1..9438175f602 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -125,7 +125,8 @@ describe("DesktopObservability", () => { }).pipe(Effect.provide(environmentLayer)); yield* Effect.gen(function* () { - const outputLog = yield* DesktopObservability.DesktopBackendOutputLog; + const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; + const outputLog = yield* factory.forInstance("primary"); yield* outputLog.writeSessionBoundary({ phase: "START", details: "pid=123 port=3773 cwd=/repo", @@ -145,6 +146,7 @@ describe("DesktopObservability", () => { assert.equal(boundary.level, "INFO"); assert.equal(boundary.annotations.component, "desktop-backend-child"); assert.equal(boundary.annotations.runId, "test-run"); + assert.equal(boundary.annotations.instanceId, "primary"); assert.equal(boundary.annotations.phase, "START"); assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); @@ -152,6 +154,7 @@ describe("DesktopObservability", () => { assert.equal(output.level, "INFO"); assert.equal(output.annotations.component, "desktop-backend-child"); assert.equal(output.annotations.runId, "test-run"); + assert.equal(output.annotations.instanceId, "primary"); assert.equal(output.annotations.stream, "stdout"); assert.equal(output.annotations.text, "hello server\n"); }).pipe( diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 4eeb76bd62a..340457f01cb 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -13,7 +13,9 @@ import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; @@ -40,10 +42,23 @@ export interface DesktopBackendOutputLogShape { ) => Effect.Effect; } -export class DesktopBackendOutputLog extends Context.Service< - DesktopBackendOutputLog, - DesktopBackendOutputLogShape ->()("t3/desktop/BackendOutputLog") {} +// Factory for per-instance backend output logs. `forInstance(id)` returns +// a writer that targets a distinct rotating log file — the primary +// instance keeps `server-child.log` so the historical path stays stable +// for ops; other instances get `server-child-.log`. +// +// Writers are cached per id within a single factory instance so repeated +// `forInstance` calls (e.g. during a backend restart that re-resolves +// services) reuse the same rotating writer rather than racing each other +// on the same file. +export interface DesktopBackendOutputLogFactoryShape { + readonly forInstance: (id: string) => Effect.Effect; +} + +export class DesktopBackendOutputLogFactory extends Context.Service< + DesktopBackendOutputLogFactory, + DesktopBackendOutputLogFactoryShape +>()("t3/desktop/BackendOutputLogFactory") {} const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -293,13 +308,34 @@ const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackend }, ); -const backendOutputLogLayer = Layer.effect( - DesktopBackendOutputLog, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; +const PRIMARY_BACKEND_LOG_INSTANCE_ID = "primary"; + +const sanitizeInstanceIdForFileName = (id: string): string => id.replace(/[^a-zA-Z0-9._-]+/g, "_"); +const backendLogFilePathForInstance = ( + environment: DesktopEnvironment.DesktopEnvironmentShape, + id: string, +): string => { + // Primary keeps the historical "server-child.log" path so ops scripts + // and packaged-build log inspection still find it where it always lived. + if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { + return environment.path.join(environment.logDir, "server-child.log"); + } + const sanitized = sanitizeInstanceIdForFileName(id); + return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); +}; + +const makeBackendOutputLogForInstance = ( + environment: DesktopEnvironment.DesktopEnvironmentShape, + id: string, +): Effect.Effect< + DesktopBackendOutputLogShape, + never, + FileSystem.FileSystem | Path.Path | Scope.Scope +> => + Effect.gen(function* () { const writer = yield* makeRotatingLogFileWriter({ - filePath: environment.path.join(environment.logDir, "server-child.log"), + filePath: backendLogFilePathForInstance(environment, id), }).pipe(Effect.option); return Option.match(writer, { @@ -316,6 +352,7 @@ const backendOutputLogLayer = Layer.effect( annotations: { component: "desktop-backend-child", runId, + instanceId: id, phase, details: sanitizeLogValue(details), }, @@ -333,6 +370,7 @@ const backendOutputLogLayer = Layer.effect( annotations: { component: "desktop-backend-child", runId, + instanceId: id, stream: streamName, text: textDecoder.decode(chunk), }, @@ -341,6 +379,49 @@ const backendOutputLogLayer = Layer.effect( ), }) satisfies DesktopBackendOutputLogShape, }); + }); + +const backendOutputLogFactoryLayer = Layer.effect( + DesktopBackendOutputLogFactory, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const factoryScope = yield* Scope.Scope; + // Per-id cache so repeated forInstance(id) calls reuse the same + // rotating writer instead of opening a second handle on the same + // file. Each writer pins itself to the factory's scope so all log + // resources tear down together at app exit. Mutex serializes + // concurrent first-time lookups for the same id. + const cacheRef = yield* SynchronizedRef.make>( + new Map(), + ); + + const makeForId = (id: string): Effect.Effect => + SynchronizedRef.modifyEffect(cacheRef, (cache) => { + // Key the cache by the resolved file path, not the raw id. + // Otherwise two ids that sanitize to the same filename (e.g. + // `wsl:default` and `wsl_default`) would each create their + // own RotatingLogFileWriter pointing at the same file, with + // independent currentSize tracking and a race on writes. + const cacheKey = backendLogFilePathForInstance(environment, id); + const cached = cache.get(cacheKey); + if (cached !== undefined) return Effect.succeed([cached, cache] as const); + return makeBackendOutputLogForInstance(environment, id).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Scope.provide(factoryScope), + Effect.map((shape) => { + const next = new Map(cache); + next.set(cacheKey, shape); + return [shape, next as ReadonlyMap] as const; + }), + ); + }); + + return DesktopBackendOutputLogFactory.of({ + forInstance: (id) => makeForId(id), + }); }), ); @@ -387,7 +468,7 @@ const tracerLayer = Layer.unwrap( ).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); export const layer = Layer.mergeAll( - backendOutputLogLayer, + backendOutputLogFactoryLayer, desktopLoggerLayer, tracerLayer, Layer.succeed(Tracer.MinimumTraceLevel, "Info"), diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts index 43960ada65f..2a1deeb66c5 100644 --- a/apps/desktop/src/app/DesktopState.ts +++ b/apps/desktop/src/app/DesktopState.ts @@ -4,7 +4,6 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; export interface DesktopStateShape { - readonly backendReady: Ref.Ref; readonly quitting: Ref.Ref; } @@ -15,7 +14,6 @@ export class DesktopState extends Context.Service { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +}; + const withHarness = ( effect: Effect.Effect< A, @@ -89,6 +101,8 @@ const withHarness = ( Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), ), ), @@ -96,14 +110,14 @@ const withHarness = ( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); describe("DesktopBackendConfiguration", () => { - it.effect("resolves backend start config with a stable scoped bootstrap token", () => + it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const first = yield* configuration.resolve; - const second = yield* configuration.resolve; + const first = yield* configuration.resolvePrimary; + const second = yield* configuration.resolvePrimary; assert.equal(first.executablePath, process.execPath); assert.equal(first.entryPath, environment.backendEntryPath); @@ -127,7 +141,20 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("includes persisted backend observability endpoints when present", () => + it.effect("resolveWsl reuses the primary's bootstrap token", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + const primary = yield* configuration.resolvePrimary; + const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); + + assert.equal(wsl.bootstrap.desktopBootstrapToken, primary.bootstrap.desktopBootstrapToken); + }), + ), + ); + + it.effect("resolvePrimary surfaces persisted backend observability endpoints", () => withHarness( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -147,18 +174,18 @@ describe("DesktopBackendConfiguration", () => { }), ); - const config = yield* configuration.resolve; + const config = yield* configuration.resolvePrimary; assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); }), ), ); - it.effect("omits backend observability endpoints when settings are missing", () => + it.effect("resolvePrimary omits backend observability endpoints when settings are missing", () => withHarness( Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolve; + const config = yield* configuration.resolvePrimary; assert.isUndefined(config.bootstrap.otlpTracesUrl); assert.isUndefined(config.bootstrap.otlpMetricsUrl); @@ -166,7 +193,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("captures backend output in development so child process logs can be persisted", () => + it.effect("resolvePrimary captures backend output in dev so child logs can be persisted", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -175,12 +202,14 @@ describe("DesktopBackendConfiguration", () => { yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolve; + const config = yield* configuration.resolvePrimary; assert.equal(config.captureOutput, true); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { isPackaged: false, @@ -192,4 +221,63 @@ describe("DesktopBackendConfiguration", () => { ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const previousWslEnv = process.env.WSLENV; + const previousOpenAiKey = process.env.OPENAI_API_KEY; + const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; + try { + process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; + process.env.OPENAI_API_KEY = "openai-key"; + process.env.ANTHROPIC_API_KEY = "anthropic-key"; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.executablePath, "wsl.exe"); + assert.equal(config.bootstrap.port, 5050); + // Binds to 0.0.0.0 inside WSL so the backend is reachable via + // both wslhost-forwarded localhost and the distro's eth0 IP. + assert.equal(config.bootstrap.host, "0.0.0.0"); + assert.equal(config.bootstrap.tailscaleServeEnabled, false); + // 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.env.OPENAI_API_KEY, "openai-key"); + assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + assert.equal( + config.env.WSLENV, + "GOPATH/p:OPENAI_API_KEY/u:EMPTY:AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY", + ); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + restoreEnv("WSLENV", previousWslEnv); + restoreEnv("OPENAI_API_KEY", previousOpenAiKey); + restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 42e4ada438b..dcb1fc2bb0c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -7,13 +9,28 @@ import * as Option from "effect/Option"; import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; +import serverPackageJson from "../../../server/package.json" with { type: "json" }; + import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; export interface DesktopBackendConfigurationShape { - readonly resolve: Effect.Effect; + // Build the Windows-native primary backend's start config. Reads the + // primary's port/host/exposure from DesktopServerExposure. + readonly resolvePrimary: Effect.Effect; + // Build a WSL backend start config for the given distro on the given + // port. The WSL backend is always loopback-only (the primary owns LAN + // exposure when the user opts in), so this takes the port directly and + // hardcodes 127.0.0.1. Distro=null means "WSL default distro" and is + // forwarded to wsl.exe with no -d flag. + readonly resolveWsl: (input: { + readonly port: number; + readonly distro: string | null; + }) => Effect.Effect; } export class DesktopBackendConfiguration extends Context.Service< @@ -44,9 +61,48 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; +// Sensitive env vars that the WSL backend needs but Windows process.env won't +// forward across the wsl.exe boundary without WSLENV. The dev-server URL is +// handled separately via a `--dev-url` CLI flag because WSLENV translation of +// URL-shaped values (colons / slashes) is unreliable. +const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; + const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); +const getWslEnvEntryName = (entry: string): string => { + const slashIndex = entry.indexOf("/"); + return slashIndex === -1 ? entry : entry.slice(0, slashIndex); +}; + +const mergeWslEnv = ( + existingWslEnv: string | undefined, + forwardedEnvNames: ReadonlyArray, +): string | undefined => { + const entries: string[] = []; + const seenNames = new Set(); + + for (const rawEntry of existingWslEnv?.split(":") ?? []) { + const entry = rawEntry.trim(); + if (entry.length === 0) continue; + + const name = getWslEnvEntryName(entry); + if (name.length === 0 || seenNames.has(name)) continue; + + seenNames.add(name); + entries.push(entry); + } + + for (const name of forwardedEnvNames) { + if (seenNames.has(name)) continue; + + seenNames.add(name); + entries.push(name); + } + + return entries.length > 0 ? entries.join(":") : undefined; +}; + const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( "desktop-backend-configuration", ); @@ -97,11 +153,101 @@ const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootst }, ); -const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( - function* (input: { - readonly bootstrapToken: string; - readonly observabilitySettings: BackendObservabilitySettings; - }): Effect.fn.Return< +interface SharedBootstrapInput { + readonly bootstrapToken: string; + readonly observabilitySettings: BackendObservabilitySettings; +} + +interface WslPreflightOutcome { + readonly _tag: "Ready"; + readonly linuxEntryPath: string; +} + +interface WslPreflightFailure { + readonly _tag: "Failed"; + readonly reason: string; +} + +const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { + readonly distro: string | null; + readonly windowsEntryPath: string; + readonly windowsRepoRoot: string; + readonly allowBuild: boolean; +}): Effect.fn.Return< + WslPreflightOutcome | WslPreflightFailure, + never, + DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem +> { + const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + + const wslAvailable = yield* wslEnv.isAvailable; + if (!wslAvailable) { + return { _tag: "Failed", reason: "WSL is not available on this system" } as const; + } + + const entryExists = yield* fileSystem + .exists(input.windowsEntryPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!entryExists) { + return { + _tag: "Failed", + reason: `missing server entry at ${input.windowsEntryPath}`, + } as const; + } + + const linuxEntry = yield* wslEnv.windowsToWslPath(input.distro, input.windowsEntryPath); + if (Option.isNone(linuxEntry)) { + return { + _tag: "Failed", + reason: `wslpath conversion failed for ${input.windowsEntryPath}`, + } as const; + } + + const nodePtyResult = yield* wslEnv.ensureNodePty(input.distro, input.windowsRepoRoot, { + allowBuild: input.allowBuild, + nodeEngineRange: serverPackageJson.engines.node, + }); + if (!nodePtyResult.ok) { + return { + _tag: "Failed", + reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`, + } as const; + } + + return { _tag: "Ready", linuxEntryPath: linuxEntry.value } as const; +}); + +// 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. +const isLocalHostIpv4 = (ip: string): boolean => { + const interfaces = NodeOS.networkInterfaces(); + for (const list of Object.values(interfaces)) { + if (!list) continue; + for (const entry of list) { + if (entry.family === "IPv4" && entry.address === ip) return true; + } + } + return false; +}; + +const buildObservabilityFragment = (observabilitySettings: BackendObservabilitySettings) => ({ + ...Option.match(observabilitySettings.otlpTracesUrl, { + onNone: () => ({}), + onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), + }), + ...Option.match(observabilitySettings.otlpMetricsUrl, { + onNone: () => ({}), + onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), + }), +}); + +const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( + function* ( + input: SharedBootstrapInput, + ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, never, DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure @@ -110,61 +256,267 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const backendExposure = yield* serverExposure.backendConfig; + const bootstrap = { + mode: "desktop" as const, + noBrowser: true, + port: backendExposure.port, + t3Home: environment.baseDir, + host: backendExposure.bindHost, + desktopBootstrapToken: input.bootstrapToken, + tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, + tailscaleServePort: backendExposure.tailscaleServePort, + ...buildObservabilityFragment(input.observabilitySettings), + }; + return { executablePath: process.execPath, + args: [environment.backendEntryPath, "--bootstrap-fd", "3"], entryPath: environment.backendEntryPath, cwd: environment.backendCwd, env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", }, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: backendExposure.port, - t3Home: environment.baseDir, - host: backendExposure.bindHost, - desktopBootstrapToken: input.bootstrapToken, - tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, - tailscaleServePort: backendExposure.tailscaleServePort, - ...Option.match(input.observabilitySettings.otlpTracesUrl, { - onNone: () => ({}), - onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), - }), - ...Option.match(input.observabilitySettings.otlpMetricsUrl, { - onNone: () => ({}), - onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), - }), - }, + // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). + extendEnv: true, + bootstrap, + bootstrapDelivery: "fd3", httpBaseUrl: backendExposure.httpBaseUrl, captureOutput: true, - }; + preflightFailure: Option.none(), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; }, ); +const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl")(function* ( + input: SharedBootstrapInput & { + readonly port: number; + readonly distro: string | null; + }, +): Effect.fn.Return< + DesktopBackendManager.DesktopBackendStartConfig, + never, + | DesktopEnvironment.DesktopEnvironment + | DesktopWslEnvironment.DesktopWslEnvironment + | FileSystem.FileSystem +> { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + + // Bind to 0.0.0.0 inside WSL so the backend is reachable both via + // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 + // -> WSL 127.0.0.1) AND via the distro's eth0 IP directly from + // 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. + // 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. + const wslBindHost = "0.0.0.0"; + + // Resolve the WSL distro's IPv4 address (eth0). Falls back to + // 127.0.0.1 + wslhost forwarding when the IP probe fails: that + // gives us the same behavior as before this change, so a missing + // WSL setup doesn't regress instead of just degrading. + // + // In mirrored mode (`networkingMode=mirrored` in .wslconfig) the + // distro shares the Windows network stack, so `hostname -I` returns + // the Windows host's IP (e.g. 192.168.0.64). Windows can't route a + // packet to its own NIC address and have it loop back to a WSL + // listener — it just times out. Loopback DOES work in mirrored mode, + // though, so detect this case by checking whether the distro IP + // matches one of our own interfaces and fall back to 127.0.0.1. + const distroIp = yield* wslEnvironment.getDistroIp(input.distro); + 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 bootstrap = { + mode: "desktop" as const, + noBrowser: true, + port: input.port, + // Omit t3Home so the Linux backend uses its own home dir instead of + // the Windows-side baseDir (which would be a /mnt/c path and share + // the SQLite file with the primary). + host: wslBindHost, + desktopBootstrapToken: input.bootstrapToken, + // PortSchema rejects 0, so when tailscale serve is disabled we still + // need a valid number in this slot. The backend reads tailscaleServePort + // only when tailscaleServeEnabled is true, so the actual value here is + // inert. + tailscaleServeEnabled: false, + tailscaleServePort: 443, + ...buildObservabilityFragment(input.observabilitySettings), + }; + + const preflight = yield* runWslPreflight({ + distro: input.distro, + windowsEntryPath: environment.backendEntryPath, + windowsRepoRoot: environment.appRoot, + allowBuild: !environment.isPackaged, + }); + + const distroArgs = input.distro ? ["-d", input.distro] : []; + const forwardedEnv: Record = {}; + const forwardedEnvNames: string[] = []; + for (const name of WSL_FORWARDED_ENV_NAMES) { + const value = process.env[name]; + if (value !== undefined && value.length > 0) { + forwardedEnv[name] = value; + forwardedEnvNames.push(name); + } + } + + // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner + // exports the Windows-side base dir for the primary; if it leaks into + // the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via + // /mnt/c, which means both backends read/write the same database and + // their env-ids collide). + const parentEnvWithoutT3Home: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key === "T3CODE_HOME") continue; + parentEnvWithoutT3Home[key] = value; + } + const wslEnv = mergeWslEnv(parentEnvWithoutT3Home.WSLENV, forwardedEnvNames); + + const baseConfig = { + executablePath: "wsl.exe", + entryPath: environment.backendEntryPath, + cwd: environment.backendCwd, + env: { + ...parentEnvWithoutT3Home, + ...backendChildEnvPatch(), + ...forwardedEnv, + ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), + }, + // env is already a complete process.env minus T3CODE_HOME; pass it + // verbatim instead of letting the spawner re-merge process.env on top. + extendEnv: false, + bootstrap, + bootstrapDelivery: "stdin" as const, + httpBaseUrl, + captureOutput: true, + }; + + // Forward the dev-server URL as an explicit CLI flag so the WSL backend's + // config resolution lands in dev/ instead of userdata/. Inheriting through + // WSLENV is unreliable in practice (URL-shaped values with colons / + // slashes get translated unpredictably depending on flags), and the + // packaged build leaves devServerUrl as None anyway. + const devUrlArgs = Option.match(environment.devServerUrl, { + onNone: () => [] as ReadonlyArray, + onSome: (url) => ["--dev-url", url.href], + }); + + if (preflight._tag === "Failed") { + return { + ...baseConfig, + args: [...distroArgs, "--", "node", "--version"], + preflightFailure: Option.some(preflight.reason), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; + } + + return { + ...baseConfig, + args: [ + ...distroArgs, + "--", + "node", + preflight.linuxEntryPath, + "--bootstrap-fd", + "0", + ...devUrlArgs, + ], + preflightFailure: Option.none(), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; +}); + export const layer = Layer.effect( DesktopBackendConfiguration, Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* DesktopAppSettings.DesktopAppSettings; const tokenRef = yield* Ref.make(Option.none()); + // Both resolvers share the same bootstrap token: the renderer holds a + // single token and uses it against whichever backend it's currently + // talking to. Observability settings get re-read each resolve so a + // hot-swap of the server-settings file is picked up on the next + // restart cycle without having to bounce the desktop process. + const sharedInputs = Effect.gen(function* () { + const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef); + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return { bootstrapToken, observabilitySettings } satisfies SharedBootstrapInput; + }); + + const buildWslPrimaryConfig = Effect.gen(function* () { + // wsl-only mode pipes the WSL backend through the same port the + // Windows primary would normally take. That way the renderer + // still loads from the local-only endpoint advertised by + // DesktopServerExposure, and primary-aware code paths (cookie + // auth, the env switcher's "primary" id) keep working without + // a parallel "secondary" registration. + const backendExposure = yield* serverExposure.backendConfig; + const persistedSettings = yield* settings.get; + const shared = yield* sharedInputs; + return yield* resolveWslStartConfig({ + ...shared, + port: backendExposure.port, + distro: persistedSettings.wslDistro, + }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + }); + + const buildWindowsPrimaryConfig = Effect.gen(function* () { + const shared = yield* sharedInputs; + return yield* resolvePrimaryStartConfig(shared).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + ); + }); + return DesktopBackendConfiguration.of({ - resolve: Effect.gen(function* () { - const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef); - const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - ); - return yield* resolveBackendStartConfig({ - bootstrapToken, - observabilitySettings, + resolvePrimary: Effect.gen(function* () { + // Dispatch on the wsl-only setting at resolve time so the + // user toggling the mode between restarts picks up the new + // path on the next start cycle. The pool's primary + // BackendInstance is created once at layer init, but its + // configResolve fires each time the backend (re)starts. + const persistedSettings = yield* settings.get; + if (persistedSettings.wslOnly && persistedSettings.wslBackendEnabled) { + return yield* buildWslPrimaryConfig; + } + return yield* buildWindowsPrimaryConfig; + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimary")), + resolveWsl: (input) => + Effect.gen(function* () { + const shared = yield* sharedInputs; + return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); }).pipe( - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), - ); - }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), + Effect.withSpan("desktop.backendConfiguration.resolveWsl", { + attributes: { port: input.port, distro: input.distro ?? null }, + }), + ), }); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 6c5109c8714..dafd99d2e25 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -20,10 +20,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; -import * as DesktopState from "../app/DesktopState.ts"; -import * as DesktopWindow from "../window/DesktopWindow.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), @@ -31,6 +28,7 @@ const decodeDesktopBackendBootstrap = Schema.decodeEffect( const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", + args: ["/server/bin.mjs", "--bootstrap-fd", "3"], entryPath: "/server/bin.mjs", cwd: "/server", env: { ELECTRON_RUN_AS_NODE: "1" }, @@ -44,8 +42,11 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { tailscaleServeEnabled: false, tailscaleServePort: 443, }, + bootstrapDelivery: "fd3", + extendEnv: true, httpBaseUrl: new URL("http://127.0.0.1:3773"), captureOutput: true, + preflightFailure: Option.none(), }; const configWithObservability: DesktopBackendBootstrapValue = { @@ -101,97 +102,93 @@ function decodeBootstrap(raw: string) { return decodeDesktopBackendBootstrap(raw); } -function makeManagerLayer(input: { +interface MakeInstanceInput { readonly spawnerLayer: Layer.Layer; readonly httpClientLayer?: Layer.Layer; readonly backendOutputLog?: Partial; - readonly desktopState?: DesktopState.DesktopStateShape; - readonly desktopWindow?: Partial; + readonly onReady?: Effect.Effect; + readonly onShutdown?: Effect.Effect; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; -}) { - return DesktopBackendManager.layer.pipe( - Layer.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - exists: () => Effect.succeed(true), - }), - Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { - resolve: Effect.succeed(input.config ?? baseConfig), - }), - input.spawnerLayer, - input.httpClientLayer ?? healthyHttpClientLayer, - input.desktopState - ? Layer.succeed(DesktopState.DesktopState, input.desktopState) - : DesktopState.layer, - Layer.succeed(DesktopObservability.DesktopBackendOutputLog, { - writeSessionBoundary: () => Effect.void, - writeOutputChunk: () => Effect.void, - ...input.backendOutputLog, - } satisfies DesktopObservability.DesktopBackendOutputLogShape), - Layer.succeed(DesktopWindow.DesktopWindow, { - createMain: Effect.die("unexpected createMain"), - ensureMain: Effect.die("unexpected ensureMain"), - revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), - activate: Effect.void, - createMainIfBackendReady: Effect.void, - handleBackendReady: Effect.void, - dispatchMenuAction: () => Effect.void, - syncAppearance: Effect.void, - ...input.desktopWindow, - } satisfies DesktopWindow.DesktopWindowShape), - ), - ), +} + +// Helper that constructs a primary backend instance using the factory +// directly. The factory's deps (FileSystem, ChildProcessSpawner, +// HttpClient, DesktopBackendOutputLogFactory) are provided per-test via +// a scoped layer; tests yield the returned Effect inside `Effect.scoped` +// to drive the instance's lifecycle. +function makeTestInstance(input: MakeInstanceInput) { + const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, + ...input.backendOutputLog, + }; + const servicesLayer = Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + }), + input.spawnerLayer, + input.httpClientLayer ?? healthyHttpClientLayer, + Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { + forInstance: () => Effect.succeed(stubLog), + } satisfies DesktopObservability.DesktopBackendOutputLogFactoryShape), ); + + const instance = DesktopBackendManager.makeBackendInstance({ + id: DesktopBackendManager.PRIMARY_INSTANCE_ID, + label: "Windows", + configResolve: Effect.succeed(input.config ?? baseConfig), + ...(input.onReady ? { onReady: () => input.onReady! } : {}), + ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), + }); + + return instance.pipe(Effect.provide(servicesLayer)); } describe("DesktopBackendManager", () => { it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - let bootstrapJson = ""; - let readyCount = 0; - const ready = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - spawnedCommand = command; - if (command._tag === "StandardCommand") { - const fd3 = command.options.additionalFds?.fd3; - if (fd3?.type === "input" && fd3.stream) { - bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + Effect.scoped( + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } } - } - - return makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); - - const managerLayer = makeManagerLayer({ - config: { - ...baseConfig, - bootstrap: configWithObservability, - }, - spawnerLayer, - desktopWindow: { - handleBackendReady: Effect.sync(() => { + + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer, + onReady: Effect.sync(() => { readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), - }, - backendOutputLog: { - writeSessionBoundary: ({ phase }) => - phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + }, + }); + + yield* instance.start; yield* Queue.take(exited); assert.equal(readyCount, 1); @@ -214,55 +211,52 @@ describe("DesktopBackendManager", () => { ); assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); - }).pipe(Effect.provide(managerLayer)); - }), + }), + ), ); it.effect("retries HTTP readiness before reporting the backend ready", () => - Effect.gen(function* () { - const requestUrls: Array = []; - const statuses = [503, 200]; - let readyCount = 0; - const firstRequest = yield* Deferred.make(); - const ready = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + Effect.scoped( + Effect.gen(function* () { + const requestUrls: Array = []; + const statuses = [503, 200]; + let readyCount = 0; + const firstRequest = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer((request) => + Effect.gen(function* () { + const status = statuses.shift(); + assert.isDefined(status); + requestUrls.push(request.url); + yield* Deferred.succeed(firstRequest, void 0); + return responseForRequest(request, status); }), ), - ), - ); - - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer((request) => - Effect.gen(function* () { - const status = statuses.shift(); - assert.isDefined(status); - requestUrls.push(request.url); - yield* Deferred.succeed(firstRequest, void 0); - return responseForRequest(request, status); - }), - ), - desktopWindow: { - handleBackendReady: Effect.sync(() => { + onReady: Effect.sync(() => { readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), - }, - backendOutputLog: { - writeSessionBoundary: ({ phase }) => - phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + }, + }); + + yield* instance.start; yield* Deferred.await(firstRequest); assert.equal(readyCount, 0); @@ -276,106 +270,100 @@ describe("DesktopBackendManager", () => { "http://127.0.0.1:3773/.well-known/t3/environment", "http://127.0.0.1:3773/.well-known/t3/environment", ]); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); - }), + }).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("starts the configured backend and closes the scoped process on stop", () => - Effect.gen(function* () { - let startCount = 0; - let closedCount = 0; - const closed = yield* Deferred.make(); - const startedPids = yield* Queue.unbounded(); - const ready = yield* Deferred.make(); - const backendReady = yield* Ref.make(false); - const quitting = yield* Ref.make(false); - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - startCount += 1; - yield* Queue.offer(startedPids, 123); - const close = Effect.sync(() => { - closedCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); - - yield* Scope.addFinalizer(scope, close); - - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - kill: () => close, - }); - }), - ), - ); - - const managerLayer = makeManagerLayer({ - spawnerLayer, - desktopState: { - backendReady, - quitting, - }, - desktopWindow: { - handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - assert.isTrue(Option.isNone(yield* manager.currentConfig)); - - yield* manager.start; + Effect.scoped( + Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + const backendReadyFlag = yield* Ref.make(false); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + yield* Queue.offer(startedPids, 123); + const close = Effect.sync(() => { + closedCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); + + yield* Scope.addFinalizer(scope, close); + + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + onReady: Ref.set(backendReadyFlag, true).pipe( + Effect.andThen(Deferred.succeed(ready, void 0)), + Effect.asVoid, + ), + onShutdown: Ref.set(backendReadyFlag, false), + }); + assert.isTrue(Option.isNone(yield* instance.currentConfig)); + + yield* instance.start; assert.equal(yield* Queue.take(startedPids), 123); yield* Deferred.await(ready); - assert.isTrue(yield* Ref.get(backendReady)); - assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); + assert.isTrue(yield* Ref.get(backendReadyFlag)); + assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); - const runningSnapshot = yield* manager.snapshot; + const runningSnapshot = yield* instance.snapshot; assert.equal(runningSnapshot.ready, true); assert.deepEqual(runningSnapshot.activePid, Option.some(123)); - yield* manager.stop(); + yield* instance.stop(); assert.equal(startCount, 1); assert.equal(closedCount, 1); - const stoppedSnapshot = yield* manager.snapshot; - assert.isFalse(yield* Ref.get(backendReady)); + const stoppedSnapshot = yield* instance.snapshot; + assert.isFalse(yield* Ref.get(backendReadyFlag)); assert.equal(stoppedSnapshot.desiredRunning, false); assert.equal(stoppedSnapshot.ready, false); assert.equal(Option.isNone(stoppedSnapshot.activePid), true); - }).pipe(Effect.provide(managerLayer)); - }), + }), + ), ); it.effect("restarts an unexpectedly exited backend with the Effect clock", () => - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - let startCount = 0; - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); - }), - ), - ); - - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* instance.start; assert.equal(yield* Queue.take(starts), 1); @@ -388,107 +376,105 @@ describe("DesktopBackendManager", () => { assert.equal(yield* Queue.size(starts), 0); yield* TestClock.adjust(Duration.millis(1)); assert.equal(yield* Queue.take(starts), 3); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); - }), + }).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("cancels a scheduled restart when start is requested manually", () => - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const secondClosed = yield* Deferred.make(); - let startCount = 0; - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - startCount += 1; - yield* Queue.offer(starts, startCount); - - if (startCount === 1) { + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const secondClosed = yield* Deferred.make(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + + if (startCount === 1) { + return makeProcess({ + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }); + } + + const scope = yield* Scope.Scope; + const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); + yield* Scope.addFinalizer(scope, close); return makeProcess({ - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + exitCode: Deferred.await(secondClosed).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + kill: () => close, }); - } - - const scope = yield* Scope.Scope; - const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); - yield* Scope.addFinalizer(scope, close); - return makeProcess({ - exitCode: Deferred.await(secondClosed).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - kill: () => close, - }); - }), - ), - ); - - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* instance.start; assert.equal(yield* Queue.take(starts), 1); - yield* manager.start; + yield* instance.start; assert.equal(yield* Queue.take(starts), 2); - yield* manager.stop(); + yield* instance.stop(); yield* TestClock.adjust(Duration.millis(500)); assert.equal(yield* Queue.size(starts), 0); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); - }), + }).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("does not restart after stop cancels a scheduled restart", () => - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - let startCount = 0; - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); - }), - ), - ); - - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* instance.start; assert.equal(yield* Queue.take(starts), 1); let restartScheduled = false; while (!restartScheduled) { - restartScheduled = (yield* manager.snapshot).restartScheduled; + restartScheduled = (yield* instance.snapshot).restartScheduled; if (!restartScheduled) { yield* Effect.yieldNow; } } - yield* manager.stop(); + yield* instance.stop(); yield* TestClock.adjust(Duration.millis(500)); assert.equal(yield* Queue.size(starts), 0); - assert.equal((yield* manager.snapshot).desiredRunning, false); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); - }), + assert.equal((yield* instance.snapshot).desiredRunning, false); + }).pipe(Effect.provide(TestClock.layer())), + ), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 97931f42dbd..610e9c03f37 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,12 +1,36 @@ +// Per-instance backend factory. Replaces the legacy singleton +// `DesktopBackendManager` Context.Service: each call to +// `makeBackendInstance(spec)` constructs an isolated backend lifecycle — +// its own state Ref, mutex, restart loop, and active child process. The +// returned `DesktopBackendInstance` exposes start/stop/snapshot/wait +// methods that operate on that single backend. +// +// The pool layer (`DesktopBackendPool.ts`) calls this factory once per +// backend it wants to run. Today that's the Windows primary; follow-up +// commits add a second call for the WSL instance. +// +// Singleton couplings that the legacy service held inline are now +// parameterized via the spec: +// - configResolve replaces the legacy `DesktopBackendConfiguration.resolve` +// so each instance can resolve its own start config — the primary wires +// `configuration.resolvePrimary`, the WSL orchestrator wires a +// `configuration.resolveWsl({ port, distro })` closure. +// - onReady / onShutdown drive UI side effects (window auto-open, +// readiness latch) only for instances that want them — the primary's +// spec passes the window's handleBackendReady/handleBackendNotReady, +// other pool instances pass nothing. +// - log writes go through a per-instance writer that the factory +// pulls from `DesktopBackendOutputLogFactory.forInstance(spec.id)`, +// so each instance lands in its own rotating file. + +import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; @@ -22,12 +46,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, + PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; -import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; -import * as DesktopState from "../app/DesktopState.ts"; -import * as DesktopWindow from "../window/DesktopWindow.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -43,14 +65,23 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; +export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; + export interface DesktopBackendStartConfig { readonly executablePath: string; + readonly args: ReadonlyArray; readonly entryPath: string; readonly cwd: string; readonly env: Record; + // When true the spawner merges the desktop process.env on top of `env`; + // when false `env` is passed verbatim. WSL mode opts out so a leaking + // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. + readonly extendEnv: boolean; readonly bootstrap: DesktopBackendBootstrapValue; + readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; readonly httpBaseUrl: URL; readonly captureOutput: boolean; + readonly preflightFailure: Option.Option; } interface BackendProcessExit { @@ -106,20 +137,53 @@ export interface DesktopBackendSnapshot { readonly restartScheduled: boolean; } -export interface DesktopBackendManagerShape { +// Opaque identifier for one backend process inside the pool. Today only +// PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros +// under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually +// these map 1:1 with environment ids on the frontend; keeping them +// desktop-local for now avoids leaking the contracts dependency. +export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">; +export const BackendInstanceId = Brand.nominal(); + +export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( + PRIMARY_LOCAL_ENVIRONMENT_ID, +); + +// One pooled backend instance. Same lifecycle surface as the legacy +// `DesktopBackendManagerShape`; the id and label give the pool registry +// + UI something to route on. +export interface DesktopBackendInstance { + readonly id: BackendInstanceId; + readonly label: string; readonly start: Effect.Effect; readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; + // Polls desiredRunning + the instance's own ready flag until the + // backend reports ready, or the timeout elapses. Returns true on + // ready, false on timeout. Used by the WSL backend swap to drive its + // rollback path. + readonly waitForReady: (timeout: Duration.Duration) => Effect.Effect; } -export class DesktopBackendManager extends Context.Service< - DesktopBackendManager, - DesktopBackendManagerShape ->()("t3/desktop/BackendManager") {} - -const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = - DesktopObservability.makeComponentLogger("desktop-backend-manager"); +// Spec describing one backend instance to spawn. The configResolve +// effect is awaited each time the instance is (re)started so live +// settings changes are picked up on the next start cycle. onReady and +// onShutdown let the primary instance trigger UI side effects (window +// open, global readiness flag) without coupling the factory to those +// concerns; other instances pass them as undefined. +export interface BackendInstanceSpec { + readonly id: BackendInstanceId; + readonly label: string; + 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 + // between "fired onReady" and "currentConfig already advanced". + readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; + readonly onShutdown?: () => Effect.Effect; +} interface ActiveBackendRun { readonly id: number; @@ -233,28 +297,25 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), ); const onOutput = options.onOutput ?? (() => Effect.void); - const command = ChildProcess.make( - options.executablePath, - [options.entryPath, "--bootstrap-fd", "3"], - { - cwd: options.cwd, - env: options.env, - extendEnv: true, - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. - stdin: "ignore", - stdout: options.captureOutput ? "pipe" : "inherit", - stderr: options.captureOutput ? "pipe" : "inherit", - killSignal: "SIGTERM", - forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, - additionalFds: { - fd3: { - type: "input", - stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), - }, - }, - }, - ); + const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`)); + const command = ChildProcess.make(options.executablePath, options.args, { + cwd: options.cwd, + env: options.env, + extendEnv: options.extendEnv, + // In Electron main, process.execPath points to the Electron binary. + // Run the child in Node mode so this backend process does not become a GUI app instance. + stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", + stdout: options.captureOutput ? "pipe" : "inherit", + stderr: options.captureOutput ? "pipe" : "inherit", + killSignal: "SIGTERM", + forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, + // wsl.exe drops additional file descriptors when forwarding to the Linux + // side, so the WSL spawn path delivers the bootstrap envelope via stdin + // (`--bootstrap-fd 0`) instead. + ...(options.bootstrapDelivery === "fd3" + ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } } + : {}), + }); const handle = yield* spawner .spawn(command) @@ -277,18 +338,34 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( return describeProcessExit(yield* Effect.result(handle.exitCode)); }); -const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { +// Factory for one pooled backend instance. The returned instance owns +// its own state Ref, mutex, restart loop, and active child process; +// nothing is shared between instances created from separate +// makeBackendInstance calls. The instance shuts down automatically when +// the calling scope closes (typically the application scope). +export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( + spec: BackendInstanceSpec, +): Effect.fn.Return< + DesktopBackendInstance, + never, + | FileSystem.FileSystem + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient + | DesktopObservability.DesktopBackendOutputLogFactory + | Scope.Scope +> { const parentScope = yield* Scope.Scope; const fileSystem = yield* FileSystem.FileSystem; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; - const desktopState = yield* DesktopState.DesktopState; - const desktopWindow = yield* DesktopWindow.DesktopWindow; + const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; + const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); const mutex = yield* Semaphore.make(1); + const { logWarning: logInstanceWarning, logError: logInstanceError } = + DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); + const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => Ref.update(state, withActiveRun(runId, f)); @@ -328,8 +405,8 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio return; } - yield* Ref.set(desktopState.backendReady, false); - const config = yield* configuration.resolve; + yield* spec.onShutdown?.() ?? Effect.void; + const config = yield* spec.configResolve; const entryExists = yield* fileSystem .exists(config.entryPath) .pipe(Effect.orElseSucceed(() => false)); @@ -342,6 +419,11 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio config: Option.some(config), })); + if (Option.isSome(config.preflightFailure)) { + yield* scheduleRestart(config.preflightFailure.value); + return; + } + if (!entryExists) { yield* scheduleRestart(`missing server entry at ${config.entryPath}`); return; @@ -362,7 +444,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }, ]); - const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( + const finalizeRun = Effect.fn("desktop.backendInstance.finalizeRun")(function* ( reason: string, ) { yield* mutex.withPermits(1)( @@ -414,7 +496,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio details: `pid=${pid.value} ${reason}`, }); } - yield* Ref.set(desktopState.backendReady, false); + yield* spec.onShutdown?.() ?? Effect.void; } if (isCurrentRun && nextState.desiredRunning) { @@ -426,7 +508,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const program = runBackendProcess({ ...config, - onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { + onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { yield* updateActiveRun(runId, (run) => ({ ...run, pid: Option.some(pid), @@ -436,7 +518,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, }); }), - onReady: Effect.fn("desktop.backendManager.onReady")(function* () { + onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { const isCurrentRun = yield* Ref.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { @@ -456,17 +538,10 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio return; } - yield* Ref.set(desktopState.backendReady, true); - yield* desktopWindow.handleBackendReady.pipe( - Effect.catch((error) => - logBackendManagerError("failed to open main window after backend readiness", { - message: error.message, - }), - ), - ); + yield* spec.onReady?.(config.httpBaseUrl) ?? Effect.void; }), onReadinessFailure: (error) => - logBackendManagerWarning("backend readiness check failed during bootstrap", { + logInstanceWarning("backend readiness check failed during bootstrap", { error: error.message, }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), @@ -488,9 +563,9 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio })); }), ), - ).pipe(Effect.withSpan("desktop.backendManager.start")); + ).pipe(Effect.withSpan("desktop.backendInstance.start", { attributes: { id: spec.id } })); - const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* ( + const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( reason: string, ) { const scheduled = yield* Ref.modify(state, (latest) => { @@ -510,8 +585,8 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio yield* Option.match(scheduled, { onNone: () => Effect.void, - onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { - yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", { + onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) { + yield* logInstanceError("backend exited unexpectedly; restart scheduled", { reason, delayMs: Duration.toMillis(delay), }); @@ -531,7 +606,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ), Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), Effect.catchCause((cause) => - logBackendManagerError("desktop backend restart fiber failed", { + logInstanceError("desktop backend restart fiber failed", { cause: Cause.pretty(cause), }), ), @@ -550,7 +625,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); - const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { + const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: { readonly timeout?: Duration.Duration; }) { const { active, restartFiber } = yield* mutex.withPermits(1)( @@ -568,7 +643,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio restartFiber: Option.none>(), }, ]); - yield* Ref.set(desktopState.backendReady, false); + yield* spec.onShutdown?.() ?? Effect.void; return result; }), ); @@ -583,14 +658,32 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); + const waitForReady = (timeout: Duration.Duration): Effect.Effect => + Effect.gen(function* () { + const current = yield* Ref.get(state); + // Return false early if an external `stop()` flipped desiredRunning off + // — no point polling for a backend that is being torn down. + if (!current.desiredRunning) return { done: true, ready: false }; + return current.ready ? { done: true, ready: true } : { done: false, ready: false }; + }).pipe( + Effect.repeat({ + until: (status) => status.done, + schedule: Schedule.spaced(Duration.millis(100)), + }), + Effect.map((status) => status.ready), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ); + yield* Effect.addFinalizer(() => stop()); - return DesktopBackendManager.of({ + return { + id: spec.id, + label: spec.label, start, stop, currentConfig, snapshot, - }); + waitForReady, + } satisfies DesktopBackendInstance; }); - -export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager()); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts new file mode 100644 index 00000000000..6fdf2d31f69 --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -0,0 +1,65 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as DesktopBackendPool from "./DesktopBackendPool.ts"; +import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; + +function makeStubInstance( + id: DesktopBackendPool.BackendInstanceId, + label: string, +): DesktopBackendPool.DesktopBackendInstance { + const snapshot: DesktopBackendSnapshot = { + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }; + return { + id, + label, + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed(snapshot), + waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), + }; +} + +describe("DesktopBackendPool", () => { + it.effect("layerTest exposes registered instances by id", () => + Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const fetchedPrimary = yield* pool.get(DesktopBackendPool.PRIMARY_INSTANCE_ID); + const fetchedWsl = yield* pool.get(DesktopBackendPool.BackendInstanceId("wsl:ubuntu")); + const fetchedMissing = yield* pool.get(DesktopBackendPool.BackendInstanceId("missing")); + const all = yield* pool.list; + const resolvedPrimary = yield* pool.primary; + + assert.equal(Option.getOrNull(fetchedPrimary)?.label, "Windows"); + assert.equal(Option.getOrNull(fetchedWsl)?.label, "WSL (Ubuntu)"); + assert.isTrue(Option.isNone(fetchedMissing)); + assert.lengthOf(all, 2); + // First instance becomes primary in layerTest so single-instance + // stubs don't have to wire an explicit primary. + assert.equal(resolvedPrimary.id, DesktopBackendPool.PRIMARY_INSTANCE_ID); + }).pipe( + Effect.provide( + DesktopBackendPool.layerTest([ + makeStubInstance(DesktopBackendPool.PRIMARY_INSTANCE_ID, "Windows"), + makeStubInstance(DesktopBackendPool.BackendInstanceId("wsl:ubuntu"), "WSL (Ubuntu)"), + ]), + ), + ), + ); + + it.effect("layerTest dies when no instances are supplied", () => + Effect.exit( + Effect.gen(function* () { + yield* DesktopBackendPool.DesktopBackendPool; + }).pipe(Effect.provide(DesktopBackendPool.layerTest([]))), + ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts new file mode 100644 index 00000000000..de581b72581 --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -0,0 +1,329 @@ +// Pool registry for multiple backend processes. This file is the entry +// point for the concurrent-Windows+WSL-backend feature; see the design +// notes below before extending it. +// +// Current state: +// - `DesktopBackendManager.ts` exposes a per-instance factory +// (`makeBackendInstance(spec)`); the pool calls it once for the +// Windows primary at startup, and `DesktopWslBackend.reconcile` +// calls it through `pool.register` to bring up the WSL instance +// when the user enables it. +// - The primary spec wires `configResolve` to +// `DesktopBackendConfiguration.resolvePrimary` and the +// `onReady`/`onShutdown` callbacks to the window service. WSL +// instances wire `configResolve: configuration.resolveWsl(...)` +// and skip onReady/onShutdown — the window only follows the primary. +// - The pool exposes `register(spec)` and `unregister(id)`. Each +// registered instance gets its own child scope, so unregister can +// stop it cleanly without tearing down the pool. The primary's id +// refuses unregister. +// - Settings: `wslBackendEnabled: boolean` + `wslDistro: string | null`. +// The legacy `wslMode: "local" | "wsl"` swap setting is migrated on +// load. IPC surface is `setWslBackendEnabled(boolean)` + +// `setWslDistro(string | null)`; both persist and then call the +// orchestrator's reconcile. No swap, no rollback, primary stays up. +// - `getLocalEnvironmentBootstraps()` (plural) returns one entry per +// pool instance currently registered with bootstrap info. The +// primary keeps the "primary" id; WSL instances are "wsl:default" +// or "wsl:". +// - `pickFolder` accepts an optional `targetEnvironmentId`. Omitting +// it gives the Windows picker — what every existing caller gets, +// and what non-WSL users see. WSL targets route to the wsl helpers. +// - Web settings UX: a plain toggle for "WSL backend" plus a distro +// picker that shows up when the toggle is on. Default-off, so +// users who never opted in see the same surface as before. +// +// Renderer-side wiring (apps/web/src/environments/local/): +// - reconcileLocalSecondaryEnvironments() runs at app boot and after +// WSL settings changes. It reads getLocalEnvironmentBootstraps(), +// skips the primary (which the existing primary/ runtime owns), +// and for every other entry POSTs the shared bootstrap token to +// /api/auth/bootstrap/bearer on that backend's URL, fetches the +// descriptor, builds a SavedEnvironmentRecord marked desktopLocal, +// writes the bearer to the secret store, and opens a connection +// through the same saved-env path remote envs use. +// - The desktopLocal marker filters records out of saved-env +// persistence, so toggling WSL off or switching distros doesn't +// pollute the user's settings file. The sidebar, CommandPalette, +// env switcher, and project-id routing all read the saved-env +// registry, so the WSL backend shows up there without any +// per-surface changes. +// +// Browser validation (2026-05-17, dev:desktop with wslBackendEnabled=true, +// wslDistro="Ubuntu"): +// - Two backends listening on distinct loopback ports +// (server.log: 13773 primary, 13774 wsl). +// - Per-instance log files: server-child.log + server-child-wsl_Ubuntu.log. +// - Distinct environment ids reported by each backend's +// /.well-known/t3/environment (Windows vs Linux platform). +// - Renderer completes the bearer-token bootstrap against the WSL +// backend (POST /api/auth/bootstrap/bearer 200), obtains a +// ws-token (POST /api/auth/ws-token 200), and holds an +// ESTABLISHED WebSocket connection to both ports (netstat). +// +// Migration history (commits): +// 1. Reshape `DesktopBackendManager` into an instance factory and route +// consumers through the pool. Pool held a single instance. (a8fc7845) +// 2. Drop `DesktopState.backendReady`. The window owns its own +// readiness latch via onReady / onShutdown callbacks. (425c7d0b) +// 3. Per-instance log routing via DesktopBackendOutputLogFactory. (563820ed) +// 4. Add register/unregister to the pool. (a0eaf560) +// 5. Wire WSL through the pool: settings rename, BackendConfiguration +// split, DesktopWslBackend orchestrator, new IPC, web compat. +// (b1622191 + 31ce3add + 627c80cb) +// 6. Widen getLocalEnvironmentBootstrap to *Bootstraps (plural). (bad66041) +// 7. pickFolder takes optional targetEnvironmentId. (5d80468d) +// 8. Settings UX: toggle + distro picker, no swap dialog. (eb5a03ea) +// 9. Register WSL backend as desktop-local saved env via +// reconcileLocalSecondaryEnvironments. (1c7e7873 + c17897bd) +// 10. CommandPalette enables file-manager picker for desktop-local +// envs, routes pickFolder by env id. (38e8477a) + +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as FileSystem from "effect/FileSystem"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +export type BackendInstanceId = DesktopBackendManager.BackendInstanceId; +export const BackendInstanceId = DesktopBackendManager.BackendInstanceId; +export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; +export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance; +export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec; + +// Caller tried to register an id that's already in the pool. The pool +// refuses overwrites so two independent orchestrators racing on the +// same id surface as a typed failure instead of one silently winning. +export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Data.TaggedError( + "DesktopBackendPoolInstanceAlreadyRegisteredError", +)<{ + readonly id: BackendInstanceId; +}> { + override get message() { + return `Backend instance "${this.id}" is already registered in the pool.`; + } +} + +// Primary instance is registered for the pool's lifetime. Unregister is +// a no-op for it today (no real callers), but if someone wires it up +// later it's a clear bug rather than something to "handle". +export class DesktopBackendPoolCannotUnregisterPrimaryError extends Data.TaggedError( + "DesktopBackendPoolCannotUnregisterPrimaryError", +)<{}> { + override get message() { + return "Refusing to unregister the primary backend from the pool."; + } +} + +export interface DesktopBackendPoolShape { + // Look up a registered instance. None when no backend with that id is + // currently registered (e.g. WSL backend disabled). + readonly get: (id: BackendInstanceId) => Effect.Effect>; + // Snapshot of all currently-registered instances. Order is unspecified; + // callers that need a canonical "primary first" view should sort by id. + readonly list: Effect.Effect; + // Convenience accessor for the always-registered primary instance. + // Currently equivalent to `get(PRIMARY_INSTANCE_ID)` unwrapped, but + // exposed as a typed effect so consumers don't have to handle the + // Option for the case that's guaranteed to be present. + readonly primary: Effect.Effect; + // Build a fresh DesktopBackendInstance from `spec` and add it to the + // registry. The pool owns the instance's scope: unregister(id) or pool + // teardown closes it and runs the instance's auto-stop finalizer. The + // returned instance has not been started — callers decide when to + // start it (and can call start more than once if a retry-after-failure + // story makes sense for them). + readonly register: ( + spec: BackendInstanceSpec, + ) => Effect.Effect; + // Stop the named instance and remove it from the registry. Closing the + // instance's scope triggers its auto-stop finalizer; the registry is + // updated atomically with the scope close so subsequent get(id) calls + // observe the unregister before the underlying child process has fully + // exited. + readonly unregister: ( + id: BackendInstanceId, + ) => Effect.Effect; +} + +export class DesktopBackendPool extends Context.Service< + DesktopBackendPool, + DesktopBackendPoolShape +>()("t3/desktop/BackendPool") {} + +// Services required by makeBackendInstance — exported so caller +// orchestrators that build their own specs can confirm the layer graph +// satisfies them at compile time. +export type BackendInstanceFactoryRequirements = + | FileSystem.FileSystem + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient + | DesktopObservability.DesktopBackendOutputLogFactory; + +interface RegisteredInstance { + readonly instance: DesktopBackendInstance; + // None for the primary (which lives in the pool's own layer scope and + // is never unregistered); Some for instances added via register, whose + // scope unregister closes to stop them. + readonly scope: Option.Option; +} + +export const layer = Layer.effect( + DesktopBackendPool, + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + // Anchor the pool's lifetime to its layer scope so registered + // instance scopes can be forked off it. Without this, instance + // scopes are orphaned: they only close via explicit unregister() + // calls, so on app shutdown the WSL backend child process gets + // hard-killed by the OS instead of receiving the graceful + // SIGTERM + grace period the instance's stop finalizer would + // otherwise run. + const layerScope = yield* Scope.Scope; + // Capture the services needed to build any future instance from the + // pool's layer scope. register() runs `makeBackendInstance` against + // a fresh child scope but reuses these services so the instance gets + // the same FileSystem, spawner, HTTP client and log factory the + // primary instance uses. + const factoryContext = yield* Effect.context(); + + // Primary label is captured once per process. When wsl-only mode + // is active, the primary's configResolve actually returns the WSL + // backend config (see DesktopBackendConfiguration.resolvePrimary), + // so the label needs to match. We don't try to react to runtime + // setting changes here: the wsl-only switch requires an app + // restart so the label reflects the mode the user is actually in. + const initialSettings = yield* settings.get; + const primaryLabel = + initialSettings.wslOnly && initialSettings.wslBackendEnabled + ? initialSettings.wslDistro + ? `WSL (${initialSettings.wslDistro})` + : "WSL" + : "Windows"; + + const primary = yield* DesktopBackendManager.makeBackendInstance({ + id: DesktopBackendManager.PRIMARY_INSTANCE_ID, + label: primaryLabel, + configResolve: configuration.resolvePrimary, + // Window creation errors propagating out of handleBackendReady are + // swallowed here on purpose: they're logged by the window service + // and we don't want a stuck splash window to block the readiness + // callback (which would prevent restartAttempt from being reset). + onReady: (httpBaseUrl) => + desktopWindow.handleBackendReady(httpBaseUrl).pipe(Effect.catch(() => Effect.void)), + onShutdown: () => desktopWindow.handleBackendNotReady, + }); + + const instancesRef = yield* SynchronizedRef.make< + ReadonlyMap + >( + new Map([ + [DesktopBackendManager.PRIMARY_INSTANCE_ID, { instance: primary, scope: Option.none() }], + ]), + ); + + const register: DesktopBackendPoolShape["register"] = (spec) => + SynchronizedRef.modifyEffect(instancesRef, (current) => { + if (current.has(spec.id)) { + return Effect.fail(new DesktopBackendPoolInstanceAlreadyRegisteredError({ id: spec.id })); + } + return Effect.gen(function* () { + // Forked from the pool's layer scope so the registered + // instance auto-stops on layer teardown. unregister() still + // closes the scope eagerly when invoked. + const instanceScope = yield* Scope.fork(layerScope, "sequential"); + const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( + Scope.provide(instanceScope), + Effect.provide(factoryContext), + ); + const next = new Map(current); + next.set(spec.id, { instance, scope: Option.some(instanceScope) }); + return [instance, next as ReadonlyMap] as const; + }); + }); + + const unregister: DesktopBackendPoolShape["unregister"] = (id) => + Effect.gen(function* () { + if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { + return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); + } + // modifyEffect atomically pulls the entry out of the registry + // and yields the scope handle; closing the scope below runs the + // instance's auto-stop finalizer. + const removed = yield* SynchronizedRef.modifyEffect(instancesRef, (current) => { + const entry = current.get(id); + if (entry === undefined) { + return Effect.succeed([Option.none(), current] as const); + } + const next = new Map(current); + next.delete(id); + return Effect.succeed([ + entry.scope, + next as ReadonlyMap, + ] as const); + }); + yield* Option.match(removed, { + onNone: () => Effect.void, + onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), + }); + }); + + return DesktopBackendPool.of({ + get: (id) => + SynchronizedRef.get(instancesRef).pipe( + Effect.map((instances) => Option.fromNullishOr(instances.get(id)?.instance)), + ), + list: SynchronizedRef.get(instancesRef).pipe( + Effect.map((instances) => Array.from(instances.values(), (entry) => entry.instance)), + ), + primary: Effect.succeed(primary), + register, + unregister, + }); + }), +); + +// Test layer for unit tests that want to assert against a known pool +// composition without standing up the full manager. Each provided +// instance is registered under its own id; the first one is also +// surfaced as `primary` so callers can stub a single-instance pool. +// `register` and `unregister` are stubbed to die so tests that +// accidentally exercise pool registration fail loudly instead of +// silently noop'ing. +export const layerTest = ( + instances: readonly DesktopBackendInstance[], +): Layer.Layer => + Layer.effect( + DesktopBackendPool, + Effect.gen(function* () { + if (instances.length === 0) { + return yield* Effect.die("DesktopBackendPool.layerTest requires at least one instance"); + } + const byId = new Map( + instances.map((instance) => [instance.id, instance] as const), + ); + const primary = instances[0]!; + return DesktopBackendPool.of({ + get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), + list: Effect.succeed(Array.from(byId.values())), + primary: Effect.succeed(primary), + register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), + unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), + }); + }), + ); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8717c877951..8a0a704f1b0 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -35,18 +35,19 @@ import { import { confirm, getAppBranding, - getLocalEnvironmentBootstrap, + getLocalEnvironmentBootstraps, openExternal, pickFolder, setTheme, showContextMenu, } from "./methods/window.ts"; +import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.gen(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* ipc.handleSync(getAppBranding); - yield* ipc.handleSync(getLocalEnvironmentBootstrap); + yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); @@ -70,6 +71,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setTailscaleServeEnabled); yield* ipc.handle(getAdvertisedEndpoints); + yield* ipc.handle(getWslState); + yield* ipc.handle(setWslBackendEnabled); + yield* ipc.handle(setWslDistro); + yield* ipc.handle(setWslOnly); + yield* ipc.handle(pickFolder); yield* ipc.handle(confirm); yield* ipc.handle(setTheme); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 2715b20cb36..43a9f742dcf 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -11,7 +11,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; -export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; @@ -32,4 +32,8 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; +export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"; +export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; +export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 1cb4d7265a1..fc9b3772922 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -4,13 +4,17 @@ import { DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, PickFolderOptionsSchema, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type DesktopEnvironmentBootstrap, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; @@ -18,6 +22,7 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; +import { resolveWslPickFolderDefaultPath } from "../../wsl/wslPathParsing.ts"; const ContextMenuPosition = Schema.Struct({ x: Schema.Number, @@ -44,26 +49,55 @@ export const getAppBranding = makeSyncIpcMethod({ }), }); -export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ - channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, - result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), - handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; - const config = yield* backendManager.currentConfig; - return Option.match(config, { - onNone: () => null, - onSome: ({ bootstrap, httpBaseUrl }) => ({ - label: "Local environment", +export const getLocalEnvironmentBootstraps = makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, + result: Schema.Array(DesktopEnvironmentBootstrapSchema), + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instances = yield* pool.list; + const bootstraps: DesktopEnvironmentBootstrap[] = []; + for (const instance of instances) { + const config = yield* instance.currentConfig; + // Skip instances that haven't produced a config yet (e.g. WSL + // backend mid-registration, before its first start cycle). They'll + // appear on the next IPC call once they've started. + if (Option.isNone(config)) continue; + // Skip instances whose preflight failed (e.g. WSL distro + // missing node, the linux server entry was never built). The + // backend manager schedules a restart instead of actually + // listening, so exposing the bootstrap would point the renderer + // at a port nothing is bound to and trigger needless + // /api/auth/bootstrap/bearer error cycles. + if (Option.isSome(config.value.preflightFailure)) continue; + const { bootstrap, httpBaseUrl } = config.value; + bootstraps.push({ + id: instance.id, + label: instance.label, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), ...(bootstrap.desktopBootstrapToken ? { bootstrapToken: bootstrap.desktopBootstrapToken } : {}), - }), - }); + }); + } + return bootstraps; }), }); +const WSL_INSTANCE_ID_PREFIX = "wsl:"; + +// Pull the distro selection out of a backend instance id like +// "wsl:ubuntu". Returns null for "wsl:default", which is the sentinel +// for "track the user's WSL default distro" and maps to the +// wslEnv-derived default at picker time. +function extractWslDistroFromEnvironmentId(envId: string): string | null { + if (!envId.startsWith(WSL_INSTANCE_ID_PREFIX)) { + return null; + } + const suffix = envId.slice(WSL_INSTANCE_ID_PREFIX.length); + return suffix === "default" || suffix.length === 0 ? null : suffix; +} + export const pickFolder = makeIpcMethod({ channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), @@ -72,9 +106,44 @@ export const pickFolder = makeIpcMethod({ const dialog = yield* ElectronDialog.ElectronDialog; const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + // Three picker modes: + // - targetEnvironmentId omitted: default to the primary picker. Keeps + // the historical behavior unchanged for users who never enabled the + // WSL backend, and is what unfamiliar callers should get out of the + // box. + // - targetEnvironmentId starts with "wsl:": route to the WSL picker + // using the distro encoded in the id (or the user's selected + // wslDistro when the id is the "wsl:default" sentinel). + // - anything else (incl. PRIMARY_LOCAL_ENVIRONMENT_ID): primary picker. + const targetId = options?.targetEnvironmentId; + const wslDistroFromTarget = + targetId !== undefined && targetId.startsWith(WSL_INSTANCE_ID_PREFIX) + ? extractWslDistroFromEnvironmentId(targetId) + : null; + const useWsl = + targetId !== undefined && + targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && + targetId.startsWith(WSL_INSTANCE_ID_PREFIX); + const settings = yield* appSettings.get; + // Fall back to the persisted wslDistro when the id is the + // "wsl:default" sentinel; the orchestrator uses the same fallback + // for the actual backend. + const wslDistro = useWsl ? (wslDistroFromTarget ?? settings.wslDistro) : null; + const defaultPath = useWsl + ? Option.fromNullishOr( + resolveWslPickFolderDefaultPath( + options, + { distro: wslDistro }, + yield* wslEnvironment.listDistros, + Option.getOrNull(yield* wslEnvironment.getUserHome(wslDistro)), + ), + ) + : environment.resolvePickFolderDefaultPath(options); const selectedPath = yield* dialog.pickFolder({ owner: yield* electronWindow.focusedMainOrFirst, - defaultPath: environment.resolvePickFolderDefaultPath(options), + defaultPath, }); return Option.getOrNull(selectedPath); }), diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts new file mode 100644 index 00000000000..75a103d0ef3 --- /dev/null +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -0,0 +1,91 @@ +import { DesktopWslStateSchema, type DesktopWslState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const readWslState: Effect.Effect< + DesktopWslState, + never, + DesktopAppSettings.DesktopAppSettings | DesktopWslEnvironment.DesktopWslEnvironment +> = Effect.gen(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* appSettings.get; + const available = yield* wslEnvironment.isAvailable; + // Only enumerate distros when WSL is actually available — listDistros on a + // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + const distros = available ? yield* wslEnvironment.listDistros : []; + return { + enabled: settings.wslBackendEnabled, + distro: settings.wslDistro, + available, + wslOnly: settings.wslOnly, + distros, + }; +}); + +export const getWslState = makeIpcMethod({ + channel: IpcChannels.GET_WSL_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.getState")(function* () { + return yield* readWslState; + }), +}); + +export const setWslBackendEnabled = makeIpcMethod({ + channel: IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, + payload: Schema.Boolean, + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.setEnabled")(function* (enabled) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + yield* appSettings.setWslBackendEnabled(enabled); + // Reconcile is idempotent and never fails; no need for a swap-style + // rollback when the WSL side has trouble coming up. With both + // backends running side by side, "WSL didn't start" is a transient + // state on one instance — the primary stays up either way. + yield* wslBackend.reconcile; + return yield* readWslState; + }), +}); + +export const setWslDistro = makeIpcMethod({ + channel: IpcChannels.SET_WSL_DISTRO_CHANNEL, + payload: Schema.NullOr(Schema.String), + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.setDistro")(function* (distro) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + yield* appSettings.setWslDistro(distro); + yield* wslBackend.reconcile; + return yield* readWslState; + }), +}); + +export const setWslOnly = makeIpcMethod({ + channel: IpcChannels.SET_WSL_ONLY_CHANNEL, + payload: Schema.Boolean, + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.setOnly")(function* (enabled) { + // wsl-only decides which backend the pool spins up as "primary", + // and that decision is captured once at layer init. After + // persisting the new value we relaunch so the user lands in the + // mode they just picked instead of having to close + reopen + // themselves. Same pattern as the server-exposure-mode change. + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setWslOnly(enabled); + const state = yield* readWslState; + if (change.changed) { + yield* lifecycle.relaunch(`wslOnly=${enabled}`); + } + return state; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0bc1badff2d..de52dce921c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,7 +29,7 @@ import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; -import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; @@ -44,6 +44,8 @@ import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -127,18 +129,31 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); -const desktopBackendLayer = DesktopBackendManager.layer.pipe( +// Pool layer instantiates the backend factory once for the Windows +// primary instance and exposes it via pool.primary. Consumers go through +// the pool now; the legacy DesktopBackendManager service is gone. The +// WSL second instance gets registered later in the migration. See +// DesktopBackendPool.ts header for the full rollout plan. +const desktopBackendLayer = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), + Layer.provideMerge(DesktopWslEnvironment.layer), Layer.provideMerge(desktopWindowLayer), ); +// WSL orchestrator hangs off the backend layer because it needs the +// pool + configuration + serverExposure; it pulls NetService and the +// foundation services through the same provideMerge chain. +const desktopWslBackendLayer = DesktopWslBackend.layer.pipe( + Layer.provideMerge(desktopBackendLayer), +); + const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, DesktopShellEnvironment.layer, desktopSshLayer, -).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); +).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer)); const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( Layer.flatMap(() => diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 173be8fb54a..ed47adcc37e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -27,12 +27,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, - getLocalEnvironmentBootstrap: () => { - const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); - if (typeof result !== "object" || result === null) { - return null; + getLocalEnvironmentBootstraps: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); + if (!Array.isArray(result)) { + return []; } - return result as ReturnType; + return result as ReturnType; }, getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => @@ -87,6 +87,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { setTailscaleServeEnabled: (input) => ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), + getWslState: () => ipcRenderer.invoke(IpcChannels.GET_WSL_STATE_CHANNEL), + setWslBackendEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, enabled), + setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), + setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f7..fee2bd32ef9 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -21,6 +21,9 @@ const DesktopSettingsPatch = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), + wslBackendEnabled: Schema.optionalKey(Schema.Boolean), + wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), + wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), }); const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); @@ -95,6 +98,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopSettingsValue); }); @@ -116,6 +122,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopSettingsValue); const exposure = yield* settings.setServerExposureMode("local-only"); @@ -195,6 +204,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopSettingsValue); }), ), @@ -234,6 +246,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopSettingsValue); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -256,6 +271,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopSettingsValue); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -277,8 +295,66 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopSettingsValue); }), ), ); + + it.effect("persists wsl backend toggle and normalizes invalid distro names", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const enable = yield* settings.setWslBackendEnabled(true); + assert.isTrue(enable.changed); + assert.equal(enable.settings.wslBackendEnabled, true); + + const distro = yield* settings.setWslDistro("Ubuntu-22.04"); + assert.isTrue(distro.changed); + assert.equal(distro.settings.wslDistro, "Ubuntu-22.04"); + + const reloaded = yield* settings.load; + assert.equal(reloaded.wslBackendEnabled, true); + assert.equal(reloaded.wslDistro, "Ubuntu-22.04"); + + const reject = yield* settings.setWslDistro("bad name!"); + assert.equal(reject.settings.wslDistro, null); + + const noop = yield* settings.setWslDistro(null); + assert.isFalse(noop.changed); + }), + ), + ); + + it.effect("migrates legacy wslMode=wsl to wslBackendEnabled on load", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + wslMode: "wsl", + wslDistro: "Ubuntu-22.04", + }); + const loaded = yield* settings.load; + assert.equal(loaded.wslBackendEnabled, true); + assert.equal(loaded.wslDistro, "Ubuntu-22.04"); + }), + ), + ); + + it.effect("drops invalid persisted wsl distro values on load", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + wslBackendEnabled: true, + wslDistro: "bad/name", + }); + const loaded = yield* settings.load; + assert.equal(loaded.wslBackendEnabled, true); + assert.equal(loaded.wslDistro, null); + }), + ), + ); }); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 177f05a4b2b..c40c22ffba0 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -19,6 +19,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; +import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; @@ -26,6 +27,21 @@ export interface DesktopSettings { readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; + // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL + // integration. We now run Windows and WSL backends side by side, so the + // setting is just whether the WSL backend should be running alongside the + // primary. Persisted documents that still carry the legacy `wslMode: "wsl"` + // value are migrated to `wslBackendEnabled: true` on load. + readonly wslBackendEnabled: boolean; + readonly wslDistro: string | null; + // When true (and wslBackendEnabled is also true) the desktop runs only + // the WSL backend as the primary, and the Windows-side Node backend is + // not started. Designed for users who develop entirely inside WSL and + // don't want a second backend process running. Defaults to false so + // existing setups stay on the parallel-backends behavior. Changing + // this requires a desktop restart because the pool's primary spec is + // chosen once at layer init. + readonly wslOnly: boolean; } export interface DesktopSettingsChange { @@ -41,6 +57,9 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslDistro: null, + wslOnly: false, }; const DesktopSettingsDocument = Schema.Struct({ @@ -49,6 +68,13 @@ const DesktopSettingsDocument = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), + // Newer form of the WSL toggle. `wslMode` is still accepted on load so + // existing on-disk settings keep working; on the next persist we write the + // new boolean and the legacy key drops out. + wslBackendEnabled: Schema.optionalKey(Schema.Boolean), + wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), + wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), + wslOnly: Schema.optionalKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -84,6 +110,15 @@ export interface DesktopAppSettingsShape { readonly setUpdateChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; + readonly setWslBackendEnabled: ( + enabled: boolean, + ) => Effect.Effect; + readonly setWslDistro: ( + distro: string | null, + ) => Effect.Effect; + readonly setWslOnly: ( + enabled: boolean, + ) => Effect.Effect; } export class DesktopAppSettings extends Context.Service< @@ -104,6 +139,10 @@ function normalizeTailscaleServePort(value: unknown): number { : DEFAULT_TAILSCALE_SERVE_PORT; } +function normalizeWslDistro(value: unknown): string | null { + return typeof value === "string" && isValidDistroName(value) ? value : null; +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, @@ -115,6 +154,13 @@ function normalizeDesktopSettingsDocument( parsed.updateChannelConfiguredByUser === true || (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); + // Newer form wins when both are present; otherwise fall back to the legacy + // `wslMode === "wsl"` signal so users coming off the swap-mode build keep + // their WSL backend enabled. + const wslBackendEnabled = + parsed.wslBackendEnabled === true || + (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); + return { serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", @@ -124,6 +170,9 @@ function normalizeDesktopSettingsDocument( ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) : defaultSettings.updateChannel, updateChannelConfiguredByUser, + wslBackendEnabled, + wslDistro: normalizeWslDistro(parsed.wslDistro), + wslOnly: parsed.wslOnly === true, }; } @@ -148,6 +197,15 @@ function toDesktopSettingsDocument( if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; } + if (settings.wslBackendEnabled !== defaults.wslBackendEnabled) { + document.wslBackendEnabled = settings.wslBackendEnabled; + } + if (settings.wslDistro !== defaults.wslDistro) { + document.wslDistro = settings.wslDistro; + } + if (settings.wslOnly !== defaults.wslOnly) { + document.wslOnly = settings.wslOnly; + } return document; } @@ -194,6 +252,34 @@ function setUpdateChannel( }; } +function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.wslBackendEnabled === enabled + ? settings + : { + ...settings, + wslBackendEnabled: enabled, + }; +} + +function setWslDistro(settings: DesktopSettings, distro: string | null): DesktopSettings { + const normalized = normalizeWslDistro(distro); + return settings.wslDistro === normalized + ? settings + : { + ...settings, + wslDistro: normalized, + }; +} + +function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.wslOnly === enabled + ? settings + : { + ...settings, + wslOnly: enabled, + }; +} + function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, @@ -285,6 +371,20 @@ export const layer = Layer.effect( persist((settings) => setUpdateChannel(settings, channel)).pipe( Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), ), + setWslBackendEnabled: (enabled) => + persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), + ), + setWslDistro: (distro) => + persist((settings) => setWslDistro(settings, distro)).pipe( + Effect.withSpan("desktop.settings.setWslDistro", { + attributes: { distro: distro ?? null }, + }), + ), + setWslOnly: (enabled) => + persist((settings) => setWslOnly(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), + ), }); }), ); @@ -313,6 +413,10 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), + setWslBackendEnabled: (enabled) => + update((settings) => setWslBackendEnabled(settings, enabled)), + setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), + setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), }); }), ); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 34d18f11a77..d73235abf9c 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -10,7 +10,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; @@ -101,7 +101,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindowShape); - const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { + const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", start: Effect.void, stop: () => Effect.void, currentConfig: Effect.succeed(Option.none()), @@ -112,7 +114,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { restartAttempt: 0, restartScheduled: false, }), - }); + waitForReady: () => Effect.succeed(true), + }; + const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 8c0acd2e8a6..23ce85ce3c0 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -18,7 +18,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; @@ -187,7 +187,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const pool = yield* DesktopBackendPool.DesktopBackendPool; const desktopState = yield* DesktopState.DesktopState; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -368,7 +368,19 @@ const make = Effect.gen(function* () { yield* Ref.set(updateInstallInFlightRef, true); return yield* Effect.gen(function* () { - yield* backendManager.stop({ timeout: Duration.seconds(5) }); + // Stop every backend in the pool, not just the primary. With + // parallel WSL + Windows backends, leaving the WSL instance up + // means quitAndInstall's app.quit() exits before the pool's + // scope cascade has a chance to run its stop finalizer, so the + // WSL child gets hard-killed by the OS instead of receiving + // SIGTERM + grace. Stops run concurrently with the same 5s + // budget the primary had on its own. + const instances = yield* pool.list; + yield* Effect.forEach( + instances, + (instance) => instance.stop({ timeout: Duration.seconds(5) }), + { concurrency: "unbounded" }, + ); yield* electronWindow.destroyAll; yield* electronUpdater.quitAndInstall({ isSilent: true, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index fc589b3e39b..ec881f03df0 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -70,7 +70,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), activate: Effect.void, createMainIfBackendReady: Effect.void, - handleBackendReady: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindowShape); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index fbcc60934aa..cccd6e0eff6 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -11,7 +11,6 @@ import { vi } from "vitest"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -143,7 +142,6 @@ function makeTestLayer(input: { desktopAssetsLayer, desktopEnvironmentLayer, desktopServerExposureLayer, - DesktopState.layer, electronMenuLayer, electronShellLayer, electronThemeLayer, @@ -170,7 +168,7 @@ describe("DesktopWindow", () => { yield* desktopWindow.activate; assert.equal(yield* Ref.get(createCount), 0); - yield* desktopWindow.handleBackendReady; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); assert.equal(yield* Ref.get(createCount), 1); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 8ebd1041c6b..81cb2aac17e 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -10,7 +10,6 @@ import type * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; -import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -32,7 +31,6 @@ type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets | DesktopServerExposure.DesktopServerExposure - | DesktopState.DesktopState | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -56,7 +54,19 @@ export interface DesktopWindowShape { readonly revealOrCreateMain: Effect.Effect; readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; - readonly handleBackendReady: Effect.Effect; + // The pool tells us not just "primary backend is ready" but also + // *where* the renderer should load from. In wsl-only mode that's the + // WSL distro IP (e.g. http://172.27.152.141:3773), not the local + // exposure URL — wslhost localhost forwarding is unreliable enough + // that pointing loadURL at 127.0.0.1 breaks the renderer on hosts + // where the forward isn't set up. The Windows-primary path passes + // the same URL serverExposure would have given us. + readonly handleBackendReady: (httpBaseUrl: URL) => Effect.Effect; + // Called when the backend transitions back to "not ready" (clean stop, + // restart, crash). Clears the latch that lets `activate` auto-create a + // window so a "macOS dock click" while the backend is down doesn't + // produce a stranded window pointing at nothing. + readonly handleBackendNotReady: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -152,7 +162,17 @@ const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const state = yield* DesktopState.DesktopState; + // Window-side latch for the primary backend's readiness. Set by + // handleBackendReady (driven by the pool's onReady callback), cleared + // by handleBackendNotReady (driven by onShutdown). Only consumed by + // createMainIfBackendReady, which gates the post-readiness window + // open in development and the macOS "activate without windows" path. + const backendReadyRef = yield* Ref.make(false); + // Renderer URL the primary backend told us to load. Populated by + // handleBackendReady. createMain prefers this over serverExposure's + // backendConfig because in wsl-only mode the primary doesn't bind on + // the local exposure URL — the WSL backend listens on the distro IP. + const backendHttpUrlRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); @@ -291,8 +311,11 @@ const make = Effect.gen(function* () { }); const createMain = Effect.gen(function* () { - const backendConfig = yield* serverExposure.backendConfig; - const window = yield* createWindow(backendConfig.httpBaseUrl); + const reportedHttpUrl = yield* Ref.get(backendHttpUrlRef); + const httpUrl = Option.isSome(reportedHttpUrl) + ? reportedHttpUrl.value + : (yield* serverExposure.backendConfig).httpBaseUrl; + const window = yield* createWindow(httpUrl); yield* electronWindow.setMain(window); yield* logWindowInfo("main window created"); return window; @@ -313,7 +336,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); const createMainIfBackendReady = Effect.gen(function* () { - const backendReady = yield* Ref.get(state.backendReady); + const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) return; const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) return; @@ -333,11 +356,16 @@ const make = Effect.gen(function* () { } }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, - handleBackendReady: Effect.gen(function* () { - yield* Ref.set(state.backendReady, true); - yield* logWindowInfo("backend ready", { source: "http" }); + handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { + yield* Ref.set(backendHttpUrlRef, Option.some(httpBaseUrl)); + yield* Ref.set(backendReadyRef, true); + yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); yield* createMainIfBackendReady; - }).pipe(Effect.withSpan("desktop.window.handleBackendReady")), + }), + handleBackendNotReady: Effect.gen(function* () { + yield* Ref.set(backendReadyRef, false); + yield* Ref.set(backendHttpUrlRef, Option.none()); + }).pipe(Effect.withSpan("desktop.window.handleBackendNotReady")), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); const existingWindow = yield* electronWindow.focusedMainOrFirst; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts new file mode 100644 index 00000000000..bd0972f83b9 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -0,0 +1,214 @@ +// Orchestrator that keeps the WSL pool instance in sync with the user's +// settings. `reconcile` is the single entry point — bootstrap calls it +// once after the primary backend starts, and the wsl.ts IPC calls it +// after persisting a `wslBackendEnabled` or `wslDistro` change. The +// effect is idempotent and never fails: errors (WSL not available, port +// allocation failed, register failed) get logged and reconcile returns +// having left the pool in a consistent state (either the previous WSL +// instance is still running, or none is). +// +// The instance id encodes the desired distro selection — `wsl:default` +// when the user picked "track the WSL default" (settings.wslDistro is +// null) and `wsl:` otherwise. Changing the distro setting +// changes the id, so reconcile unregisters the old instance before +// registering the new one. The label that the frontend env switcher +// renders is derived from the same field. +// +// Port allocation: each WSL instance gets a freshly scanned port to +// 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. + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; + +import * as NetService from "@t3tools/shared/Net"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; + +const WSL_INSTANCE_ID_PREFIX = "wsl:"; +const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; +const MAX_TCP_PORT = 65_535; + +export interface DesktopWslBackendShape { + // Bring the pool in line with the current persisted WSL settings. + // Idempotent. Never fails (errors are logged); callers can chain it + // after persisting settings without an error-handling dance. + readonly reconcile: Effect.Effect; +} + +export class DesktopWslBackend extends Context.Service()( + "t3/desktop/WslBackend", +) {} + +const { logInfo: logWslBackendInfo, logWarning: logWslBackendWarning } = + DesktopObservability.makeComponentLogger("desktop-wsl-backend"); + +const resolveTargetInstanceId = (distro: string | null): DesktopBackendPool.BackendInstanceId => + DesktopBackendPool.BackendInstanceId( + distro === null ? WSL_DEFAULT_DISTRO_ID : `${WSL_INSTANCE_ID_PREFIX}${distro}`, + ); + +const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean => + id.startsWith(WSL_INSTANCE_ID_PREFIX); + +const buildLabel = (distro: string | null): string => + distro === null ? "WSL (default distro)" : `WSL (${distro})`; + +// 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. +const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( + startPort: number, +): Effect.fn.Return { + const net = yield* NetService.NetService; + for (let port = startPort; port <= MAX_TCP_PORT; port += 1) { + if (yield* net.canListenOnHost(port, "127.0.0.1")) { + return port; + } + } + return yield* new NetService.NetError({ + message: `No loopback port available for WSL backend between ${startPort} and ${MAX_TCP_PORT}.`, + }); +}); + +export const layer = Layer.effect( + DesktopWslBackend, + Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const net = yield* NetService.NetService; + // Serialize reconcile so the bootstrap fork and the IPC handlers + // (setWslBackendEnabled, setWslDistro) can't interleave. Without + // this, two reconciles could both observe "no WSL instance + // registered" between their pool reads and both call startNew + // with different distros, leaving the loser stranded. + const reconcileMutex = yield* Semaphore.make(1); + + const findExistingWslInstance = pool.list.pipe( + Effect.map((instances) => instances.find((instance) => isWslInstanceId(instance.id))), + Effect.map(Option.fromNullishOr), + ); + + const stopExisting = (id: DesktopBackendPool.BackendInstanceId) => + pool.unregister(id).pipe( + Effect.catchTag("DesktopBackendPoolCannotUnregisterPrimaryError", (cause) => + // Should never happen — wsl: ids are not the primary id — but + // log loudly if the logic ever drifts. + logWslBackendWarning("refusing to unregister primary as wsl instance", { + id, + error: cause.message, + }), + ), + ); + + const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: { + readonly distro: string | null; + }) { + const primaryConfig = yield* serverExposure.backendConfig; + const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( + Effect.provideService(NetService.NetService, net), + Effect.map((value) => Option.some(value)), + Effect.catch((error) => + logWslBackendWarning("could not allocate port for WSL backend", { + error: error.message, + }).pipe(Effect.as(Option.none())), + ), + ); + + if (Option.isNone(port)) { + return; + } + const allocatedPort = port.value; + + const targetId = resolveTargetInstanceId(input.distro); + yield* logWslBackendInfo("registering WSL backend with pool", { + id: targetId, + port: allocatedPort, + distro: input.distro ?? null, + }); + + const instance = yield* pool + .register({ + id: targetId, + label: buildLabel(input.distro), + configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), + }) + .pipe( + Effect.map((registered) => Option.some(registered)), + Effect.catch((error) => + logWslBackendWarning("WSL backend already registered, skipping start", { + id: targetId, + error: error.message, + }).pipe(Effect.as(Option.none())), + ), + ); + + yield* Option.match(instance, { + onNone: () => Effect.void, + onSome: (registered) => registered.start, + }); + }); + + const reconcileBody = Effect.gen(function* () { + const settings = yield* appSettings.get; + const available = yield* wslEnvironment.isAvailable; + const existing = yield* findExistingWslInstance; + const existingId = Option.map(existing, (instance) => instance.id); + + // In wsl-only mode the pool's primary IS the WSL backend (see + // DesktopBackendConfiguration.resolvePrimary), so the + // orchestrator skips registering a parallel "wsl:" + // secondary. Without this skip we'd spin up two WSL processes + // on the same distro for users who explicitly asked for one. + const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly; + const targetId = shouldRun + ? Option.some(resolveTargetInstanceId(settings.wslDistro)) + : Option.none(); + + // No-op if the desired state already matches what's registered. + if (Option.isNone(targetId) && Option.isNone(existingId)) { + return; + } + if ( + Option.isSome(targetId) && + Option.isSome(existingId) && + targetId.value === existingId.value + ) { + return; + } + + if (Option.isSome(existingId)) { + yield* logWslBackendInfo("tearing down WSL backend", { id: existingId.value }); + yield* stopExisting(existingId.value); + } + + if (Option.isSome(targetId)) { + // Pre-warm the WSL VM before registering so the readiness probe + // doesn't race wsl.exe's first-spawn cold start. preWarm tolerates + // distro=null (uses the WSL default) and is bounded by its own + // timeout, so it's safe to await unconditionally here. + yield* wslEnvironment.preWarm(settings.wslDistro); + yield* startNew({ distro: settings.wslDistro }); + } + }); + + const reconcile = reconcileMutex + .withPermits(1)(reconcileBody) + .pipe(Effect.withSpan("desktop.wslBackend.reconcile")); + + return DesktopWslBackend.of({ reconcile }); + }), +); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts new file mode 100644 index 00000000000..9a881318ad2 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; + +import { formatMissingToolsReason, parseToolchainReport } from "./DesktopWslEnvironment.ts"; + +describe("parseToolchainReport", () => { + it("returns no missing tools and no node version on empty output", () => { + expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); + }); + + it("collects all missing: lines", () => { + const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); + expect(parseToolchainReport(stdout)).toEqual({ + missingTools: ["make", "g++"], + nodeVersion: "24.10.0", + }); + }); + + it("ignores blank lines and trims whitespace", () => { + const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); + expect(parseToolchainReport(stdout)).toEqual({ + missingTools: ["python3"], + nodeVersion: "v22.16.0", + }); + }); + + it("returns null node version when value after prefix is empty", () => { + expect(parseToolchainReport("nodeVersion:")).toEqual({ + missingTools: [], + nodeVersion: null, + }); + }); +}); + +describe("formatMissingToolsReason", () => { + it("returns null when everything is present and node is in range", () => { + expect( + formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), + ).toBeNull(); + }); + + it("returns null when range is not specified and tools are present", () => { + expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); + }); + + it("flags missing node first", () => { + const reason = formatMissingToolsReason( + { missingTools: ["node", "make"], nodeVersion: null }, + "^24.10", + ); + expect(reason).toContain("node"); + expect(reason).toContain("^24.10"); + expect(reason).toContain("make"); + expect(reason).toContain("nvm"); + }); + + it("flags an out-of-range node version with the actual version surfaced", () => { + const reason = formatMissingToolsReason( + { missingTools: [], nodeVersion: "20.0.0" }, + "^24.10 || ^22.16", + ); + expect(reason).toContain("node 20.0.0"); + expect(reason).toContain("requires ^24.10 || ^22.16"); + }); + + it("flags missing build tools without node when node is fine", () => { + const reason = formatMissingToolsReason( + { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, + "^24.10", + ); + expect(reason).toContain("g++"); + expect(reason).toContain("python3"); + expect(reason).toContain("build-essential"); + expect(reason).not.toContain("nvm"); + }); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts new file mode 100644 index 00000000000..924f0a6720b --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -0,0 +1,578 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { satisfiesSemverRange } from "@t3tools/shared/semver"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; + +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); +const LIST_TIMEOUT = Duration.seconds(8); +const PRE_WARM_TIMEOUT = Duration.seconds(10); +const WSLPATH_TIMEOUT = Duration.seconds(10); +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); + +export interface EnsureWslNodePtyOptions { + readonly allowBuild?: boolean; + readonly nodeEngineRange?: string | null; +} + +export type EnsureWslNodePtyResult = + | { readonly ok: true } + | { readonly ok: false; readonly reason: string }; + +export interface DesktopWslEnvironmentShape { + readonly isAvailable: Effect.Effect; + readonly listDistros: Effect.Effect; + readonly preWarm: (distro: string | null) => Effect.Effect; + readonly windowsToWslPath: ( + distro: string | null, + windowsPath: string, + ) => Effect.Effect>; + // Resolves the user's Linux home dir inside the chosen distro (e.g. + // "/home/josh"). Used by the folder picker to expand `~` correctly. + 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 + // 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). + readonly getDistroIp: (distro: string | null) => Effect.Effect>; + readonly ensureNodePty: ( + distro: string | null, + windowsRepoRoot: string, + options?: EnsureWslNodePtyOptions, + ) => Effect.Effect; +} + +export class DesktopWslEnvironment extends Context.Service< + DesktopWslEnvironment, + DesktopWslEnvironmentShape +>()("t3/desktop/WslEnvironment") {} + +const buildDistroArgs = (distro: string | null): ReadonlyArray => + distro ? ["-d", distro] : []; + +const concatChunks = (arrays: ReadonlyArray): Uint8Array => { + let totalLength = 0; + for (const arr of arrays) totalLength += arr.byteLength; + const out = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + out.set(arr, offset); + offset += arr.byteLength; + } + return out; +}; + +const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); + +interface ShellResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +const TIMEOUT_RESULT: ShellResult = { exitCode: 124, stdout: "", stderr: "\n[timeout]" }; + +// wsl.exe re-escapes args before forwarding them to the Linux side, which +// mangles quotes inside `bash -lc "