1// Pool registry for multiple backend processes. This file is the entry
2// point for the concurrent-Windows+WSL-backend feature; see the design
3// notes below before extending it.
4//
5// Current state:
6// - `DesktopBackendManager.ts` exposes a per-instance factory
7// (`makeBackendInstance(spec)`); the pool calls it once for the
8// Windows primary at startup, and `DesktopWslBackend.reconcile`
9// calls it through `pool.register` to bring up the WSL instance
10// when the user enables it.
11// - The primary spec wires `configResolve` to
12// `DesktopBackendConfiguration.resolvePrimary` and the
13// `onReady`/`onShutdown` callbacks to the window service. WSL
14// instances wire `configResolve: configuration.resolveWsl(...)`
15// and skip onReady/onShutdown — the window only follows the primary.
16// - The pool exposes `register(spec)` and `unregister(id)`. Each
17// registered instance gets its own child scope, so unregister can
18// stop it cleanly without tearing down the pool. The primary's id
19// refuses unregister.
20// - Settings: `wslBackendEnabled: boolean` + `wslDistro: string | null`.
21// The legacy `wslMode: "local" | "wsl"` swap setting is migrated on
22// load. IPC surface is `setWslBackendEnabled(boolean)` +
23// `setWslDistro(string | null)`; both persist and then call the
24// orchestrator's reconcile. No swap, no rollback, primary stays up.
25// - `getLocalEnvironmentBootstraps()` (plural) returns one entry per
26// pool instance currently registered with bootstrap info. The
27// primary keeps the "primary" id; WSL instances are "wsl:default"
28// or "wsl:<distro>".
29// - `pickFolder` accepts an optional `targetEnvironmentId`. Omitting
30// it gives the Windows picker — what every existing caller gets,
31// and what non-WSL users see. WSL targets route to the wsl helpers.
32// - Web settings UX: a plain toggle for "WSL backend" plus a distro
33// picker that shows up when the toggle is on. Default-off, so
34// users who never opted in see the same surface as before.
35//
36// Renderer-side wiring (apps/web/src/environments/local/):
37// - reconcileLocalSecondaryEnvironments() runs at app boot and after
38// WSL settings changes. It reads getLocalEnvironmentBootstraps(),
39// skips the primary (which the existing primary/ runtime owns),
40// and for every other entry POSTs the shared bootstrap token to
41// /api/auth/bootstrap/bearer on that backend's URL, fetches the
42// descriptor, builds a SavedEnvironmentRecord marked desktopLocal,
43// writes the bearer to the secret store, and opens a connection
44// through the same saved-env path remote envs use.
45// - The desktopLocal marker filters records out of saved-env
46// persistence, so toggling WSL off or switching distros doesn't
47// pollute the user's settings file. The sidebar, CommandPalette,
48// env switcher, and project-id routing all read the saved-env
49// registry, so the WSL backend shows up there without any
50// per-surface changes.
51//
52// Browser validation (2026-05-17, dev:desktop with wslBackendEnabled=true,
53// wslDistro="Ubuntu"):
54// - Two backends listening on distinct loopback ports
55// (server.log: 13773 primary, 13774 wsl).
56// - Per-instance log files: server-child.log + server-child-wsl_Ubuntu.log.
57// - Distinct environment ids reported by each backend's
58// /.well-known/t3/environment (Windows vs Linux platform).
59// - Renderer completes the bearer-token bootstrap against the WSL
60// backend (POST /api/auth/bootstrap/bearer 200), obtains a
61// ws-token (POST /api/auth/ws-token 200), and holds an
62// ESTABLISHED WebSocket connection to both ports (netstat).
63//
64// Migration history (commits):
65// 1. Reshape `DesktopBackendManager` into an instance factory and route
66// consumers through the pool. Pool held a single instance. (a8fc7845)
67// 2. Drop `DesktopState.backendReady`. The window owns its own
68// readiness latch via onReady / onShutdown callbacks. (425c7d0b)
69// 3. Per-instance log routing via DesktopBackendOutputLogFactory. (563820ed)
70// 4. Add register/unregister to the pool. (a0eaf560)
71// 5. Wire WSL through the pool: settings rename, BackendConfiguration
72// split, DesktopWslBackend orchestrator, new IPC, web compat.
73// (b1622191 + 31ce3add + 627c80cb)
74// 6. Widen getLocalEnvironmentBootstrap to *Bootstraps (plural). (bad66041)
75// 7. pickFolder takes optional targetEnvironmentId. (5d80468d)
76// 8. Settings UX: toggle + distro picker, no swap dialog. (eb5a03ea)
77// 9. Register WSL backend as desktop-local saved env via
78// reconcileLocalSecondaryEnvironments. (1c7e7873 + c17897bd)
79// 10. CommandPalette enables file-manager picker for desktop-local
80// envs, routes pickFolder by env id. (38e8477a)
81
82import * as Context from "effect/Context";
83import * as Deferred from "effect/Deferred";
84import * as Effect from "effect/Effect";
85import * as Exit from "effect/Exit";
86import * as Layer from "effect/Layer";
87import * as Option from "effect/Option";
88import * as Schema from "effect/Schema";
89import * as Scope from "effect/Scope";
90import * as SynchronizedRef from "effect/SynchronizedRef";
91
92import * as FileSystem from "effect/FileSystem";
93import { HttpClient } from "effect/http";
94import { ChildProcessSpawner } from "effect/process";
95
96import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
97import * as DesktopBackendManager from "./DesktopBackendManager.ts";
98import * as DesktopObservability from "../app/DesktopObservability.ts";
99import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
101import * as DesktopTerminalHost from "./DesktopTerminalHost.ts";
102import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
103import * as DesktopWindow from "../window/DesktopWindow.ts";
104import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
105import * as ElectronDialog from "../electron/ElectronDialog.ts";
106import * as ElectronApp from "../electron/ElectronApp.ts";
107
108const { logWarning: logBackendPoolWarning } =
109 DesktopObservability.makeComponentLogger("desktop-backend-pool");
110
111export type BackendInstanceId = DesktopBackendManager.BackendInstanceId;
112export const BackendInstanceId = DesktopBackendManager.BackendInstanceId;
113export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID;
114export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance;
115export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec;
116
117// Caller tried to register an id that's already in the pool. The pool
118// refuses overwrites so two independent orchestrators racing on the
119// same id surface as a typed failure instead of one silently winning.
120export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.TaggedError<DesktopBackendPoolInstanceAlreadyRegisteredError>()(
121 "DesktopBackendPoolInstanceAlreadyRegisteredError",
122 {
123 id: Schema.String,
124 },
125) {
126 override get message() {
127 return `Backend instance "${this.id}" is already registered in the pool.`;
128 }
129}
130
131// Primary instance is registered for the pool's lifetime. Unregister is
132// a no-op for it today (no real callers), but if someone wires it up
133// later it's a clear bug rather than something to "handle".
134export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.TaggedError<DesktopBackendPoolCannotUnregisterPrimaryError>()(
135 "DesktopBackendPoolCannotUnregisterPrimaryError",
136 {},
137) {
138 override get message() {
139 return "Refusing to unregister the primary backend from the pool.";
140 }
141}
142
143export class DesktopBackendPool extends Context.Service<
144 DesktopBackendPool,
145 {
146 // Look up a registered instance. None when no backend with that id is
147 // currently registered (e.g. WSL backend disabled).
148 readonly get: (id: BackendInstanceId) => Effect.Effect<Option.Option<DesktopBackendInstance>>;
149 // Snapshot of all currently-registered instances. Order is unspecified;
150 // callers that need a canonical "primary first" view should sort by id.
151 readonly list: Effect.Effect<readonly DesktopBackendInstance[]>;
152 // Convenience accessor for the always-registered primary instance.
153 // Currently equivalent to `get(PRIMARY_INSTANCE_ID)` unwrapped, but
154 // exposed as a typed effect so consumers don't have to handle the
155 // Option for the case that's guaranteed to be present.
156 readonly primary: Effect.Effect<DesktopBackendInstance>;
157 // Build a fresh DesktopBackendInstance from `spec` and add it to the
158 // registry. The pool owns the instance's scope: unregister(id) or pool
159 // teardown closes it and runs the instance's auto-stop finalizer. The
160 // returned instance has not been started — callers decide when to
161 // start it (and can call start more than once if a retry-after-failure
162 // story makes sense for them).
163 readonly register: (
164 spec: BackendInstanceSpec,
165 ) => Effect.Effect<DesktopBackendInstance, DesktopBackendPoolInstanceAlreadyRegisteredError>;
166 // Stop the named instance and remove it from the registry. Closing the
167 // instance's scope triggers its auto-stop finalizer; the registry is
168 // updated atomically with the scope close so subsequent get(id) calls
169 // observe the unregister before the underlying child process has fully
170 // exited.
171 readonly unregister: (
172 id: BackendInstanceId,
173 ) => Effect.Effect<void, DesktopBackendPoolCannotUnregisterPrimaryError>;
174 }
175>()("@t3tools/desktop/backend/DesktopBackendPool") {}
176
177// Services required by makeBackendInstance — exported so caller
178// orchestrators that build their own specs can confirm the layer graph
179// satisfies them at compile time.
180export type BackendInstanceFactoryRequirements =
181 | FileSystem.FileSystem
182 | ChildProcessSpawner.ChildProcessSpawner
183 | HttpClient.HttpClient
184 | DesktopObservability.DesktopBackendOutputLogFactory
185 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
186 | DesktopTerminalHost.DesktopTerminalHost
187 | DesktopBrowserHost.DesktopBrowserHost
188 | DesktopWslEnvironment.DesktopWslEnvironment;
189
190interface ActiveRegisteredInstance {
191 readonly _tag: "Active";
192 readonly instance: DesktopBackendInstance;
193 // None for the primary (which lives in the pool's own layer scope and
194 // is never unregistered); Some for instances added via register, whose
195 // scope unregister closes to stop them.
196 readonly scope: Option.Option<Scope.Closeable>;
197}
198
199interface ClosingRegisteredInstance {
200 readonly _tag: "Closing";
201 readonly done: Deferred.Deferred<void>;
202}
203
204type RegisteredInstance = ActiveRegisteredInstance | ClosingRegisteredInstance;
205
206type RegisterAction =
207 | { readonly _tag: "Registered"; readonly instance: DesktopBackendInstance }
208 | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> };
209
210type UnregisterAction =
211 | { readonly _tag: "Absent" }
212 | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> }
213 | { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance };
214
215export const layer = Layer.effect(
216 DesktopBackendPool,
217 Effect.gen(function* () {
218 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
219 const desktopWindow = yield* DesktopWindow.DesktopWindow;
220 const electronDialog = yield* ElectronDialog.ElectronDialog;
221 const electronApp = yield* ElectronApp.ElectronApp;
222 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
223 // Anchor the pool's lifetime to its layer scope so registered
224 // instance scopes can be forked off it. Without this, instance
225 // scopes are orphaned: they only close via explicit unregister()
226 // calls, so on app shutdown the WSL backend child process gets
227 // hard-killed by the OS instead of receiving the graceful
228 // SIGTERM + grace period the instance's stop finalizer would
229 // otherwise run.
230 const layerScope = yield* Scope.Scope;
231 // Capture the services needed to build any future instance from the
232 // pool's layer scope. register() runs `makeBackendInstance` against
233 // a fresh child scope but reuses these services so the instance gets
234 // the same FileSystem, spawner, HTTP client and log factory the
235 // primary instance uses.
236 const factoryContext = yield* Effect.context<BackendInstanceFactoryRequirements>();
237
238 // A WSL preflight failure on the primary only happens in wsl-only mode.
239 // Fatal configuration failures persist the Windows fallback. Bounded
240 // transport failures use an in-memory fallback for this launch so the app
241 // opens without overwriting the user's WSL preference.
242 const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")(
243 function* (failure: DesktopBackendManager.PreflightFailure) {
244 const { reason, fatal } = failure;
245 if (!fatal) {
246 yield* logBackendPoolWarning(
247 "primary WSL preflight retry window exhausted; using Windows for this launch",
248 { reason },
249 );
250 yield* electronDialog.showErrorBox(
251 "WSL backend is still unavailable",
252 `${reason}\n\nThe Knife will use the Windows backend for this launch and retry WSL the next time the app starts.`,
253 );
254 yield* appSettings.applyWslWindowsFallbackInMemory;
255 return true;
256 }
257
258 yield* logBackendPoolWarning("primary WSL preflight failed; falling back to Windows", {
259 reason,
260 });
261 yield* electronDialog.showErrorBox(
262 "WSL backend couldn't start",
263 `${reason}\n\nFalling back to the Windows backend so The Knife can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`,
264 );
265 // Fully disable the WSL backend — both flags, matching the "Switch to
266 // Windows" recovery path — so the manager's next restart re-resolves the
267 // primary as Windows and reconcile won't register a secondary WSL backend
268 // against the same broken setup. Clearing wslBackendEnabled alone would
269 // leave a stale wslOnly:true that silently re-traps the user in wsl-only
270 // mode the next time they enable WSL. If the persisted write fails, keep
271 // this process recoverable by applying the fallback to in-memory settings.
272 yield* appSettings.applyWslWindowsFallback.pipe(
273 Effect.catch((error) =>
274 logBackendPoolWarning(
275 "failed to persist Windows fallback after WSL preflight failure",
276 {
277 error: error.message,
278 },
279 ).pipe(Effect.andThen(appSettings.applyWslWindowsFallbackInMemory)),
280 ),
281 );
282 return true;
283 },
284 );
285
286 const primary = yield* DesktopBackendManager.makeBackendInstance({
287 id: DesktopBackendManager.PRIMARY_INSTANCE_ID,
288 // Keep this lazy. The pool layer is initialized before startup loads
289 // persisted desktop settings, so resolving the primary label here would
290 // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only
291 // primaries as Windows.
292 label: configuration.resolvePrimaryLabel,
293 configResolve: configuration.resolvePrimary,
294 // Window creation errors propagating out of handleBackendReady must
295 // not block the readiness callback (that would prevent restartAttempt
296 // from being reset), so we absorb them here. The window service only
297 // logs on success, so log the failure here before swallowing it —
298 // otherwise a post-readiness window-open failure vanishes silently and
299 // is near-impossible to diagnose in production.
300 onReady: (httpBaseUrl) =>
301 desktopWindow.handleBackendReady(httpBaseUrl).pipe(
302 Effect.catch((error) =>
303 logBackendPoolWarning("failed to open main window after backend readiness", {
304 error: error.message,
305 }),
306 ),
307 ),
308 onShutdown: () => desktopWindow.handleBackendNotReady,
309 onPreflightFailed: handlePrimaryPreflightFailure,
310 onStateDirOwned: () =>
311 electronDialog
312 .showErrorBox(
313 "This T3 home is unavailable",
314 "Another The Knife server, such as the background service, is using this data directory. Stop that server and reopen The Knife, or set a separate T3CODE_HOME and pair with the running server.",
315 )
316 .pipe(Effect.andThen(electronApp.quit)),
317 });
318
319 const instancesRef = yield* SynchronizedRef.make<
320 ReadonlyMap<BackendInstanceId, RegisteredInstance>
321 >(
322 new Map([
323 [
324 DesktopBackendManager.PRIMARY_INSTANCE_ID,
325 { _tag: "Active", instance: primary, scope: Option.none() },
326 ],
327 ]),
328 );
329
330 const register: DesktopBackendPool["Service"]["register"] = (spec) =>
331 Effect.suspend(() =>
332 SynchronizedRef.modifyEffect(
333 instancesRef,
334 (
335 current,
336 ): Effect.Effect<
337 readonly [RegisterAction, ReadonlyMap<BackendInstanceId, RegisteredInstance>],
338 DesktopBackendPoolInstanceAlreadyRegisteredError
339 > => {
340 const existing = current.get(spec.id);
341 if (existing?._tag === "Active") {
342 return Effect.fail(
343 new DesktopBackendPoolInstanceAlreadyRegisteredError({ id: spec.id }),
344 );
345 }
346 if (existing?._tag === "Closing") {
347 return Effect.succeed([
348 { _tag: "Wait", done: existing.done } as const,
349 current,
350 ] as const);
351 }
352 return Effect.gen(function* () {
353 // Provide the captured factory services first, then the child scope
354 // last so instance finalizers are owned by the unregisterable scope.
355 const instanceScope = yield* Scope.fork(layerScope, "sequential");
356 const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe(
357 Effect.provide(factoryContext),
358 Scope.provide(instanceScope),
359 );
360 const next = new Map(current);
361 next.set(spec.id, {
362 _tag: "Active",
363 instance,
364 scope: Option.some(instanceScope),
365 });
366 return [
367 { _tag: "Registered", instance } as const,
368 next as ReadonlyMap<BackendInstanceId, RegisteredInstance>,
369 ] as const;
370 });
371 },
372 ).pipe(
373 Effect.flatMap((result) =>
374 result._tag === "Registered"
375 ? Effect.succeed(result.instance)
376 : Deferred.await(result.done).pipe(Effect.andThen(register(spec))),
377 ),
378 ),
379 );
380
381 const unregister: DesktopBackendPool["Service"]["unregister"] = (id) =>
382 Effect.gen(function* () {
383 if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) {
384 return yield* new DesktopBackendPoolCannotUnregisterPrimaryError();
385 }
386 const done = yield* Deferred.make<void>();
387 const action = yield* SynchronizedRef.modifyEffect(
388 instancesRef,
389 (
390 current,
391 ): Effect.Effect<
392 readonly [UnregisterAction, ReadonlyMap<BackendInstanceId, RegisteredInstance>]
393 > => {
394 const entry = current.get(id);
395 if (entry === undefined) {
396 return Effect.succeed([{ _tag: "Absent" } as const, current] as const);
397 }
398 if (entry._tag === "Closing") {
399 return Effect.succeed([
400 { _tag: "Wait", done: entry.done } as const,
401 current,
402 ] as const);
403 }
404 const next = new Map(current);
405 next.set(id, { _tag: "Closing", done });
406 return Effect.succeed([
407 { _tag: "Close", entry } as const,
408 next as ReadonlyMap<BackendInstanceId, RegisteredInstance>,
409 ] as const);
410 },
411 );
412
413 if (action._tag === "Absent") return;
414 if (action._tag === "Wait") {
415 yield* Deferred.await(action.done);
416 return;
417 }
418
419 const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => {
420 const closing = current.get(id);
421 if (closing?._tag !== "Closing" || closing.done !== done) {
422 return Effect.succeed([undefined, current] as const);
423 }
424 const next = new Map(current);
425 next.delete(id);
426 return Effect.succeed([
427 undefined,
428 next as ReadonlyMap<BackendInstanceId, RegisteredInstance>,
429 ] as const);
430 }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid);
431 yield* Option.match(action.entry.scope, {
432 onNone: () => Effect.void,
433 onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore),
434 }).pipe(Effect.ensuring(finish));
435 });
436
437 return DesktopBackendPool.of({
438 get: (id) =>
439 SynchronizedRef.get(instancesRef).pipe(
440 Effect.map((instances) => {
441 const entry = instances.get(id);
442 return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none();
443 }),
444 ),
445 list: SynchronizedRef.get(instancesRef).pipe(
446 Effect.map((instances) =>
447 Array.from(instances.values()).flatMap((entry) =>
448 entry._tag === "Active" ? [entry.instance] : [],
449 ),
450 ),
451 ),
452 primary: Effect.succeed(primary),
453 register,
454 unregister,
455 });
456 }),
457);
458
459// Test layer for unit tests that want to assert against a known pool
460// composition without standing up the full manager. Each provided
461// instance is registered under its own id; the first one is also
462// surfaced as `primary` so callers can stub a single-instance pool.
463// `register` and `unregister` are stubbed to die so tests that
464// accidentally exercise pool registration fail loudly instead of
465// silently noop'ing.
466export const layerTest = (
467 instances: readonly DesktopBackendInstance[],
468): Layer.Layer<DesktopBackendPool> =>
469 Layer.effect(
470 DesktopBackendPool,
471 Effect.gen(function* () {
472 if (instances.length === 0) {
473 return yield* Effect.die("DesktopBackendPool.layerTest requires at least one instance");
474 }
475 const byId = new Map<BackendInstanceId, DesktopBackendInstance>(
476 instances.map((instance) => [instance.id, instance] as const),
477 );
478 const primary = instances[0]!;
479 return DesktopBackendPool.of({
480 get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))),
481 list: Effect.succeed(Array.from(byId.values())),
482 primary: Effect.succeed(primary),
483 register: () => Effect.die("DesktopBackendPool.layerTest does not support register"),
484 unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"),
485 });
486 }),
487 );