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
23import * as Cause from "effect/Cause";
24import * as Context from "effect/Context";
25import * as Effect from "effect/Effect";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as Ref from "effect/Ref";
29import * as Semaphore from "effect/Semaphore";
30
31import * as NetService from "@t3tools/shared/Net";
32
33import * as DesktopObservability from "../app/DesktopObservability.ts";
34import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts";
35import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
36import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
37import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
38import * 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.
44export const WSL_INSTANCE_ID_PREFIX = "wsl:";
45const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`;
46const MAX_TCP_PORT = 65_535;
47
48export 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
63const { logInfo: logWslBackendInfo, logWarning: logWslBackendWarning } =
64 DesktopObservability.makeComponentLogger("desktop-wsl-backend");
65
66const resolveTargetInstanceId = (distro: string | null): DesktopBackendPool.BackendInstanceId =>
67 DesktopBackendPool.BackendInstanceId(
68 distro === null ? WSL_DEFAULT_DISTRO_ID : `${WSL_INSTANCE_ID_PREFIX}${distro}`,
69 );
70
71const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean =>
72 id.startsWith(WSL_INSTANCE_ID_PREFIX);
73
74const 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.
80const 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
94export 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);