// 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 class DesktopWslBackend extends Context.Service< DesktopWslBackend, { // 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>; } >()("@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.catchTags({ 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: (failure) => Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), 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), }); }), );