| 1 | // Orchestrator that keeps the WSL pool instance in sync with the user's |
| 2 | // settings. `reconcile` is the single entry point — bootstrap calls it |
| 3 | // once after the primary backend starts, and the wsl.ts IPC calls it |
| 4 | // after persisting a `wslBackendEnabled` or `wslDistro` change. The |
| 5 | // effect is idempotent and never fails: errors (WSL not available, port |
| 6 | // allocation failed, register failed) get logged and reconcile returns |
| 7 | // having left the pool in a consistent state (either the previous WSL |
| 8 | // instance is still running, or none is). |
| 9 | // |
| 10 | // The instance id encodes the desired distro selection — `wsl:default` |
| 11 | // when the user picked "track the WSL default" (settings.wslDistro is |
| 12 | // null) and `wsl:<distro>` otherwise. Changing the distro setting |
| 13 | // changes the id, so reconcile unregisters the old instance before |
| 14 | // registering the new one. The label that the frontend env switcher |
| 15 | // renders is derived from the same field. |
| 16 | // |
| 17 | // Port allocation: each WSL instance gets a freshly scanned port to |
| 18 | // avoid colliding with the primary or with a previously-registered WSL |
| 19 | // instance that's still tearing down. The scan only checks loopback |
| 20 | // (127.0.0.1) since the WSL backend is loopback-only — the primary |
| 21 | // owns LAN exposure when the user opts in. |
| 22 | |
| 23 | import * as Cause from "effect/Cause"; |
| 24 | import * as Context from "effect/Context"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as Layer from "effect/Layer"; |
| 27 | import * as Option from "effect/Option"; |
| 28 | import * as Ref from "effect/Ref"; |
| 29 | import * as Semaphore from "effect/Semaphore"; |
| 30 | |
| 31 | import * as NetService from "@t3tools/shared/Net"; |
| 32 | |
| 33 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 34 | import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; |
| 35 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; |
| 36 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 37 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 38 | import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; |
| 39 | |
| 40 | // Exported so callers that parse pool ids (e.g. the pickFolder IPC |
| 41 | // handler in ipc/methods/window.ts) reference the same prefix this |
| 42 | // module produces. Keeping it inline in two places risks silent |
| 43 | // divergence if one ever gets renamed. |
| 44 | export const WSL_INSTANCE_ID_PREFIX = "wsl:"; |
| 45 | const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; |
| 46 | const MAX_TCP_PORT = 65_535; |
| 47 | |
| 48 | export class DesktopWslBackend extends Context.Service< |
| 49 | DesktopWslBackend, |
| 50 | { |
| 51 | // Bring the pool in line with the current persisted WSL settings. |
| 52 | // Idempotent. Never fails (errors are logged); callers can chain it |
| 53 | // after persisting settings without an error-handling dance. |
| 54 | readonly reconcile: Effect.Effect<void>; |
| 55 | // Reason the dual-mode WSL secondary last failed preflight (no node, wrong |
| 56 | // version, missing build tools), or None. Read by the getWslState IPC so |
| 57 | // Connections settings can show it inline. None in wsl-only mode (that path |
| 58 | // surfaces via a dialog + Windows fallback). |
| 59 | readonly lastPreflightError: Effect.Effect<Option.Option<string>>; |
| 60 | } |
| 61 | >()("@t3tools/desktop/wsl/DesktopWslBackend") {} |
| 62 | |
| 63 | const { logInfo: logWslBackendInfo, logWarning: logWslBackendWarning } = |
| 64 | DesktopObservability.makeComponentLogger("desktop-wsl-backend"); |
| 65 | |
| 66 | const resolveTargetInstanceId = (distro: string | null): DesktopBackendPool.BackendInstanceId => |
| 67 | DesktopBackendPool.BackendInstanceId( |
| 68 | distro === null ? WSL_DEFAULT_DISTRO_ID : `${WSL_INSTANCE_ID_PREFIX}${distro}`, |
| 69 | ); |
| 70 | |
| 71 | const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean => |
| 72 | id.startsWith(WSL_INSTANCE_ID_PREFIX); |
| 73 | |
| 74 | const buildLabel = (distro: string | null): string => |
| 75 | distro === null ? "WSL (default distro)" : `WSL (${distro})`; |
| 76 | |
| 77 | // Loopback-only port scan starting one above the primary's port. The |
| 78 | // WSL backend is reachable via 127.0.0.1 from Windows (wslhost |
| 79 | // auto-forwards), so we only need to verify the IPv4 loopback can bind. |
| 80 | const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( |
| 81 | startPort: number, |
| 82 | ): Effect.fn.Return<number, NetService.NetError, NetService.NetService> { |
| 83 | const net = yield* NetService.NetService; |
| 84 | for (let port = startPort; port <= MAX_TCP_PORT; port += 1) { |
| 85 | if (yield* net.canListenOnHost(port, "127.0.0.1")) { |
| 86 | return port; |
| 87 | } |
| 88 | } |
| 89 | return yield* new NetService.NetError({ |
| 90 | message: `No loopback port available for WSL backend between ${startPort} and ${MAX_TCP_PORT}.`, |
| 91 | }); |
| 92 | }); |
| 93 | |
| 94 | export const layer = Layer.effect( |
| 95 | DesktopWslBackend, |
| 96 | Effect.gen(function* () { |
| 97 | const pool = yield* DesktopBackendPool.DesktopBackendPool; |
| 98 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 99 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 100 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; |
| 101 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 102 | const net = yield* NetService.NetService; |
| 103 | // Serialize reconcile so the bootstrap fork and the IPC handlers |
| 104 | // (setWslBackendEnabled, setWslDistro) can't interleave. Without |
| 105 | // this, two reconciles could both observe "no WSL instance |
| 106 | // registered" between their pool reads and both call startNew |
| 107 | // with different distros, leaving the loser stranded. |
| 108 | const reconcileMutex = yield* Semaphore.make(1); |
| 109 | |
| 110 | // Last fatal preflight failure from the dual-mode WSL *secondary*, surfaced |
| 111 | // inline in Connections settings. The primary's failure is handled by the |
| 112 | // pool (dialog + Windows fallback) instead; here the app stays usable on |
| 113 | // Windows, so we record the reason rather than interrupting. Cleared on any |
| 114 | // reconcile state change so it reflects the current attempt. |
| 115 | const preflightErrorRef = yield* Ref.make(Option.none<string>()); |
| 116 | const ownershipRefusedRef = yield* Ref.make( |
| 117 | Option.none<DesktopBackendPool.BackendInstanceId>(), |
| 118 | ); |
| 119 | |
| 120 | const findExistingWslInstance = pool.list.pipe( |
| 121 | Effect.map((instances) => instances.find((instance) => isWslInstanceId(instance.id))), |
| 122 | Effect.map(Option.fromNullishOr), |
| 123 | ); |
| 124 | |
| 125 | const stopExisting = (id: DesktopBackendPool.BackendInstanceId) => |
| 126 | pool.unregister(id).pipe( |
| 127 | Effect.catchTags({ |
| 128 | DesktopBackendPoolCannotUnregisterPrimaryError: (cause) => |
| 129 | // Should never happen — wsl: ids are not the primary id — but |
| 130 | // log loudly if the logic ever drifts. |
| 131 | logWslBackendWarning("refusing to unregister primary as wsl instance", { |
| 132 | id, |
| 133 | error: cause.message, |
| 134 | }), |
| 135 | }), |
| 136 | ); |
| 137 | |
| 138 | const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: { |
| 139 | readonly distro: string | null; |
| 140 | }) { |
| 141 | const primaryConfig = yield* serverExposure.backendConfig; |
| 142 | const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( |
| 143 | Effect.provideService(NetService.NetService, net), |
| 144 | Effect.asSome, |
| 145 | Effect.catch((error) => |
| 146 | logWslBackendWarning("could not allocate port for WSL backend", { |
| 147 | error: error.message, |
| 148 | }).pipe(Effect.as(Option.none<number>())), |
| 149 | ), |
| 150 | ); |
| 151 | |
| 152 | if (Option.isNone(port)) { |
| 153 | return; |
| 154 | } |
| 155 | const allocatedPort = port.value; |
| 156 | |
| 157 | const targetId = resolveTargetInstanceId(input.distro); |
| 158 | yield* logWslBackendInfo("registering WSL backend with pool", { |
| 159 | id: targetId, |
| 160 | port: allocatedPort, |
| 161 | distro: input.distro ?? null, |
| 162 | }); |
| 163 | |
| 164 | const instance = yield* pool |
| 165 | .register({ |
| 166 | id: targetId, |
| 167 | label: Effect.succeed(buildLabel(input.distro)), |
| 168 | configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), |
| 169 | // Dual-mode secondary: record a fatal preflight failure so Connections |
| 170 | // settings can show why the WSL backend never appeared. No dialog or |
| 171 | // fallback — Windows is the primary and keeps working. |
| 172 | onPreflightFailed: (failure) => |
| 173 | Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), |
| 174 | onStateDirOwned: () => |
| 175 | Ref.set(ownershipRefusedRef, Option.some(targetId)).pipe( |
| 176 | Effect.andThen( |
| 177 | Ref.set( |
| 178 | preflightErrorRef, |
| 179 | Option.some( |
| 180 | "Another The Knife server is using this distro's T3 home. Stop it, or pair with it instead.", |
| 181 | ), |
| 182 | ), |
| 183 | ), |
| 184 | ), |
| 185 | onReady: () => |
| 186 | Ref.set(ownershipRefusedRef, Option.none()).pipe( |
| 187 | Effect.andThen(Ref.set(preflightErrorRef, Option.none())), |
| 188 | ), |
| 189 | }) |
| 190 | .pipe( |
| 191 | Effect.asSome, |
| 192 | Effect.catch((error) => |
| 193 | logWslBackendWarning("WSL backend already registered, skipping start", { |
| 194 | id: targetId, |
| 195 | error: error.message, |
| 196 | }).pipe(Effect.as(Option.none<DesktopBackendPool.DesktopBackendInstance>())), |
| 197 | ), |
| 198 | ); |
| 199 | |
| 200 | yield* Option.match(instance, { |
| 201 | onNone: () => Effect.void, |
| 202 | onSome: (registered) => registered.start, |
| 203 | }); |
| 204 | }); |
| 205 | |
| 206 | const reconcileBody = Effect.gen(function* () { |
| 207 | const settings = yield* appSettings.get; |
| 208 | if (!settings.localEnvironmentEnabled) return; |
| 209 | const available = yield* wslEnvironment.isAvailable; |
| 210 | const existing = yield* findExistingWslInstance; |
| 211 | const existingId = Option.map(existing, (instance) => instance.id); |
| 212 | |
| 213 | // In wsl-only mode the pool's primary IS the WSL backend (see |
| 214 | // DesktopBackendConfiguration.resolvePrimary), so the |
| 215 | // orchestrator skips registering a parallel "wsl:<distro>" |
| 216 | // secondary. Without this skip we'd spin up two WSL processes |
| 217 | // on the same distro for users who explicitly asked for one. |
| 218 | const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly; |
| 219 | const targetId = shouldRun |
| 220 | ? Option.some(resolveTargetInstanceId(settings.wslDistro)) |
| 221 | : Option.none<DesktopBackendPool.BackendInstanceId>(); |
| 222 | |
| 223 | // No-op if the desired state already matches what's registered. |
| 224 | if (Option.isNone(targetId) && Option.isNone(existingId)) { |
| 225 | return; |
| 226 | } |
| 227 | if ( |
| 228 | Option.isSome(targetId) && |
| 229 | Option.isSome(existing) && |
| 230 | targetId.value === existing.value.id |
| 231 | ) { |
| 232 | const existingInstance = existing.value; |
| 233 | const snapshot = yield* existingInstance.snapshot; |
| 234 | const isIdle = |
| 235 | !snapshot.ready && Option.isNone(snapshot.activePid) && !snapshot.restartScheduled; |
| 236 | if (isIdle) { |
| 237 | const refused = yield* Ref.get(ownershipRefusedRef); |
| 238 | if (Option.isSome(refused) && refused.value === existingInstance.id) return; |
| 239 | yield* logWslBackendInfo("retrying idle WSL backend", { id: existingInstance.id }); |
| 240 | yield* Ref.set(preflightErrorRef, Option.none()); |
| 241 | yield* existingInstance.start; |
| 242 | } |
| 243 | return; |
| 244 | } |
| 245 | |
| 246 | // A real state change is happening (start, stop, or distro swap). Clear |
| 247 | // any stale secondary preflight error so it reflects this fresh attempt; |
| 248 | // onPreflightFailed re-sets it only if the new secondary exhausts retries. |
| 249 | yield* Ref.set(preflightErrorRef, Option.none()); |
| 250 | yield* Ref.set(ownershipRefusedRef, Option.none()); |
| 251 | |
| 252 | if (Option.isSome(existingId)) { |
| 253 | yield* logWslBackendInfo("tearing down WSL backend", { id: existingId.value }); |
| 254 | yield* stopExisting(existingId.value); |
| 255 | } |
| 256 | |
| 257 | if (Option.isSome(targetId)) { |
| 258 | // Pre-warm the WSL VM before registering so the readiness probe |
| 259 | // doesn't race wsl.exe's first-spawn cold start. preWarm tolerates |
| 260 | // distro=null (uses the WSL default) and is bounded by its own |
| 261 | // timeout, so it's safe to await unconditionally here. |
| 262 | yield* wslEnvironment.preWarm(settings.wslDistro); |
| 263 | yield* startNew({ distro: settings.wslDistro }); |
| 264 | } |
| 265 | }); |
| 266 | |
| 267 | // Top-level safety net. Every internal step today already catches |
| 268 | // its own failures (port allocation, register, preWarm), so the |
| 269 | // inferred error type is `never` and this catch is a no-op in |
| 270 | // steady state. It's here to enforce the file-header contract |
| 271 | // ("reconcile never fails; errors are logged") if a future change |
| 272 | // introduces an unhandled failure path — otherwise IPC callers |
| 273 | // like setWslBackendEnabled would surface it to the renderer as |
| 274 | // an opaque error. |
| 275 | const reconcile = reconcileMutex |
| 276 | .withPermits(1)(reconcileBody) |
| 277 | .pipe( |
| 278 | Effect.catchCause((cause) => |
| 279 | logWslBackendWarning("reconcile failed", { cause: Cause.pretty(cause) }), |
| 280 | ), |
| 281 | Effect.withSpan("desktop.wslBackend.reconcile"), |
| 282 | ); |
| 283 | |
| 284 | return DesktopWslBackend.of({ |
| 285 | reconcile, |
| 286 | lastPreflightError: Ref.get(preflightErrorRef), |
| 287 | }); |
| 288 | }), |
| 289 | ); |