diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 052a25e4b97..a5e5ebce5c3 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -13,7 +13,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopCloudAuth from "./DesktopCloudAuth.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"; @@ -22,6 +22,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; @@ -132,11 +133,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)) { @@ -180,8 +183,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")); @@ -230,10 +238,20 @@ const scopedProgram = Effect.scoped( yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); const shutdown = yield* DesktopLifecycle.DesktopShutdown; - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; yield* Effect.addFinalizer(() => - backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), + Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + // 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 2349fe52dc3..7865b0970f2 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,3 +1,4 @@ +import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; @@ -13,7 +14,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 +43,23 @@ export interface DesktopBackendOutputLogShape { ) => Effect.Effect; } -export class DesktopBackendOutputLog extends Context.Service< - DesktopBackendOutputLog, - DesktopBackendOutputLogShape ->()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLog") {} +// 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 +>()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLogFactory") {} const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -293,22 +309,51 @@ 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_LOCAL_ENVIRONMENT_ID; + +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 writer = yield* makeRotatingLogFileWriter({ - filePath: environment.path.join(environment.logDir, "server-child.log"), - }).pipe(Effect.option); - - return Option.match(writer, { - onNone: () => DesktopBackendOutputLogNoop, - onSome: (logFile) => - ({ - writeSessionBoundary: Effect.fn( - "desktop.observability.backendOutput.writeSessionBoundary", - )(function* ({ phase, details }) { +// Just the IO sink. Cacheable by resolved file path so two ids that +// sanitize to the same filename share a single RotatingLogFileWriter +// (no race on currentSize tracking). Splitting the sink off from the +// per-call shape lets the shape annotate writes with the *caller's* +// id rather than whatever id created the cached writer first. +const makeBackendOutputSinkForInstance = ( + environment: DesktopEnvironment.DesktopEnvironmentShape, + id: string, +): Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | Path.Path | Scope.Scope +> => + makeRotatingLogFileWriter({ + filePath: backendLogFilePathForInstance(environment, id), + }).pipe(Effect.option); + +const makeBackendOutputLogShape = ( + environment: DesktopEnvironment.DesktopEnvironmentShape, + id: string, + sink: Option.Option, +): DesktopBackendOutputLogShape => + Option.match(sink, { + onNone: () => DesktopBackendOutputLogNoop, + onSome: (logFile) => + ({ + writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( + function* ({ phase, details }) { const runId = yield* currentDesktopRunId; yield* writeBackendChildLogRecord(logFile, { message: `backend child process session ${phase.toLowerCase()}`, @@ -316,30 +361,81 @@ const backendOutputLogLayer = Layer.effect( annotations: { component: "desktop-backend-child", runId, + instanceId: id, phase, details: sanitizeLogValue(details), }, }); + }, + ), + writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( + function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId, + instanceId: id, + stream: streamName, + text: textDecoder.decode(chunk), + }, + }); + }, + ), + }) 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-file-path cache of the IO sink only. The per-call shape + // wraps the sink with the caller's instance id so a cache hit on + // a path collision (e.g. "wsl:default" and "wsl_default" both + // resolve to server-child-wsl_default.log) doesn't attribute the + // second caller's writes to the first caller's id. Each sink 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 file path. + const cacheRef = yield* SynchronizedRef.make< + ReadonlyMap> + >(new Map()); + + const makeForId = (id: string): Effect.Effect => + SynchronizedRef.modifyEffect(cacheRef, (cache) => { + const cacheKey = backendLogFilePathForInstance(environment, id); + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return Effect.succeed([ + makeBackendOutputLogShape(environment, id, cached), + cache, + ] as const); + } + return makeBackendOutputSinkForInstance(environment, id).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Scope.provide(factoryScope), + Effect.map((sink) => { + const next = new Map(cache); + next.set(cacheKey, sink); + return [ + makeBackendOutputLogShape(environment, id, sink), + next as ReadonlyMap>, + ] as const; }), - writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( - function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - const runId = yield* currentDesktopRunId; - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId, - stream: streamName, - text: textDecoder.decode(chunk), - }, - }); - }, - ), - }) satisfies DesktopBackendOutputLogShape, + ); + }); + + return DesktopBackendOutputLogFactory.of({ + forInstance: (id) => makeForId(id), }); }), ); @@ -387,7 +483,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 f325c99d229..5fbfe7c8540 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 +103,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 +112,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 +143,40 @@ 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 and resolveWsl share one token under concurrent resolution", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + // Resolve both before any token is cached, concurrently, so the + // generate step (a yield point) can interleave. The atomic + // get-or-create must still hand both the same token; a non-atomic + // Ref would let each generate its own and break the shared-token + // invariant. + const [primary, wsl] = yield* Effect.all( + [configuration.resolvePrimary, configuration.resolveWsl({ port: 5000, distro: null })], + { concurrency: "unbounded" }, + ); + + 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 +196,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 +215,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 +224,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 +243,219 @@ 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"); + // The existing WSLENV is preserved byte-for-byte (note the empty + // "::" segment survives — WSL ignores it, so we don't normalize + // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is + // already declared, so it isn't forwarded twice. + 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)), + ); + + it.effect( + "resolvePrimary falls back to the Windows primary when wsl-only but WSL is unavailable", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolvePrimary; + + // wsl-only is persisted but WSL is unavailable, so the primary must + // not spawn wsl.exe (which would loop on preflight failures while the + // Connections backend control is hidden). Resolve the Windows primary. + assert.equal(config.executablePath, process.execPath); + assert.equal(config.bootstrap.t3Home, environment.baseDir); + assert.isTrue(Option.isNone(config.preflightFailure)); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolvePrimaryLabel reports the WSL distro when wsl-only and WSL is available", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const label = yield* configuration.resolvePrimaryLabel; + assert.equal(label, "WSL (Ubuntu)"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + wslDistro: "Ubuntu", + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const label = yield* configuration.resolvePrimaryLabel; + assert.equal(label, "Local environment"); + }), + ), + ); + + it.effect("resolvePrimaryLabel reports Windows when wsl-only but WSL is unavailable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + // Mirrors the resolvePrimary fall-back: the label must follow the + // backend that actually resolves, not the persisted preference, so the + // env switcher can't show "WSL" for a Windows backend. + const label = yield* configuration.resolvePrimaryLabel; + assert.equal(label, "Windows"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + wslDistro: "Ubuntu", + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { + // getLocalEnvironmentBootstraps is a sync IPC method: it resolves the + // primary instance's lazy label through Effect.runSync. The label chains + // to wslEnvironment.isAvailable, whose real layer probes the filesystem. + // That probe must run once at layer build and expose a resolved value, not + // a live async effect — otherwise runSync throws in the handler. Build the + // real WSL layer (not the sync test stub) and resolve the label with a + // top-level runSync, exactly as the handler does. + const runtime = ManagedRuntime.make( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layer), + // isAvailable on win32 only touches the filesystem, never the spawner, + // so a die-stub is enough to satisfy the layer's deps. + Layer.provideMerge( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.die("spawner should not be used while probing WSL availability"), + ), + ), + ), + Layer.provideMerge(makeEnvironmentLayer("/tmp/t3-wsl-isavailable", { platform: "win32" })), + Layer.provide(NodeServices.layer), + ), + ); + try { + const configuration = await runtime.runPromise( + DesktopBackendConfiguration.DesktopBackendConfiguration, + ); + const label = Effect.runSync(configuration.resolvePrimaryLabel); + assert.equal(typeof label, "string"); + } finally { + await runtime.dispose(); + } + }); }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 5e4e034b5e7..a99420e4074 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 Crypto from "effect/Crypto"; @@ -7,18 +9,40 @@ 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"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +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. Can fail + // with PlatformError because bootstrap token generation now uses + // crypto.randomBytes under the hood (post Effect 4 migration). + readonly resolvePrimary: Effect.Effect< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError >; + // 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; + // The renderer-facing label for the primary instance, derived from the + // same decision resolvePrimary makes (including the WSL-availability + // fall-back to Windows), so the env switcher can't show "WSL" for a + // backend that actually resolved to Windows. + readonly resolvePrimaryLabel: Effect.Effect; } export class DesktopBackendConfiguration extends Context.Service< @@ -49,9 +73,46 @@ 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 existing = existingWslEnv?.trim() ?? ""; + + // Names already declared, so we don't forward a duplicate. We parse the + // existing value only for this membership test — the string itself is + // preserved verbatim below rather than re-serialized. + const seenNames = new Set( + existing + .split(":") + .map((entry) => getWslEnvEntryName(entry.trim())) + .filter((name) => name.length > 0), + ); + + const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); + + // Preserve the user's WSLENV exactly as Windows handed it to us — empty + // "::" segments and duplicate entries are harmless no-ops to WSL and not + // ours to normalize — and only append the secrets we need to forward + // across the wsl.exe boundary. + const parts = [existing, ...additions].filter((part) => part.length > 0); + return parts.length > 0 ? parts.join(":") : undefined; +}; + const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( "desktop-backend-configuration", ); @@ -85,11 +146,128 @@ const readPersistedBackendObservabilitySettings: Effect.Effect< }; }); -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; + // Absolute path to the node binary the preflight validated after the shared + // remote resolver repaired PATH. The launch must use this exact path so it + // doesn't fall through to a different/old node than the one node-pty was + // built against. + readonly nodePath: string; +} + +interface WslPreflightFailure { + readonly _tag: "Failed"; + readonly reason: string; + // Fatal: the WSL distro is misconfigured (no node, wrong version, missing + // build tools) and retrying won't help — surface it and (wsl-only) fall back + // to Windows. Non-fatal: transient (WSL not ready yet, wslpath while it + // boots) so the manager keeps retrying until it self-heals. + readonly fatal: boolean; +} + +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", + fatal: false, + } 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}`, + fatal: false, + } 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}`, + fatal: false, + } 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}`, + fatal: true, + } as const; + } + + return { + _tag: "Ready", + linuxEntryPath: linuxEntry.value, + nodePath: nodePtyResult.nodePath, + } 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) { + // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on + // the Node build Electron ships (41 / Node 22, verified), but some Node + // builds report the numeric 4. Normalize to a string so a future runtime + // bump can't silently break mirrored-mode detection and leave the + // renderer pointed at the distro IP instead of loopback. + const family = String(entry.family); + if ((family === "IPv4" || family === "4") && 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 @@ -98,72 +276,318 @@ 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({ reason: preflight.reason, fatal: preflight.fatal }), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; + } + + return { + ...baseConfig, + args: [ + ...distroArgs, + "--", + // Absolute node path resolved by the preflight after the shared remote + // resolver repaired PATH. `wsl.exe -- node` would run against the bare + // non-login PATH and hit /usr/bin/node instead of the version-managed + // node node-pty was built against. + preflight.nodePath, + 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 crypto = yield* Crypto.Crypto; - const tokenRef = yield* Ref.make(Option.none()); - const getOrCreateBootstrapToken = Effect.gen(function* () { - const existing = yield* Ref.get(tokenRef); - if (Option.isSome(existing)) { - return existing.value; - } - - const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); - yield* Ref.set(tokenRef, Option.some(token)); - return token; + // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. + // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can + // resolve concurrently; with a plain Ref both could observe None, generate + // distinct tokens, and one would overwrite the other — leaving the two + // backends holding mismatched tokens and breaking the shared-token + // invariant the renderer relies on. modifyEffect serializes the whole + // get-or-create so the first caller wins and the rest reuse its token. + const tokenRef = yield* SynchronizedRef.make(Option.none()); + const getOrCreateBootstrapToken = SynchronizedRef.modifyEffect(tokenRef, (current) => + Option.match(current, { + onSome: (token) => Effect.succeed([token, current] as const), + onNone: () => + crypto.randomBytes(24).pipe( + Effect.map((bytes) => { + const token = Encoding.encodeHex(bytes); + return [token, Option.some(token)] as const; + }), + ), + }), + ); + + // 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; + 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), + ); + }); + + // Single source of truth for what the primary actually runs as. Both + // the start-config dispatch and the renderer-facing label derive from + // this, so they can't disagree — e.g. the label reading "WSL" while the + // config silently fell back to Windows because WSL is unavailable. + // Dispatch happens at resolve time so toggling wsl-only between restarts + // is picked up on the next start cycle (the pool's primary instance is + // created once at layer init, but configResolve fires on each restart). + const describePrimary = Effect.gen(function* () { + const persistedSettings = yield* settings.get; + const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; + // Only honor wsl-only when WSL is actually usable. If the user + // persisted wsl-only but WSL has since become unavailable (wsl.exe + // removed, no distro), fall back to the Windows primary instead of + // looping forever on preflight failures: the Connections backend + // control is hidden while WSL is unavailable, so a stuck WSL primary + // would otherwise leave no in-app way back to Windows. + const useWsl = wslRequested && (yield* wslEnvironment.isAvailable); + return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; }); return DesktopBackendConfiguration.of({ - resolve: Effect.gen(function* () { - const bootstrapToken = yield* getOrCreateBootstrapToken; - const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - ); - return yield* resolveBackendStartConfig({ - bootstrapToken, - observabilitySettings, + resolvePrimary: Effect.gen(function* () { + const { useWsl, wslRequested } = yield* describePrimary; + if (useWsl) { + return yield* buildWslPrimaryConfig; + } + if (wslRequested) { + yield* Effect.logWarning( + "WSL-only backend requested but WSL is unavailable; starting the Windows primary instead.", + ); + } + return yield* buildWindowsPrimaryConfig; + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimary")), + resolvePrimaryLabel: Effect.gen(function* () { + const { useWsl, distro } = yield* describePrimary; + if (!useWsl) { + return environment.platform === "win32" ? "Windows" : "Local environment"; + } + return distro ? `WSL (${distro})` : "WSL"; + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimaryLabel")), + 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..ae76e24042a 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,96 @@ 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 onPreflightFailed?: (reason: string) => 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), - ), - ), + readonly configResolve?: Effect.Effect; +} + +// 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: Effect.succeed("Windows"), + configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig), + ...(input.onReady ? { onReady: () => input.onReady! } : {}), + ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), + ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), + }); + + 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 +214,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 +273,136 @@ 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)), - ), - }); + it.effect("does not notify shutdown before the first start has prior state", () => + Effect.scoped( + Effect.gen(function* () { + let shutdownCount = 0; + const closed = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + yield* Queue.offer(startedPids, 123); + const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid); + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + onShutdown: Effect.sync(() => { + shutdownCount += 1; }), - ), - ); + }); - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), - }); + yield* instance.start; + assert.equal(yield* Queue.take(startedPids), 123); + assert.equal(shutdownCount, 0); + }), + ), + ); + + it.effect("restarts an unexpectedly exited backend with the Effect clock", () => + 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* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + yield* instance.start; assert.equal(yield* Queue.take(starts), 1); @@ -388,107 +415,265 @@ 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("does not notify shutdown when a scheduled restart starts from non-ready state", () => + Effect.scoped( + Effect.gen(function* () { + let shutdownCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + preflightFailure: Option.some({ reason: "preflight failed", fatal: false }), + }, + onShutdown: Effect.sync(() => { + shutdownCount += 1; + }), + }); + + yield* instance.start; + assert.equal(shutdownCount, 0); + + yield* TestClock.adjust(Duration.millis(500)); + assert.equal(shutdownCount, 0); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("surfaces a fatal preflight failure once and stops looping after the cap", () => + Effect.scoped( + Effect.gen(function* () { + const failures: string[] = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + preflightFailure: Option.some({ reason: "Node.js not found", fatal: true }), + }, + onPreflightFailed: (reason) => + Effect.sync(() => { + failures.push(reason); + }), + }); + + yield* instance.start; + assert.deepEqual(failures, []); + + // Five fatal attempts with exponential backoff (500ms, 1s, 2s, 4s) reach + // the cap, at which point the failure is surfaced exactly once. + yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.seconds(1)); + yield* TestClock.adjust(Duration.seconds(2)); + yield* TestClock.adjust(Duration.seconds(4)); + assert.deepEqual(failures, ["Node.js not found"]); + + // Past the cap the loop stops and nothing else is surfaced. + yield* TestClock.adjust(Duration.seconds(8)); + yield* TestClock.adjust(Duration.seconds(30)); + assert.deepEqual(failures, ["Node.js not found"]); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("can be started again after a fatal preflight cap once config recovers", () => + Effect.scoped( + Effect.gen(function* () { + const failing = yield* Ref.make(true); + const starts = yield* Queue.unbounded(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Queue.offer(starts, 123).pipe( + Effect.as( + makeProcess({ + exitCode: Effect.never, + }), + ), + ), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + configResolve: Ref.get(failing).pipe( + Effect.map((isFailing) => + isFailing + ? { + ...baseConfig, + preflightFailure: Option.some({ + reason: "Node.js not found", + fatal: true, + }), + } + : baseConfig, + ), + ), + }); + + yield* instance.start; + yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.seconds(1)); + yield* TestClock.adjust(Duration.seconds(2)); + yield* TestClock.adjust(Duration.seconds(4)); + yield* TestClock.adjust(Duration.seconds(8)); + + const parked = yield* instance.snapshot; + assert.equal(parked.desiredRunning, false); + assert.equal(parked.ready, false); + assert.isTrue(Option.isNone(parked.activePid)); + assert.equal(parked.restartScheduled, false); + assert.equal(yield* Queue.size(starts), 0); + + yield* Ref.set(failing, false); + yield* instance.start; + + assert.equal(yield* Queue.take(starts), 123); + const running = yield* instance.snapshot; + assert.equal(running.desiredRunning, true); + assert.deepEqual(running.activePid, Option.some(123)); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("keeps retrying a transient (non-fatal) preflight failure without surfacing", () => + Effect.scoped( + Effect.gen(function* () { + const failures: string[] = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + preflightFailure: Option.some({ reason: "wslpath conversion failed", fatal: false }), + }, + onPreflightFailed: (reason) => + Effect.sync(() => { + failures.push(reason); + }), + }); + + yield* instance.start; + // Well beyond the fatal cap's worth of time: a transient failure must + // keep retrying (self-heal) and never surface. + yield* TestClock.adjust(Duration.minutes(2)); + assert.deepEqual(failures, []); + }).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), - }); + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + 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)), - ), - }); - }), - ), - ); + 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 managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), - }); + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + 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 07693a82707..47ae3dc421c 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,15 +46,17 @@ 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); +// After this many consecutive *fatal* preflight failures, stop the silent +// restart loop and surface the reason via onPreflightFailed. Transient +// failures are not counted, so they keep retrying and can still self-heal. +const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); @@ -43,14 +69,33 @@ 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; +} + +// A preflight failure records whether it is fatal. Transient failures (WSL +// cold-starting, wslpath while the VM boots) keep retrying so the backend can +// self-heal; fatal ones (no node, wrong version, missing build tools) are +// surfaced via onPreflightFailed and stop the restart loop after +// MAX_PREFLIGHT_FAILURE_ATTEMPTS. +export interface PreflightFailure { + readonly reason: string; + readonly fatal: boolean; } interface BackendProcessExit { @@ -106,20 +151,61 @@ 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: Effect.Effect; 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 ->()("@t3tools/desktop/backend/DesktopBackendManager") {} - -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: Effect.Effect; + // configResolve can now fail with PlatformError because the + // bootstrap-token closure inside DesktopBackendConfiguration uses + // crypto.randomBytes (Effect 4 beta.73 migration). + readonly configResolve: Effect.Effect; + // Receives the *resolved* httpBaseUrl of the run that just became + // ready. The window service uses this to decide what URL to load + // (the WSL backend reports its distro IP, the Windows backend reports + // 127.0.0.1). Splitting this off from configResolve avoids races + // between "fired onReady" and "currentConfig already advanced". + readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; + readonly onShutdown?: () => Effect.Effect; + // Fired once when a fatal preflight failure has exhausted its retries. The + // pool wires this on the primary to surface the reason and, in wsl-only mode, + // fall back to the Windows backend so a window can still open instead of the + // app silently retrying forever with no window. + readonly onPreflightFailed?: (reason: string) => Effect.Effect; +} interface ActiveBackendRun { readonly id: number; @@ -134,6 +220,9 @@ interface BackendManagerState { readonly config: Option.Option; readonly active: Option.Option; readonly restartAttempt: number; + // Consecutive fatal preflight failures, reset on a clean preflight. Drives + // the MAX_PREFLIGHT_FAILURE_ATTEMPTS cap; restartAttempt counts all restarts. + readonly preflightFailureAttempt: number; readonly restartFiber: Option.Option>; readonly nextRunId: number; } @@ -144,6 +233,7 @@ const initialState: BackendManagerState = { config: Option.none(), active: Option.none(), restartAttempt: 0, + preflightFailureAttempt: 0, restartFiber: Option.none(), nextRunId: 1, }; @@ -233,28 +323,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 +364,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,10 +431,15 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio return; } - yield* Ref.set(desktopState.backendReady, false); - const config = yield* configuration.resolve.pipe( + if (current.ready) { + yield* spec.onShutdown?.() ?? Effect.void; + yield* Ref.update(state, (latest) => + latest.ready ? { ...latest, ready: false } : latest, + ); + } + const config = yield* spec.configResolve.pipe( Effect.tapError((error) => - logBackendManagerError("failed to generate desktop backend configuration", { + logInstanceError("failed to generate desktop backend configuration", { cause: error.message, }), ), @@ -344,14 +452,68 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio .exists(config.value.entryPath) .pipe(Effect.orElseSucceed(() => false)); + const resetFatalPreflightCounter = + !current.desiredRunning && current.preflightFailureAttempt > 0; yield* cancelRestart; yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: true, ready: false, config: Option.some(config.value), + preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt, })); + const preflightFailure = config.value.preflightFailure; + if (Option.isSome(preflightFailure)) { + const { reason, fatal } = preflightFailure.value; + if (!fatal) { + // Transient (WSL cold-starting, wslpath while the VM boots). Keep + // retrying so the backend self-heals once WSL is ready; don't count + // it toward the fatal cap. + yield* scheduleRestart(reason); + return; + } + const attempt = yield* Ref.modify(state, (latest) => { + const next = latest.preflightFailureAttempt + 1; + return [next, { ...latest, preflightFailureAttempt: next }] as const; + }); + if (attempt > MAX_PREFLIGHT_FAILURE_ATTEMPTS) { + // We already surfaced and asked for the Windows fallback, yet we're + // still resolving the WSL primary — the fallback didn't take (e.g. + // the settings write failed). Stop rather than loop forever. + yield* logInstanceError("backend preflight still failing after fallback; stopping", { + reason, + attempt, + }); + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + ready: false, + })); + return; + } + if (attempt === MAX_PREFLIGHT_FAILURE_ATTEMPTS) { + // Fatal and out of retries. Surface the reason (onPreflightFailed, + // on the primary, shows a dialog and persists Windows mode), then + // schedule one more restart so the next resolve picks up the Windows + // primary and a window can open. + yield* logInstanceError( + "backend preflight failed repeatedly; surfacing and falling back", + { reason, attempt }, + ); + yield* spec.onPreflightFailed?.(reason) ?? Effect.void; + yield* scheduleRestart(reason); + return; + } + yield* scheduleRestart(reason); + return; + } + // Clean preflight — reset the fatal counter so a later failure gets a + // fresh allowance. + yield* Ref.update(state, (latest) => + latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, + ); + if (!entryExists) { yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`); return; @@ -372,7 +534,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)( @@ -424,7 +586,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) { @@ -436,7 +598,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const program = runBackendProcess({ ...config.value, - 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), @@ -446,7 +608,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.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) { @@ -466,17 +628,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.value.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), @@ -498,9 +653,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) => { @@ -520,8 +675,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), }); @@ -541,7 +696,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), }), ), @@ -560,7 +715,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)( @@ -578,7 +733,15 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio restartFiber: Option.none>(), }, ]); - yield* Ref.set(desktopState.backendReady, false); + // Ignore failures from spec.onShutdown so a downstream throw + // can't abort the rest of stop(). Ref.modify above already + // flipped state to "no active run / no restart fiber", and the + // physical cleanup (Fiber.interrupt + closeRun) runs after the + // mutex releases. If onShutdown were allowed to propagate, both + // would be skipped and the child process + restart fiber would + // be orphaned while state claimed nothing was running — the + // next start() would then spawn a second backend on top. + yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore); return result; }), ); @@ -593,14 +756,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..fcb11165030 --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -0,0 +1,136 @@ +import { assert, describe, it } from "@effect/vitest"; +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 Ref from "effect/Ref"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +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: Effect.succeed(label), + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed(snapshot), + waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), + }; +} + +function makePoolLayer( + labelRef: Ref.Ref, +): Layer.Layer { + return DesktopBackendPool.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + FileSystem.layerNoop({}), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HTTP request")), + ), + Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { + forInstance: () => + Effect.succeed({ + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, + } satisfies DesktopObservability.DesktopBackendOutputLogShape), + } satisfies DesktopObservability.DesktopBackendOutputLogFactoryShape), + Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolvePrimary: Effect.die("unexpected primary config resolve"), + resolvePrimaryLabel: Ref.get(labelRef), + resolveWsl: () => Effect.die("unexpected WSL config resolve"), + } satisfies DesktopBackendConfiguration.DesktopBackendConfigurationShape), + DesktopAppSettings.layerTest(), + ElectronDialog.layer, + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window create"), + ensureMain: Effect.die("unexpected window ensure"), + revealOrCreateMain: Effect.die("unexpected window reveal"), + activate: Effect.die("unexpected window activate"), + createMainIfBackendReady: Effect.die("unexpected window create"), + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + dispatchMenuAction: () => Effect.die("unexpected menu action"), + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindowShape), + ), + ), + ); +} + +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(yield* Option.getOrThrow(fetchedPrimary).label, "Windows"); + assert.equal(yield* Option.getOrThrow(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"))), + ); + + it.effect("resolves the primary label lazily after pool layer construction", () => + Effect.scoped( + Effect.gen(function* () { + const labelRef = yield* Ref.make("Windows"); + const pool = yield* DesktopBackendPool.DesktopBackendPool.pipe( + Effect.provide(makePoolLayer(labelRef)), + ); + const primary = yield* pool.primary; + + yield* Ref.set(labelRef, "WSL (Ubuntu)"); + + assert.equal(yield* primary.label, "WSL (Ubuntu)"); + }), + ), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts new file mode 100644 index 00000000000..a5f0b349cac --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -0,0 +1,368 @@ +// 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 DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; + +const { logWarning: logBackendPoolWarning } = + DesktopObservability.makeComponentLogger("desktop-backend-pool"); + +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 +>()("@t3tools/desktop/backend/DesktopBackendPool") {} + +// 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 electronDialog = yield* ElectronDialog.ElectronDialog; + const appSettings = 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(); + + // A fatal WSL preflight failure on the *primary* only happens in wsl-only + // mode (the primary is Windows otherwise, and Windows has no preflight). + // Surface the reason and fall back to the Windows backend so a window can + // still open instead of the app sitting invisible, retrying a node setup + // that won't fix itself. The user re-enables "Run in WSL only" from Settings + // once the distro is fixed. + const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( + function* (reason: string) { + yield* logBackendPoolWarning("primary WSL preflight failed; falling back to Windows", { + reason, + }); + yield* electronDialog.showErrorBox( + "WSL backend couldn't start", + `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, + ); + // Fully disable the WSL backend — both flags, matching the "Switch to + // Windows" recovery path — so the manager's next restart re-resolves the + // primary as Windows and reconcile won't register a secondary WSL backend + // against the same broken setup. Clearing wslBackendEnabled alone would + // leave a stale wslOnly:true that silently re-traps the user in wsl-only + // mode the next time they enable WSL. If the persisted write fails, keep + // this process recoverable by applying the fallback to in-memory settings. + yield* appSettings.applyWslWindowsFallback.pipe( + Effect.catch((error) => + logBackendPoolWarning( + "failed to persist Windows fallback after WSL preflight failure", + { + error: error.message, + }, + ).pipe(Effect.andThen(appSettings.applyWslWindowsFallbackInMemory)), + ), + ); + }, + ); + + const primary = yield* DesktopBackendManager.makeBackendInstance({ + id: DesktopBackendManager.PRIMARY_INSTANCE_ID, + // Keep this lazy. The pool layer is initialized before startup loads + // persisted desktop settings, so resolving the primary label here would + // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only + // primaries as Windows. + label: configuration.resolvePrimaryLabel, + configResolve: configuration.resolvePrimary, + // Window creation errors propagating out of handleBackendReady must + // not block the readiness callback (that would prevent restartAttempt + // from being reset), so we absorb them here. The window service only + // logs on success, so log the failure here before swallowing it — + // otherwise a post-readiness window-open failure vanishes silently and + // is near-impossible to diagnose in production. + onReady: (httpBaseUrl) => + desktopWindow.handleBackendReady(httpBaseUrl).pipe( + Effect.catch((error) => + logBackendPoolWarning("failed to open main window after backend readiness", { + error: error.message, + }), + ), + ), + onShutdown: () => desktopWindow.handleBackendNotReady, + onPreflightFailed: handlePrimaryPreflightFailure, + }); + + 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 40f84054878..2d75d25016a 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -42,18 +42,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); @@ -77,6 +78,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 1ded238c663..b40091a1503 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -17,7 +17,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"; @@ -38,4 +38,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..7d5ffd0ab9e 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -4,13 +4,18 @@ 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 DesktopWslBackend from "../../wsl/DesktopWslBackend.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 +23,11 @@ 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 { + extractDistroFromUncPath, + resolveWslPickFolderDefaultPath, + wslUncPathToLinuxPath, +} from "../../wsl/wslPathParsing.ts"; const ContextMenuPosition = Schema.Struct({ x: Schema.Number, @@ -44,26 +54,53 @@ 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: yield* instance.label, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), ...(bootstrap.desktopBootstrapToken ? { bootstrapToken: bootstrap.desktopBootstrapToken } : {}), - }), - }); + }); + } + return bootstraps; }), }); +// 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(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX)) { + return null; + } + const suffix = envId.slice(DesktopWslBackend.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,11 +109,62 @@ 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(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX) + ? extractWslDistroFromEnvironmentId(targetId) + : null; + const useWsl = + targetId !== undefined && + targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && + targetId.startsWith(DesktopWslBackend.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); + if (Option.isNone(selectedPath)) { + return null; + } + if (!useWsl) { + return selectedPath.value; + } + + const linuxUncPath = wslUncPathToLinuxPath(selectedPath.value); + if (linuxUncPath !== null) { + return linuxUncPath; + } + + const converted = yield* wslEnvironment.windowsToWslPath( + extractDistroFromUncPath(selectedPath.value) ?? wslDistro, + selectedPath.value, + ); + return Option.getOrElse(converted, () => selectedPath.value); }), }); diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts new file mode 100644 index 00000000000..10817d0f2f1 --- /dev/null +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -0,0 +1,200 @@ +import { DesktopWslStateSchema } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopState from "../../app/DesktopState.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; +import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import { setWslBackendEnabled } from "./wsl.ts"; + +const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); + +const invokeSetWslBackendEnabled = (enabled: boolean) => + setWslBackendEnabled.handler(enabled).pipe(Effect.flatMap(decodeWslState)); + +function makeWslBackendLayer(input: { readonly onReconcile?: Effect.Effect } = {}) { + return Layer.succeed( + DesktopWslBackend.DesktopWslBackend, + DesktopWslBackend.DesktopWslBackend.of({ + reconcile: input.onReconcile ?? Effect.void, + lastPreflightError: Effect.succeed(Option.none()), + }), + ); +} + +function makeLifecycleLayer(relaunchReasons: Array) { + return Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: (reason) => + Effect.sync(() => { + relaunchReasons.push(reason); + }), + register: Effect.void, + }), + ); +} + +const unusedLifecycleRuntimeLayer = Layer.mergeAll( + DesktopLifecycle.layerShutdown, + DesktopState.layer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({} as DesktopEnvironment.DesktopEnvironmentShape), + ), + Layer.succeed( + DesktopWindow.DesktopWindow, + DesktopWindow.DesktopWindow.of({} as DesktopWindow.DesktopWindowShape), + ), + Layer.succeed( + ElectronApp.ElectronApp, + ElectronApp.ElectronApp.of({} as ElectronApp.ElectronAppShape), + ), + Layer.succeed( + ElectronTheme.ElectronTheme, + ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronThemeShape), + ), +); + +describe("WSL IPC", () => { + it.effect("relaunches when enabling the WSL backend while wsl-only is already persisted", () => { + const relaunchReasons: Array = []; + let reconcileCount = 0; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: false, + wslOnly: true, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer({ + onReconcile: Effect.sync(() => { + reconcileCount += 1; + }), + }), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const state = yield* invokeSetWslBackendEnabled(true); + + assert.deepEqual(state, { + enabled: true, + distro: null, + available: true, + wslOnly: true, + distros: [], + preflightError: null, + }); + assert.equal(reconcileCount, 0); + assert.deepEqual(relaunchReasons, ["wslBackendEnabled=true"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reconciles in dual-backend mode without relaunching", () => { + const relaunchReasons: Array = []; + let reconcileCount = 0; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: false, + wslOnly: false, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer({ + onReconcile: Effect.sync(() => { + reconcileCount += 1; + }), + }), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const state = yield* invokeSetWslBackendEnabled(true); + + assert.equal(state.enabled, true); + assert.equal(state.wslOnly, false); + assert.equal(reconcileCount, 1); + assert.deepEqual(relaunchReasons, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("clears wsl-only before relaunching when disabling a WSL-only backend", () => { + const relaunchReasons: Array = []; + let reconcileCount = 0; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer({ + onReconcile: Effect.sync(() => { + reconcileCount += 1; + }), + }), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const state = yield* invokeSetWslBackendEnabled(false); + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const settings = yield* appSettings.get; + + assert.deepEqual(state, { + enabled: false, + distro: null, + available: true, + wslOnly: false, + distros: [], + preflightError: null, + }); + assert.equal(settings.wslBackendEnabled, false); + assert.equal(settings.wslOnly, false); + assert.equal(reconcileCount, 0); + assert.deepEqual(relaunchReasons, ["wslBackendEnabled=false"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("clears dual-backend WSL without relaunching", () => { + const relaunchReasons: Array = []; + let reconcileCount = 0; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: false, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer({ + onReconcile: Effect.sync(() => { + reconcileCount += 1; + }), + }), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const state = yield* invokeSetWslBackendEnabled(false); + + assert.equal(state.enabled, false); + assert.equal(state.wslOnly, false); + assert.equal(reconcileCount, 1); + assert.deepEqual(relaunchReasons, []); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts new file mode 100644 index 00000000000..c0e0aedfb4a --- /dev/null +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -0,0 +1,126 @@ +import { DesktopWslStateSchema, type DesktopWslState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +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 + | DesktopWslBackend.DesktopWslBackend +> = Effect.gen(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + 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 : []; + const preflightError = yield* wslBackend.lastPreflightError; + return { + enabled: settings.wslBackendEnabled, + distro: settings.wslDistro, + available, + wslOnly: settings.wslOnly, + distros, + // Only the dual-mode secondary records this; a wsl-only failure surfaces via + // a dialog + Windows fallback, so it stays null there. + preflightError: settings.wslOnly ? null : Option.getOrNull(preflightError), + }; +}); + +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; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const previousSettings = yield* appSettings.get; + const updateSettings = enabled + ? appSettings.setWslBackendEnabled(true) + : appSettings.applyWslWindowsFallback; + const change = yield* updateSettings; + const settings = yield* appSettings.get; + const changedWslOnlyPrimary = enabled + ? settings.wslOnly + : previousSettings.wslBackendEnabled && previousSettings.wslOnly; + if (changedWslOnlyPrimary && change.changed) { + const state = yield* readWslState; + yield* lifecycle.relaunch(`wslBackendEnabled=${enabled}`); + return state; + } + // 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; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setWslDistro(distro); + const settings = yield* appSettings.get; + // In wsl-only mode the pool's primary IS the WSL backend, and its distro + // is captured when that backend starts. reconcile only manages the + // dual-mode secondary (it skips registering one when wslOnly), so it can't + // swap the primary's distro — relaunch so the primary comes back up on the + // newly selected distro, same as the wsl-only toggle. In dual-backend mode + // the secondary wsl: instance is swapped by reconcile instead. + if (settings.wslOnly && change.changed) { + const state = yield* readWslState; + yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); + return state; + } + 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 9356eef441b..c7c775e44b4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,7 +31,7 @@ import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.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"; @@ -45,6 +45,8 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.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* () { @@ -129,19 +131,32 @@ 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, DesktopCloudAuth.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 84f7580cb07..bf2df8d45c7 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..f9ea884b2d0 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -21,6 +21,10 @@ 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)), + wslOnly: Schema.optionalKey(Schema.Boolean), }); const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); @@ -95,6 +99,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopSettingsValue); }); @@ -116,6 +123,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 +205,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopSettingsValue); }), ), @@ -234,6 +247,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 +272,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 +296,101 @@ 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("applies WSL Windows fallback with persisted and volatile updates", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setWslBackendEnabled(true); + yield* settings.setWslOnly(true); + + const persistedFallback = yield* settings.applyWslWindowsFallback; + assert.isTrue(persistedFallback.changed); + assert.equal(persistedFallback.settings.wslBackendEnabled, false); + assert.equal(persistedFallback.settings.wslOnly, false); + + const persistedReload = yield* settings.load; + assert.equal(persistedReload.wslBackendEnabled, false); + assert.equal(persistedReload.wslOnly, false); + + yield* settings.setWslBackendEnabled(true); + yield* settings.setWslOnly(true); + + const volatileFallback = yield* settings.applyWslWindowsFallbackInMemory; + assert.isTrue(volatileFallback.changed); + assert.equal(volatileFallback.settings.wslBackendEnabled, false); + assert.equal(volatileFallback.settings.wslOnly, false); + + const current = yield* settings.get; + assert.equal(current.wslBackendEnabled, false); + assert.equal(current.wslOnly, false); + + const diskReload = yield* settings.load; + assert.equal(diskReload.wslBackendEnabled, true); + assert.equal(diskReload.wslOnly, true); + }), + ), + ); + + 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 a54f22fec5b..27b5b054de8 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,17 @@ 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; + readonly applyWslWindowsFallback: Effect.Effect; + readonly applyWslWindowsFallbackInMemory: Effect.Effect; } export class DesktopAppSettings extends Context.Service< @@ -104,6 +141,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 +156,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 +172,9 @@ function normalizeDesktopSettingsDocument( ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) : defaultSettings.updateChannel, updateChannelConfiguredByUser, + wslBackendEnabled, + wslDistro: normalizeWslDistro(parsed.wslDistro), + wslOnly: parsed.wslOnly === true, }; } @@ -148,6 +199,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 +254,38 @@ 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 applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { + return setWslOnly(setWslBackendEnabled(settings, false), false); +} + function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, @@ -268,6 +360,11 @@ export const layer = Layer.effect( Effect.as([settingsChange(nextSettings, true), nextSettings] as const), ); }); + const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) => + SynchronizedRef.modify(settingsRef, (settings) => { + const nextSettings = update(settings); + return [settingsChange(nextSettings, nextSettings !== settings), nextSettings] as const; + }); return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), @@ -291,6 +388,26 @@ 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 } }), + ), + applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( + Effect.withSpan("desktop.settings.applyWslWindowsFallback"), + ), + applyWslWindowsFallbackInMemory: updateInMemory(applyWslWindowsFallback).pipe( + Effect.withSpan("desktop.settings.applyWslWindowsFallbackInMemory"), + ), }); }), ); @@ -319,6 +436,12 @@ 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)), + applyWslWindowsFallback: update(applyWslWindowsFallback), + applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); }), ); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 34d18f11a77..b2a3c9d8266 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: Effect.succeed("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 e6c81d8d25b..3563a01f615 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 62d619fe18b..cb1db0de730 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -73,7 +73,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 271b35095e4..d73c6ecce86 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -11,7 +11,6 @@ import { vi } from "vite-plus/test"; 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, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => @@ -198,7 +196,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); @@ -221,7 +219,7 @@ describe("DesktopWindow", () => { yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); const willNavigate = fakeWindow.webContentsListeners.get("will-navigate"); if (!willNavigate) { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 35145cc1d53..d21086aa662 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; } @@ -163,7 +173,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); @@ -319,8 +339,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; @@ -341,7 +364,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; @@ -361,11 +384,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.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts new file mode 100644 index 00000000000..f1da0231399 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -0,0 +1,199 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as NetService from "@t3tools/shared/Net"; + +import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import type { + DesktopBackendSnapshot, + DesktopBackendStartConfig, +} from "../backend/DesktopBackendManager.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; +import * as DesktopWslBackend from "./DesktopWslBackend.ts"; + +function makeStubInstance(input: { + readonly id: DesktopBackendPool.BackendInstanceId; + readonly label: string; + readonly snapshot: DesktopBackendSnapshot; + readonly start?: Effect.Effect; +}): DesktopBackendPool.DesktopBackendInstance { + return { + id: input.id, + label: Effect.succeed(input.label), + start: input.start ?? Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed(input.snapshot), + waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), + }; +} + +const idleSnapshot: DesktopBackendSnapshot = { + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 5, + restartScheduled: false, +}; + +const primarySnapshot: DesktopBackendSnapshot = { + desiredRunning: true, + ready: true, + activePid: Option.some(123), + restartAttempt: 0, + restartScheduled: false, +}; + +const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 3773, + bindHost: "127.0.0.1", + httpBaseUrl: new URL("http://127.0.0.1:3773"), + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.succeed([]), +} satisfies DesktopServerExposure.DesktopServerExposureShape); + +const backendConfigurationLayer = Layer.succeed( + DesktopBackendConfiguration.DesktopBackendConfiguration, + { + resolvePrimary: Effect.die("unexpected resolvePrimary"), + resolvePrimaryLabel: Effect.succeed("Windows"), + resolveWsl: () => Effect.die("unexpected resolveWsl"), + } satisfies DesktopBackendConfiguration.DesktopBackendConfigurationShape, +); + +const netLayer = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(41773), + findAvailablePort: (preferred) => Effect.succeed(preferred), +} satisfies NetService.NetServiceShape); + +describe("DesktopWslBackend", () => { + it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { + let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; + const primary = makeStubInstance({ + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", + snapshot: primarySnapshot, + }); + const wsl = makeStubInstance({ + id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), + label: "WSL (Ubuntu)", + snapshot: primarySnapshot, + }); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + get: (id) => + Effect.succeed( + id === DesktopBackendPool.PRIMARY_INSTANCE_ID + ? Option.some(primary) + : Option.none(), + ), + list: Effect.succeed([primary]), + primary: Effect.succeed(primary), + register: (spec) => + Effect.sync(() => { + registeredSpec = spec; + return wsl; + }), + unregister: () => Effect.die("unexpected unregister"), + } satisfies DesktopBackendPool.DesktopBackendPoolShape); + + return Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + + yield* backend.reconcile; + const spec = registeredSpec; + assert.isDefined(spec); + if (spec === undefined) { + throw new Error("Expected WSL backend registration"); + } + const recordFailure = spec.onPreflightFailed; + const clearFailure = spec.onReady; + assert.isDefined(recordFailure); + assert.isDefined(clearFailure); + if (recordFailure === undefined || clearFailure === undefined) { + throw new Error("Expected WSL backend callbacks"); + } + + yield* recordFailure("Node.js not found"); + assert.deepEqual(yield* backend.lastPreflightError, Option.some("Node.js not found")); + + yield* clearFailure(new URL("http://127.0.0.1:41773")); + assert.deepEqual(yield* backend.lastPreflightError, Option.none()); + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provideMerge(poolLayer), + Layer.provideMerge(backendConfigurationLayer), + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(netLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: false, + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), + ), + ), + ); + }); + + it.effect("retries an unchanged WSL instance when it is idle after failed preflight", () => { + let startCount = 0; + const primary = makeStubInstance({ + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", + snapshot: primarySnapshot, + }); + const wsl = makeStubInstance({ + id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), + label: "WSL (Ubuntu)", + snapshot: idleSnapshot, + start: Effect.sync(() => { + startCount += 1; + }), + }); + + return Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + + yield* backend.reconcile; + + assert.equal(startCount, 1); + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provideMerge(DesktopBackendPool.layerTest([primary, wsl])), + Layer.provideMerge(backendConfigurationLayer), + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(netLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: false, + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), + ), + ), + ); + }); +}); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts new file mode 100644 index 00000000000..4c88d4035eb --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -0,0 +1,267 @@ +// 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 Cause from "effect/Cause"; +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 Ref from "effect/Ref"; +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"; + +// Exported so callers that parse pool ids (e.g. the pickFolder IPC +// handler in ipc/methods/window.ts) reference the same prefix this +// module produces. Keeping it inline in two places risks silent +// divergence if one ever gets renamed. +export 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; + // Reason the dual-mode WSL secondary last failed preflight (no node, wrong + // version, missing build tools), or None. Read by the getWslState IPC so + // Connections settings can show it inline. None in wsl-only mode (that path + // surfaces via a dialog + Windows fallback). + readonly lastPreflightError: Effect.Effect>; +} + +export class DesktopWslBackend extends Context.Service()( + "@t3tools/desktop/wsl/DesktopWslBackend", +) {} + +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); + + // Last fatal preflight failure from the dual-mode WSL *secondary*, surfaced + // inline in Connections settings. The primary's failure is handled by the + // pool (dialog + Windows fallback) instead; here the app stays usable on + // Windows, so we record the reason rather than interrupting. Cleared on any + // reconcile state change so it reflects the current attempt. + const preflightErrorRef = yield* Ref.make(Option.none()); + + 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: Effect.succeed(buildLabel(input.distro)), + configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), + // Dual-mode secondary: record a fatal preflight failure so Connections + // settings can show why the WSL backend never appeared. No dialog or + // fallback — Windows is the primary and keeps working. + onPreflightFailed: (reason) => Ref.set(preflightErrorRef, Option.some(reason)), + onReady: () => Ref.set(preflightErrorRef, Option.none()), + }) + .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(existing) && + targetId.value === existing.value.id + ) { + const existingInstance = existing.value; + const snapshot = yield* existingInstance.snapshot; + const isIdle = + !snapshot.ready && Option.isNone(snapshot.activePid) && !snapshot.restartScheduled; + if (isIdle) { + yield* logWslBackendInfo("retrying idle WSL backend", { id: existingInstance.id }); + yield* Ref.set(preflightErrorRef, Option.none()); + yield* existingInstance.start; + } + return; + } + + // A real state change is happening (start, stop, or distro swap). Clear + // any stale secondary preflight error so it reflects this fresh attempt; + // onPreflightFailed re-sets it only if the new secondary exhausts retries. + yield* Ref.set(preflightErrorRef, Option.none()); + + 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 }); + } + }); + + // Top-level safety net. Every internal step today already catches + // its own failures (port allocation, register, preWarm), so the + // inferred error type is `never` and this catch is a no-op in + // steady state. It's here to enforce the file-header contract + // ("reconcile never fails; errors are logged") if a future change + // introduces an unhandled failure path — otherwise IPC callers + // like setWslBackendEnabled would surface it to the renderer as + // an opaque error. + const reconcile = reconcileMutex + .withPermits(1)(reconcileBody) + .pipe( + Effect.catchCause((cause) => + logWslBackendWarning("reconcile failed", { cause: Cause.pretty(cause) }), + ), + Effect.withSpan("desktop.wslBackend.reconcile"), + ); + + return DesktopWslBackend.of({ + reconcile, + lastPreflightError: Ref.get(preflightErrorRef), + }); + }), +); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts new file mode 100644 index 00000000000..df123cf7cb5 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vite-plus/test"; + +import { + buildWslNodeEnvPreamble, + formatMissingToolsReason, + parseNodePath, + parseToolchainReport, +} from "./DesktopWslEnvironment.ts"; + +describe("buildWslNodeEnvPreamble", () => { + it("passes the required Node engine range into the shared resolver", () => { + const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); + + expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'"); + expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan( + preamble.lastIndexOf("ensure_remote_node_path || true"), + ); + }); + + it("keeps the shared resolver permissive when no Node engine range is provided", () => { + expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''"); + }); +}); + +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("parseNodePath", () => { + it("extracts the absolute node path from a nodePath: line", () => { + const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node"; + expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node"); + }); + + it("returns null when node was not found (empty value after prefix)", () => { + expect(parseNodePath("nodePath:")).toBeNull(); + }); + + it("returns null when there is no nodePath line at all", () => { + expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull(); + }); + + it("ignores surrounding noise and trims whitespace", () => { + const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n"); + expect(parseNodePath(stdout)).toBe("/usr/bin/node"); + }); +}); + +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..516c1526179 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -0,0 +1,640 @@ +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 { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; +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 nodePath: string } + | { 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 +>()("@t3tools/desktop/wsl/DesktopWslEnvironment") {} + +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]" }; + +// Reuse the SSH remote resolver so WSL and SSH discover version-managed Node +// the same way. Passing the engine range lets the resolver fall through to +// version managers like nvm when a system node exists but is too old. +export const buildWslNodeEnvPreamble = ( + nodeEngineRange?: string | null, +): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} +ensure_remote_node_path || true +`; + +// wsl.exe re-escapes args before forwarding them to the Linux side, which +// mangles quotes inside `bash -lc "