// 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 Deferred from "effect/Deferred"; 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 Schema from "effect/Schema"; 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 Schema.TaggedErrorClass()( "DesktopBackendPoolInstanceAlreadyRegisteredError", { id: Schema.String, }, ) { 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 Schema.TaggedErrorClass()( "DesktopBackendPoolCannotUnregisterPrimaryError", {}, ) { override get message() { return "Refusing to unregister the primary backend from the pool."; } } export class DesktopBackendPool extends Context.Service< DesktopBackendPool, { // 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; } >()("@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 ActiveRegisteredInstance { readonly _tag: "Active"; 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; } interface ClosingRegisteredInstance { readonly _tag: "Closing"; readonly done: Deferred.Deferred; } type RegisteredInstance = ActiveRegisteredInstance | ClosingRegisteredInstance; type RegisterAction = | { readonly _tag: "Registered"; readonly instance: DesktopBackendInstance } | { readonly _tag: "Wait"; readonly done: Deferred.Deferred }; type UnregisterAction = | { readonly _tag: "Absent" } | { readonly _tag: "Wait"; readonly done: Deferred.Deferred } | { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance }; 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 WSL preflight failure on the primary only happens in wsl-only mode. // Fatal configuration failures persist the Windows fallback. Bounded // transport failures use an in-memory fallback for this launch so the app // opens without overwriting the user's WSL preference. const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( function* (failure: DesktopBackendManager.PreflightFailure) { const { reason, fatal } = failure; if (!fatal) { yield* logBackendPoolWarning( "primary WSL preflight retry window exhausted; using Windows for this launch", { reason }, ); yield* electronDialog.showErrorBox( "WSL backend is still unavailable", `${reason}\n\nT3 Code will use the Windows backend for this launch and retry WSL the next time the app starts.`, ); yield* appSettings.applyWslWindowsFallbackInMemory; return true; } 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)), ), ); return true; }, ); 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, { _tag: "Active", instance: primary, scope: Option.none() }, ], ]), ); const register: DesktopBackendPool["Service"]["register"] = (spec) => Effect.suspend(() => SynchronizedRef.modifyEffect( instancesRef, ( current, ): Effect.Effect< readonly [RegisterAction, ReadonlyMap], DesktopBackendPoolInstanceAlreadyRegisteredError > => { const existing = current.get(spec.id); if (existing?._tag === "Active") { return Effect.fail( new DesktopBackendPoolInstanceAlreadyRegisteredError({ id: spec.id }), ); } if (existing?._tag === "Closing") { return Effect.succeed([ { _tag: "Wait", done: existing.done } as const, current, ] as const); } return Effect.gen(function* () { // Provide the captured factory services first, then the child scope // last so instance finalizers are owned by the unregisterable scope. const instanceScope = yield* Scope.fork(layerScope, "sequential"); const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( Effect.provide(factoryContext), Scope.provide(instanceScope), ); const next = new Map(current); next.set(spec.id, { _tag: "Active", instance, scope: Option.some(instanceScope), }); return [ { _tag: "Registered", instance } as const, next as ReadonlyMap, ] as const; }); }, ).pipe( Effect.flatMap((result) => result._tag === "Registered" ? Effect.succeed(result.instance) : Deferred.await(result.done).pipe(Effect.andThen(register(spec))), ), ), ); const unregister: DesktopBackendPool["Service"]["unregister"] = (id) => Effect.gen(function* () { if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); } const done = yield* Deferred.make(); const action = yield* SynchronizedRef.modifyEffect( instancesRef, ( current, ): Effect.Effect< readonly [UnregisterAction, ReadonlyMap] > => { const entry = current.get(id); if (entry === undefined) { return Effect.succeed([{ _tag: "Absent" } as const, current] as const); } if (entry._tag === "Closing") { return Effect.succeed([ { _tag: "Wait", done: entry.done } as const, current, ] as const); } const next = new Map(current); next.set(id, { _tag: "Closing", done }); return Effect.succeed([ { _tag: "Close", entry } as const, next as ReadonlyMap, ] as const); }, ); if (action._tag === "Absent") return; if (action._tag === "Wait") { yield* Deferred.await(action.done); return; } const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { const closing = current.get(id); if (closing?._tag !== "Closing" || closing.done !== done) { return Effect.succeed([undefined, current] as const); } const next = new Map(current); next.delete(id); return Effect.succeed([ undefined, next as ReadonlyMap, ] as const); }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid); yield* Option.match(action.entry.scope, { onNone: () => Effect.void, onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), }).pipe(Effect.ensuring(finish)); }); return DesktopBackendPool.of({ get: (id) => SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => { const entry = instances.get(id); return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); }), ), list: SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => Array.from(instances.values()).flatMap((entry) => entry._tag === "Active" ? [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"), }); }), );