1// Per-instance backend factory. Replaces the legacy singleton
2// `DesktopBackendManager` Context.Service: each call to
3// `makeBackendInstance(spec)` constructs an isolated backend lifecycle —
4// its own state Ref, mutex, restart loop, and active child process. The
5// returned `DesktopBackendInstance` exposes start/stop/snapshot/wait
6// methods that operate on that single backend.
7//
8// The pool layer (`DesktopBackendPool.ts`) calls this factory once per
9// backend it wants to run. Today that's the Windows primary; follow-up
10// commits add a second call for the WSL instance.
11//
12// Singleton couplings that the legacy service held inline are now
13// parameterized via the spec:
14// - configResolve replaces the legacy `DesktopBackendConfiguration.resolve`
15// so each instance can resolve its own start config — the primary wires
16// `configuration.resolvePrimary`, the WSL orchestrator wires a
17// `configuration.resolveWsl({ port, distro })` closure.
18// - onReady / onShutdown drive UI side effects (window auto-open,
19// readiness latch) only for instances that want them — the primary's
20// spec passes the window's handleBackendReady/handleBackendNotReady,
21// other pool instances pass nothing.
22// - log writes go through a per-instance writer that the factory
23// pulls from `DesktopBackendOutputLogFactory.forInstance(spec.id)`,
24// so each instance lands in its own rotating file.
25
26import * as Brand from "effect/Brand";
27import * as Cause from "effect/Cause";
28import * as Duration from "effect/Duration";
29import * as Effect from "effect/Effect";
30import * as Exit from "effect/Exit";
31import * as Fiber from "effect/Fiber";
32import * as FileSystem from "effect/FileSystem";
33import * as Option from "effect/Option";
34import * as PlatformError from "effect/PlatformError";
35import * as Ref from "effect/Ref";
36import * as Schedule from "effect/Schedule";
37import * as Schema from "effect/Schema";
38import * as Semaphore from "effect/Semaphore";
39import * as Scope from "effect/Scope";
40import * as Stream from "effect/Stream";
41import { HttpClient } from "effect/http";
42import { ChildProcess, ChildProcessSpawner } from "effect/process";
43
44import {
45 SERVER_EXIT_CODE_STATE_DIR_OWNED,
46 DesktopBackendBootstrap,
47 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
48 PRIMARY_LOCAL_ENVIRONMENT_ID,
49 DesktopTelemetryControlMessage,
50 type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue,
51} from "@t3tools/contracts";
52import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness";
53
54import * as DesktopObservability from "../app/DesktopObservability.ts";
55import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
56import * as DesktopTerminalHost from "./DesktopTerminalHost.ts";
57import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
58import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
59
60const INITIAL_RESTART_DELAY = Duration.millis(500);
61const MAX_RESTART_DELAY = Duration.seconds(10);
62// After this many consecutive fatal preflight failures, stop the silent
63// restart loop and surface the reason via onPreflightFailed. Transient
64// failures may instead provide their own larger retryLimit when they should
65// self-heal for a while but must not leave the app connecting forever.
66const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5;
67const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);
68const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
69const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
70// Same as the service launcher. A killed backend leaves its agents running,
71// and a busy one needs a few seconds to stop every provider session.
72const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5);
73const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5);
74const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
75const { logWarning: logBackendProcessWarning } =
76 DesktopObservability.makeComponentLogger("desktop-backend-process");
77
78type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient;
79
80type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;
81
82export type BackendProcessOutputStream = "stdout" | "stderr";
83
84export interface BackendProcessContext {
85 readonly executablePath: string;
86 readonly entryPath: string;
87 readonly cwd: string;
88 readonly httpBaseUrl: URL;
89}
90
91export type DesktopBackendBootstrapDelivery = "fd3" | "stdin";
92
93export interface DesktopBackendStartConfig extends BackendProcessContext {
94 readonly args: ReadonlyArray<string>;
95 readonly env: Record<string, string | undefined>;
96 // When true the spawner merges the desktop process.env on top of `env`;
97 // when false `env` is passed verbatim. WSL mode opts out so a leaking
98 // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3.
99 readonly extendEnv: boolean;
100 readonly bootstrap: DesktopBackendBootstrapValue;
101 readonly bootstrapDelivery: DesktopBackendBootstrapDelivery;
102 readonly httpBaseUrl: URL;
103 readonly captureOutput: boolean;
104 readonly preflightFailure: Option.Option<PreflightFailure>;
105 // Present for a WSL run after the configured/default distro has been
106 // resolved to the concrete distro passed to wsl.exe.
107 readonly runningDistro?: string;
108 // Present only when this run launched from a staged WSL-local runtime.
109 // Once HTTP readiness succeeds, the manager uses it to retain this cache
110 // plus the newest previous cache and prune older versions.
111 readonly wslRuntimeId?: string;
112}
113
114// A preflight failure records whether it is fatal. Transient failures (WSL
115// cold-starting, wslpath while the VM boots) keep retrying so the backend can
116// self-heal; fatal ones (no node, wrong version, missing build tools) are
117// surfaced via onPreflightFailed and stop the restart loop after
118// MAX_PREFLIGHT_FAILURE_ATTEMPTS.
119export interface PreflightFailure {
120 readonly reason: string;
121 readonly fatal: boolean;
122 readonly retryLimit?: number;
123}
124
125interface BackendProcessExit {
126 readonly code: Option.Option<number>;
127 readonly reason: string;
128}
129
130const backendProcessContextSchema = {
131 executablePath: Schema.String,
132 entryPath: Schema.String,
133 cwd: Schema.String,
134 httpBaseUrl: Schema.URL,
135};
136
137export class BackendReadinessTimeoutError extends Schema.TaggedError<BackendReadinessTimeoutError>()(
138 "BackendReadinessTimeoutError",
139 {
140 ...backendProcessContextSchema,
141 readinessUrl: Schema.URL,
142 timeoutMs: Schema.Number,
143 cause: Schema.Defect(),
144 },
145) {
146 override get message(): string {
147 return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`;
148 }
149}
150
151export class BackendProcessBootstrapEncodeError extends Schema.TaggedError<BackendProcessBootstrapEncodeError>()(
152 "BackendProcessBootstrapEncodeError",
153 {
154 ...backendProcessContextSchema,
155 cause: Schema.Defect(),
156 },
157) {
158 override get message(): string {
159 return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`;
160 }
161}
162
163export class BackendProcessSpawnError extends Schema.TaggedError<BackendProcessSpawnError>()(
164 "BackendProcessSpawnError",
165 {
166 ...backendProcessContextSchema,
167 cause: Schema.Defect(),
168 },
169) {
170 override get message(): string {
171 return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`;
172 }
173}
174
175export class BackendProcessOutputReadError extends Schema.TaggedError<BackendProcessOutputReadError>()(
176 "BackendProcessOutputReadError",
177 {
178 ...backendProcessContextSchema,
179 pid: Schema.Number,
180 streamName: Schema.Literals(["stdout", "stderr"]),
181 cause: Schema.Defect(),
182 },
183) {
184 override get message(): string {
185 return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`;
186 }
187}
188
189export class BackendProcessOutputHandlingError extends Schema.TaggedError<BackendProcessOutputHandlingError>()(
190 "BackendProcessOutputHandlingError",
191 {
192 ...backendProcessContextSchema,
193 pid: Schema.Number,
194 streamName: Schema.Literals(["stdout", "stderr"]),
195 chunkByteLength: Schema.Number,
196 cause: Schema.Defect(),
197 },
198) {
199 override get message(): string {
200 return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`;
201 }
202}
203
204export type BackendProcessOutputError =
205 | BackendProcessOutputReadError
206 | BackendProcessOutputHandlingError;
207
208export class BackendProcessExitStatusError extends Schema.TaggedError<BackendProcessExitStatusError>()(
209 "BackendProcessExitStatusError",
210 {
211 ...backendProcessContextSchema,
212 pid: Schema.Number,
213 cause: Schema.Defect(),
214 },
215) {
216 override get message(): string {
217 return `Failed to read the exit status of desktop backend process ${this.pid}.`;
218 }
219}
220
221export const BackendProcessError = Schema.Union([
222 BackendProcessBootstrapEncodeError,
223 BackendProcessSpawnError,
224 BackendProcessExitStatusError,
225]);
226export type BackendProcessError = typeof BackendProcessError.Type;
227
228interface RunBackendProcessOptions extends DesktopBackendStartConfig {
229 readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;
230 readonly desktopTerminalStream?: Stream.Stream<Uint8Array>;
231 readonly onDesktopTerminalClosed?: Effect.Effect<void>;
232 readonly onDesktopTerminalLine?: (line: string) => Effect.Effect<void>;
233 /** Events for the desktop's browser tabs, written to `desktopBrowserFd`. */
234 readonly desktopBrowserStream?: Stream.Stream<Uint8Array>;
235 /** Each line the backend writes to `desktopBrowserControlFd`. */
236 readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect<void>;
237 readonly onDesktopTelemetryControl?: (
238 message: DesktopTelemetryControlMessageValue,
239 ) => Effect.Effect<void>;
240 readonly readinessTimeout?: Duration.Duration;
241 readonly outputDrainTimeout?: Duration.Duration;
242 readonly onStarted?: (pid: number) => Effect.Effect<void>;
243 readonly onExitObserved?: () => Effect.Effect<void>;
244 readonly onReady?: () => Effect.Effect<void>;
245 readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>;
246 readonly onOutput?: (
247 streamName: BackendProcessOutputStream,
248 chunk: Uint8Array,
249 ) => Effect.Effect<void, Error>;
250 readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect<void>;
251}
252
253export interface DesktopBackendSnapshot {
254 readonly desiredRunning: boolean;
255 readonly ready: boolean;
256 readonly activePid: Option.Option<number>;
257 readonly restartAttempt: number;
258 readonly restartScheduled: boolean;
259}
260
261// Opaque identifier for one backend process inside the pool. Today only
262// PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros
263// under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually
264// these map 1:1 with environment ids on the frontend; keeping them
265// desktop-local for now avoids leaking the contracts dependency.
266export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">;
267export const BackendInstanceId = Brand.nominal<BackendInstanceId>();
268
269export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId(
270 PRIMARY_LOCAL_ENVIRONMENT_ID,
271);
272
273// One pooled backend instance. Same lifecycle surface as the legacy
274// `DesktopBackendManagerShape`; the id and label give the pool registry
275// + UI something to route on.
276export interface DesktopBackendInstance {
277 readonly id: BackendInstanceId;
278 readonly label: Effect.Effect<string>;
279 readonly start: Effect.Effect<void>;
280 readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>;
281 readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>;
282 readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;
283 // Polls desiredRunning + the instance's own ready flag until the
284 // backend reports ready, or the timeout elapses. Returns true on
285 // ready, false on timeout. Used by the WSL backend swap to drive its
286 // rollback path.
287 readonly waitForReady: (timeout: Duration.Duration) => Effect.Effect<boolean>;
288}
289
290// Spec describing one backend instance to spawn. The configResolve
291// effect is awaited each time the instance is (re)started so live
292// settings changes are picked up on the next start cycle. onReady and
293// onShutdown let the primary instance trigger UI side effects (window
294// open, global readiness flag) without coupling the factory to those
295// concerns; other instances pass them as undefined.
296export interface BackendInstanceSpec {
297 readonly id: BackendInstanceId;
298 readonly label: Effect.Effect<string>;
299 // configResolve can now fail with PlatformError because the
300 // bootstrap-token closure inside DesktopBackendConfiguration uses
301 // crypto.randomBytes (Effect 4 beta.73 migration).
302 readonly configResolve: Effect.Effect<DesktopBackendStartConfig, PlatformError.PlatformError>;
303 // Receives the *resolved* httpBaseUrl of the run that just became
304 // ready. The window service uses this to decide what URL to load
305 // (the WSL backend reports its distro IP, the Windows backend reports
306 // 127.0.0.1). Splitting this off from configResolve avoids races
307 // between "fired onReady" and "currentConfig already advanced".
308 readonly onReady?: (httpBaseUrl: URL) => Effect.Effect<void>;
309 readonly onShutdown?: () => Effect.Effect<void>;
310 // Fired once when a fatal or bounded preflight failure has exhausted its
311 // retries. Returns true when the callback changed configuration and the
312 // manager should resolve once more; false stops the failed instance.
313 readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect<boolean>;
314 readonly onStateDirOwned?: () => Effect.Effect<void>;
315}
316
317interface ActiveBackendRun {
318 readonly id: number;
319 readonly scope: Scope.Closeable;
320 readonly fiber: Option.Option<Fiber.Fiber<void, never>>;
321 readonly pid: Option.Option<number>;
322 readonly exitObserved: boolean;
323 readonly stopRequested: boolean;
324}
325
326interface BackendManagerState {
327 readonly desiredRunning: boolean;
328 readonly ready: boolean;
329 readonly config: Option.Option<DesktopBackendStartConfig>;
330 readonly active: Option.Option<ActiveBackendRun>;
331 readonly restartAttempt: number;
332 // Consecutive bounded/fatal preflight failures, reset on a clean or
333 // unbounded-transient preflight. restartAttempt counts all restarts.
334 readonly preflightFailureAttempt: number;
335 readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>;
336 readonly nextRunId: number;
337}
338
339const initialState: BackendManagerState = {
340 desiredRunning: false,
341 ready: false,
342 config: Option.none(),
343 active: Option.none(),
344 restartAttempt: 0,
345 preflightFailureAttempt: 0,
346 restartFiber: Option.none(),
347 nextRunId: 1,
348};
349
350const activePid = (active: Option.Option<ActiveBackendRun>): Option.Option<number> =>
351 Option.flatMap(active, (run) => run.pid);
352
353const withActiveRun =
354 (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>
355 (state: BackendManagerState): BackendManagerState => ({
356 ...state,
357 active: Option.map(state.active, (run) => (run.id === runId ? f(run) : run)),
358 });
359
360const calculateRestartDelay = (attempt: number): Duration.Duration =>
361 Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY);
362
363const closeRun = (
364 run: ActiveBackendRun,
365 parentScope: Scope.Scope,
366 options?: { readonly timeout?: Duration.Duration },
367): Effect.Effect<boolean> => {
368 const waitForFiber = Option.match(run.fiber, {
369 onNone: () => Effect.void,
370 onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),
371 });
372 const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber));
373 const timeout = options?.timeout;
374
375 if (!timeout) {
376 return close.pipe(Effect.as(true));
377 }
378
379 return Effect.forkIn(close, parentScope).pipe(
380 Effect.flatMap((closeFiber) =>
381 Fiber.await(closeFiber).pipe(Effect.timeoutOption(timeout), Effect.map(Option.isSome)),
382 ),
383 );
384};
385
386export const waitForHttpReady = (
387 options: BackendProcessContext & { readonly timeout: Duration.Duration },
388): Effect.Effect<void, BackendReadinessTimeoutError, HttpClient.HttpClient> => {
389 const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl);
390 return waitForHttpReadyShared({
391 baseUrl: options.httpBaseUrl.href,
392 path: BACKEND_READINESS_PATH,
393 timeoutMs: Duration.toMillis(options.timeout),
394 intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL),
395 probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT),
396 makeError: ({ cause }) =>
397 new BackendReadinessTimeoutError({
398 executablePath: options.executablePath,
399 entryPath: options.entryPath,
400 cwd: options.cwd,
401 httpBaseUrl: options.httpBaseUrl,
402 readinessUrl,
403 timeoutMs: Duration.toMillis(options.timeout),
404 cause,
405 }),
406 });
407};
408
409function drainBackendOutput(
410 context: BackendProcessContext & { readonly pid: number },
411 streamName: BackendProcessOutputStream,
412 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
413 onOutput: (
414 streamName: BackendProcessOutputStream,
415 chunk: Uint8Array,
416 ) => Effect.Effect<void, Error>,
417 onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect<void>,
418): Effect.Effect<void> {
419 return stream.pipe(
420 Stream.mapError(
421 (cause) =>
422 new BackendProcessOutputReadError({
423 ...context,
424 streamName,
425 cause,
426 }),
427 ),
428 Stream.runForEach((chunk) =>
429 onOutput(streamName, chunk).pipe(
430 Effect.mapError(
431 (cause) =>
432 new BackendProcessOutputHandlingError({
433 ...context,
434 streamName,
435 chunkByteLength: chunk.byteLength,
436 cause,
437 }),
438 ),
439 Effect.catchTags({ BackendProcessOutputHandlingError: onOutputFailure }),
440 ),
441 ),
442 Effect.catchTags({
443 BackendProcessOutputReadError: onOutputFailure,
444 }),
445 );
446}
447
448const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
449const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect(
450 Schema.fromJsonString(DesktopTelemetryControlMessage),
451);
452
453export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
454 options: RunBackendProcessOptions,
455): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
456 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
457 const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(
458 Effect.mapError(
459 (cause) =>
460 new BackendProcessBootstrapEncodeError({
461 executablePath: options.executablePath,
462 entryPath: options.entryPath,
463 cwd: options.cwd,
464 httpBaseUrl: options.httpBaseUrl,
465 cause,
466 }),
467 ),
468 );
469 const onOutput = options.onOutput ?? (() => Effect.void);
470 const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`));
471 const additionalFds: Record<`fd${number}`, ChildProcess.AdditionalFdConfig> = {};
472 if (options.bootstrapDelivery === "fd3") {
473 additionalFds.fd3 = {
474 type: "input",
475 stream: bootstrapStream,
476 };
477 if (options.bootstrap.desktopTelemetryFd !== undefined) {
478 additionalFds[`fd${options.bootstrap.desktopTelemetryFd}`] = {
479 type: "input",
480 stream: options.desktopTelemetryStream,
481 };
482 }
483 if (options.bootstrap.desktopTelemetryControlFd !== undefined) {
484 additionalFds[`fd${options.bootstrap.desktopTelemetryControlFd}`] = {
485 type: "output",
486 };
487 }
488 if (
489 options.bootstrap.desktopBrowserFd !== undefined &&
490 options.desktopBrowserStream !== undefined
491 ) {
492 additionalFds[`fd${options.bootstrap.desktopBrowserFd}`] = {
493 type: "input",
494 stream: options.desktopBrowserStream,
495 };
496 }
497 if (options.bootstrap.desktopBrowserControlFd !== undefined) {
498 additionalFds[`fd${options.bootstrap.desktopBrowserControlFd}`] = { type: "output" };
499 }
500 }
501 if (
502 options.bootstrap.desktopTerminalFd !== undefined &&
503 options.bootstrap.desktopTerminalControlFd !== undefined &&
504 options.desktopTerminalStream
505 ) {
506 additionalFds[`fd${options.bootstrap.desktopTerminalFd}`] = {
507 type: "input",
508 stream: options.desktopTerminalStream,
509 };
510 additionalFds[`fd${options.bootstrap.desktopTerminalControlFd}`] = { type: "output" };
511 }
512 const command = ChildProcess.make(options.executablePath, options.args, {
513 cwd: options.cwd,
514 env: options.env,
515 extendEnv: options.extendEnv,
516 // In Electron main, process.execPath points to the Electron binary.
517 // Run the child in Node mode so this backend process does not become a GUI app instance.
518 stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore",
519 stdout: options.captureOutput ? "pipe" : "inherit",
520 stderr: options.captureOutput ? "pipe" : "inherit",
521 killSignal: "SIGTERM",
522 forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE,
523 // wsl.exe drops additional file descriptors when forwarding to the Linux
524 // side, so the WSL spawn path delivers the bootstrap envelope via stdin
525 // (`--bootstrap-fd 0`) instead.
526 ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}),
527 });
528
529 const handle = yield* spawner.spawn(command).pipe(
530 Effect.mapError(
531 (cause) =>
532 new BackendProcessSpawnError({
533 executablePath: options.executablePath,
534 entryPath: options.entryPath,
535 cwd: options.cwd,
536 httpBaseUrl: options.httpBaseUrl,
537 cause,
538 }),
539 ),
540 );
541 const outputFibers: Array<Fiber.Fiber<void, never>> = [];
542
543 yield* options.onStarted?.(handle.pid) ?? Effect.void;
544 if (
545 options.bootstrap.desktopTelemetryControlFd !== undefined &&
546 options.onDesktopTelemetryControl !== undefined
547 ) {
548 const controlFd = options.bootstrap.desktopTelemetryControlFd;
549 const handleControl = options.onDesktopTelemetryControl;
550 yield* handle.getOutputFd(controlFd).pipe(
551 Stream.decodeText(),
552 Stream.splitLines,
553 Stream.filter((line) => line.trim().length > 0),
554 Stream.runForEach((line) =>
555 decodeDesktopTelemetryControlLine(line).pipe(
556 Effect.flatMap(handleControl),
557 Effect.catchCause((cause) =>
558 logBackendProcessWarning("ignored invalid desktop telemetry control message", {
559 fd: controlFd,
560 cause: Cause.pretty(cause),
561 }),
562 ),
563 ),
564 ),
565 Effect.catchCause((cause) =>
566 logBackendProcessWarning("desktop telemetry control stream stopped", {
567 fd: controlFd,
568 cause: Cause.pretty(cause),
569 }),
570 ),
571 Effect.ensuring(
572 handleControl({
573 version: 1,
574 type: "setDiagnosticsDemand",
575 enabled: false,
576 }),
577 ),
578 Effect.forkScoped,
579 );
580 }
581 if (options.bootstrap.desktopTerminalControlFd !== undefined && options.onDesktopTerminalLine) {
582 yield* handle.getOutputFd(options.bootstrap.desktopTerminalControlFd).pipe(
583 Stream.decodeText(),
584 Stream.splitLines,
585 Stream.runForEach(options.onDesktopTerminalLine),
586 Effect.catchCause(() => Effect.void),
587 Effect.ensuring(options.onDesktopTerminalClosed ?? Effect.void),
588 Effect.forkScoped,
589 );
590 }
591 if (
592 options.bootstrap.desktopBrowserControlFd !== undefined &&
593 options.onDesktopBrowserCommand !== undefined
594 ) {
595 const browserFd = options.bootstrap.desktopBrowserControlFd;
596 const handleCommand = options.onDesktopBrowserCommand;
597 yield* handle.getOutputFd(browserFd).pipe(
598 Stream.decodeText(),
599 Stream.splitLines,
600 Stream.filter((line) => line.length > 0),
601 Stream.runForEach(handleCommand),
602 Effect.catchCause((cause) =>
603 logBackendProcessWarning("desktop browser command stream stopped", {
604 fd: browserFd,
605 cause: Cause.pretty(cause),
606 }),
607 ),
608 Effect.forkScoped,
609 );
610 }
611 if (options.captureOutput) {
612 const outputContext = {
613 executablePath: options.executablePath,
614 entryPath: options.entryPath,
615 cwd: options.cwd,
616 httpBaseUrl: options.httpBaseUrl,
617 pid: Number(handle.pid),
618 };
619 const onOutputFailure = options.onOutputFailure ?? (() => Effect.void);
620 outputFibers.push(
621 yield* drainBackendOutput(
622 outputContext,
623 "stdout",
624 handle.stdout,
625 onOutput,
626 onOutputFailure,
627 ).pipe(Effect.forkScoped),
628 yield* drainBackendOutput(
629 outputContext,
630 "stderr",
631 handle.stderr,
632 onOutput,
633 onOutputFailure,
634 ).pipe(Effect.forkScoped),
635 );
636 }
637 // Probe readiness in a loop while the backend process is still alive
638 // instead of giving up after the first budget. A slow cold boot (the
639 // WSL bundle loading across /mnt/c, or a first launch right after an
640 // update) can exceed the initial readiness budget while the backend is
641 // about to come up moments later; a one-shot probe left the app stuck
642 // on "Connecting to WSL…" forever even though the backend kept running
643 // and became healthy. Each round gets a fresh budget, and the forked
644 // loop is torn down with the run scope once the child exits.
645 const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() =>
646 waitForHttpReady({
647 executablePath: options.executablePath,
648 entryPath: options.entryPath,
649 cwd: options.cwd,
650 httpBaseUrl: options.httpBaseUrl,
651 timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
652 }).pipe(
653 Effect.flatMap(() => options.onReady?.() ?? Effect.void),
654 Effect.as(true),
655 Effect.catchTags({
656 BackendReadinessTimeoutError: (error) =>
657 (options.onReadinessFailure?.(error) ?? Effect.void).pipe(Effect.as(false)),
658 }),
659 ),
660 );
661
662 yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped);
663
664 const exit = yield* handle.exitCode.pipe(
665 Effect.mapError(
666 (cause) =>
667 new BackendProcessExitStatusError({
668 executablePath: options.executablePath,
669 entryPath: options.entryPath,
670 cwd: options.cwd,
671 httpBaseUrl: options.httpBaseUrl,
672 pid: Number(handle.pid),
673 cause,
674 }),
675 ),
676 Effect.exit,
677 );
678 yield* options.onExitObserved?.() ?? Effect.void;
679 yield* Effect.forEach(outputFibers, Fiber.await, {
680 concurrency: "unbounded",
681 discard: true,
682 }).pipe(
683 Effect.timeout(options.outputDrainTimeout ?? DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT),
684 Effect.ignore,
685 );
686 if (Exit.isFailure(exit)) {
687 return yield* Effect.failCause(exit.cause);
688 }
689 const exitCode = exit.value;
690 return {
691 code: Option.some(exitCode),
692 reason: `code=${exitCode}`,
693 } satisfies BackendProcessExit;
694});
695
696// Factory for one pooled backend instance. The returned instance owns
697// its own state Ref, mutex, restart loop, and active child process;
698// nothing is shared between instances created from separate
699// makeBackendInstance calls. The instance shuts down automatically when
700// the calling scope closes (typically the application scope).
701export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
702 spec: BackendInstanceSpec,
703): Effect.fn.Return<
704 DesktopBackendInstance,
705 never,
706 | FileSystem.FileSystem
707 | ChildProcessSpawner.ChildProcessSpawner
708 | HttpClient.HttpClient
709 | DesktopObservability.DesktopBackendOutputLogFactory
710 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
711 | DesktopTerminalHost.DesktopTerminalHost
712 | DesktopBrowserHost.DesktopBrowserHost
713 | DesktopWslEnvironment.DesktopWslEnvironment
714 | Scope.Scope
715> {
716 const parentScope = yield* Scope.Scope;
717 const fileSystem = yield* FileSystem.FileSystem;
718 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
719 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
720 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
721 const desktopTerminalHost = yield* DesktopTerminalHost.DesktopTerminalHost;
722 const desktopBrowserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
723 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
724 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
725 const httpClient = yield* HttpClient.HttpClient;
726 const state = yield* Ref.make(initialState);
727 const mutex = yield* Semaphore.make(1);
728
729 const { logWarning: logInstanceWarning, logError: logInstanceError } =
730 DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`);
731
732 const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>
733 Ref.update(state, withActiveRun(runId, f));
734
735 const snapshot = Ref.get(state).pipe(
736 Effect.map((current): DesktopBackendSnapshot => ({
737 desiredRunning: current.desiredRunning,
738 ready: current.ready,
739 activePid: activePid(current.active),
740 restartAttempt: current.restartAttempt,
741 restartScheduled: Option.isSome(current.restartFiber),
742 })),
743 );
744 const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config));
745
746 const cancelRestart = Effect.gen(function* () {
747 const restartFiber = yield* Ref.modify(state, (current) => [
748 current.restartFiber,
749 {
750 ...current,
751 restartFiber: Option.none(),
752 },
753 ]);
754
755 yield* Option.match(restartFiber, {
756 onNone: () => Effect.void,
757 onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),
758 });
759 });
760
761 const start: Effect.Effect<void> = Effect.suspend(() =>
762 mutex.withPermits(1)(
763 Effect.gen(function* () {
764 const current = yield* Ref.get(state);
765 if (Option.isSome(current.active)) {
766 if (!current.desiredRunning) {
767 yield* Ref.update(state, (latest) => ({
768 ...latest,
769 desiredRunning: true,
770 }));
771 }
772 return;
773 }
774
775 if (current.ready) {
776 yield* spec.onShutdown?.() ?? Effect.void;
777 yield* Ref.update(state, (latest) =>
778 latest.ready ? { ...latest, ready: false } : latest,
779 );
780 }
781 const config = yield* spec.configResolve.pipe(
782 Effect.tapError((error) =>
783 logInstanceError("failed to generate desktop backend configuration", {
784 cause: error.message,
785 }),
786 ),
787 Effect.option,
788 );
789 if (Option.isNone(config)) {
790 if (current.desiredRunning) {
791 yield* scheduleRestart("failed to generate desktop backend configuration");
792 }
793 return;
794 }
795 const entryExists = yield* fileSystem
796 .exists(config.value.entryPath)
797 .pipe(Effect.orElseSucceed(() => false));
798
799 const resetFatalPreflightCounter =
800 !current.desiredRunning && current.preflightFailureAttempt > 0;
801 yield* cancelRestart;
802 yield* Ref.update(state, (latest) => ({
803 ...latest,
804 desiredRunning: true,
805 ready: false,
806 config: Option.some(config.value),
807 preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt,
808 }));
809
810 const preflightFailure = config.value.preflightFailure;
811 if (Option.isSome(preflightFailure)) {
812 const { reason, fatal, retryLimit } = preflightFailure.value;
813 if (!fatal && retryLimit === undefined) {
814 // Transient (WSL cold-starting, wslpath while the VM boots). Keep
815 // retrying so the backend self-heals once WSL is ready. Reset a
816 // prior bounded/fatal streak because this is a different failure.
817 yield* Ref.update(state, (latest) =>
818 latest.preflightFailureAttempt === 0
819 ? latest
820 : { ...latest, preflightFailureAttempt: 0 },
821 );
822 yield* scheduleRestart(reason);
823 return;
824 }
825 const attemptLimit = retryLimit ?? MAX_PREFLIGHT_FAILURE_ATTEMPTS;
826 const attempt = yield* Ref.modify(state, (latest) => {
827 const next = latest.preflightFailureAttempt + 1;
828 return [next, { ...latest, preflightFailureAttempt: next }] as const;
829 });
830 if (attempt > attemptLimit) {
831 // We already surfaced and asked for the Windows fallback, yet we're
832 // still resolving the WSL primary — the fallback didn't take (e.g.
833 // the settings write failed). Stop rather than loop forever.
834 yield* logInstanceError("backend preflight still failing after fallback; stopping", {
835 reason,
836 attempt,
837 });
838 yield* Ref.update(state, (latest) => ({
839 ...latest,
840 desiredRunning: false,
841 ready: false,
842 }));
843 return;
844 }
845 if (attempt === attemptLimit) {
846 // Fatal/bounded and out of retries. Surface the reason (onPreflightFailed,
847 // on the primary, shows a dialog and persists Windows mode), then
848 // schedule one more restart so the next resolve picks up the Windows
849 // primary and a window can open.
850 yield* logInstanceError(
851 "backend preflight failed repeatedly; surfacing and falling back",
852 { reason, attempt },
853 );
854 const shouldRestart = yield* (
855 spec.onPreflightFailed?.(preflightFailure.value) ?? Effect.succeed(false)
856 );
857 if (shouldRestart) {
858 yield* scheduleRestart(reason);
859 } else {
860 yield* Ref.update(state, (latest) => ({
861 ...latest,
862 desiredRunning: false,
863 ready: false,
864 }));
865 }
866 return;
867 }
868 yield* scheduleRestart(reason);
869 return;
870 }
871 // Clean preflight — reset the fatal counter so a later failure gets a
872 // fresh allowance.
873 yield* Ref.update(state, (latest) =>
874 latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 },
875 );
876
877 if (!entryExists) {
878 yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`);
879 return;
880 }
881
882 const runScope = yield* Scope.make("sequential");
883 const runId = yield* Ref.modify(state, (latest) => [
884 latest.nextRunId,
885 {
886 ...latest,
887 active: Option.some({
888 id: latest.nextRunId,
889 scope: runScope,
890 fiber: Option.none(),
891 pid: Option.none(),
892 exitObserved: false,
893 stopRequested: false,
894 } satisfies ActiveBackendRun),
895 nextRunId: latest.nextRunId + 1,
896 },
897 ]);
898
899 const finalizeRun = Effect.fn("desktop.backendInstance.finalizeRun")(function* (
900 reason: string,
901 exitCode?: number,
902 ) {
903 const stateDirOwned = exitCode === SERVER_EXIT_CODE_STATE_DIR_OWNED;
904 yield* mutex.withPermits(1)(
905 Effect.gen(function* () {
906 const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } =
907 yield* Ref.modify(
908 state,
909 (
910 latest,
911 ): readonly [
912 {
913 readonly isCurrentRun: boolean;
914 readonly nextState: BackendManagerState;
915 readonly pid: Option.Option<number>;
916 readonly exitObserved: boolean;
917 readonly stopRequested: boolean;
918 readonly wasReady: boolean;
919 },
920 BackendManagerState,
921 ] => {
922 const currentRun = Option.getOrUndefined(latest.active);
923 if (currentRun?.id !== runId) {
924 return [
925 {
926 isCurrentRun: false,
927 nextState: latest,
928 pid: Option.none<number>(),
929 exitObserved: false,
930 stopRequested: false,
931 wasReady: false,
932 },
933 latest,
934 ] as const;
935 }
936
937 const next = {
938 ...latest,
939 active: Option.none<ActiveBackendRun>(),
940 ready: false,
941 };
942 return [
943 {
944 isCurrentRun: true,
945 nextState: next,
946 pid: currentRun.pid,
947 exitObserved: currentRun.exitObserved,
948 stopRequested: currentRun.stopRequested,
949 wasReady: latest.ready,
950 },
951 next,
952 ] as const;
953 },
954 );
955
956 if (isCurrentRun) {
957 yield* desktopTelemetryPublisher.removeControlSource(spec.id);
958 if (Option.isSome(pid)) {
959 if (exitObserved && !stopRequested) {
960 yield* backendOutputLog.persistFailure({
961 details: `pid=${pid.value} ${reason}`,
962 });
963 } else {
964 yield* backendOutputLog.discardSession;
965 }
966 }
967 if (wasReady) {
968 yield* spec.onShutdown?.() ?? Effect.void;
969 }
970 }
971
972 if (isCurrentRun && nextState.desiredRunning) {
973 if (stateDirOwned && !stopRequested) {
974 yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: false }));
975 yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore);
976 return;
977 }
978 yield* scheduleRestart(reason);
979 }
980 }),
981 );
982 });
983
984 if (config.value.bootstrap.desktopTerminalFd !== undefined)
985 yield* desktopTerminalHost.configure(config.value.httpBaseUrl.href);
986 const program = runBackendProcess({
987 ...config.value,
988 desktopTelemetryStream: desktopTelemetryPublisher.encoded,
989 // Only a bootstrap that names the browser fds (the local primary) gets them.
990 desktopTerminalStream: desktopTerminalHost.requests,
991 onDesktopTerminalLine: desktopTerminalHost.handleLine,
992 onDesktopTerminalClosed: desktopTerminalHost.reset,
993 desktopBrowserStream: desktopBrowserHost.events,
994 onDesktopBrowserCommand: desktopBrowserHost.handleCommandLine,
995 onDesktopTelemetryControl: (message) =>
996 desktopTelemetryPublisher.handleControlForSource(spec.id, message),
997 onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) {
998 yield* updateActiveRun(runId, (run) => ({
999 ...run,
1000 pid: Option.some(pid),
1001 }));
1002 yield* backendOutputLog.beginSession({
1003 details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`,
1004 });
1005 }),
1006 onExitObserved: () =>
1007 updateActiveRun(runId, (run) => ({
1008 ...run,
1009 exitObserved: true,
1010 })),
1011 onReady: Effect.fn("desktop.backendInstance.onReady")(function* () {
1012 const isCurrentRun = yield* Ref.modify(state, (latest) => {
1013 const activeRun = Option.getOrUndefined(latest.active);
1014 if (activeRun?.id !== runId) {
1015 return [false, latest] as const;
1016 }
1017
1018 return [
1019 true,
1020 {
1021 ...latest,
1022 restartAttempt: 0,
1023 ready: true,
1024 },
1025 ] as const;
1026 });
1027 if (!isCurrentRun) {
1028 return;
1029 }
1030
1031 yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;
1032 if (
1033 config.value.runningDistro !== undefined &&
1034 config.value.wslRuntimeId !== undefined
1035 ) {
1036 yield* wslEnvironment.pruneRuntimes(
1037 config.value.runningDistro,
1038 config.value.wslRuntimeId,
1039 );
1040 }
1041 }),
1042 onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")(
1043 function* (error) {
1044 yield* logInstanceWarning("backend readiness check failed during bootstrap", {
1045 error: error.message,
1046 });
1047 yield* backendOutputLog.persistFailureSnapshot({
1048 details: error.message,
1049 });
1050 },
1051 ),
1052 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),
1053 }).pipe(
1054 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1055 Effect.provideService(HttpClient.HttpClient, httpClient),
1056 Scope.provide(runScope),
1057 Effect.matchEffect({
1058 onFailure: (error) => finalizeRun(error.message),
1059 onSuccess: (exit) => finalizeRun(exit.reason, Option.getOrUndefined(exit.code)),
1060 }),
1061 Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)),
1062 Effect.ensuring(
1063 config.value.bootstrap.desktopTerminalFd === undefined
1064 ? Effect.void
1065 : desktopTerminalHost.reset,
1066 ),
1067 );
1068
1069 const fiber = yield* Effect.forkIn(program, parentScope);
1070 yield* updateActiveRun(runId, (run) => ({
1071 ...run,
1072 fiber: Option.some(fiber),
1073 }));
1074 }),
1075 ),
1076 ).pipe(Effect.withSpan("desktop.backendInstance.start", { attributes: { id: spec.id } }));
1077
1078 const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* (
1079 reason: string,
1080 ) {
1081 const scheduled = yield* Ref.modify(state, (latest) => {
1082 if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) {
1083 return [Option.none<Duration.Duration>(), latest] as const;
1084 }
1085
1086 const delay = calculateRestartDelay(latest.restartAttempt);
1087 return [
1088 Option.some(delay),
1089 {
1090 ...latest,
1091 restartAttempt: latest.restartAttempt + 1,
1092 },
1093 ] as const;
1094 });
1095
1096 yield* Option.match(scheduled, {
1097 onNone: () => Effect.void,
1098 onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) {
1099 yield* logInstanceError("backend exited unexpectedly; restart scheduled", {
1100 reason,
1101 delayMs: Duration.toMillis(delay),
1102 });
1103 const restartFiber = yield* Effect.forkIn(
1104 Effect.sleep(delay).pipe(
1105 Effect.andThen(
1106 Ref.modify(state, (latest) => {
1107 const shouldRestart = latest.desiredRunning;
1108 return [
1109 shouldRestart,
1110 {
1111 ...latest,
1112 restartFiber: Option.none(),
1113 },
1114 ] as const;
1115 }),
1116 ),
1117 Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)),
1118 Effect.catchCause((cause) =>
1119 logInstanceError("desktop backend restart fiber failed", {
1120 cause: Cause.pretty(cause),
1121 }),
1122 ),
1123 ),
1124 parentScope,
1125 );
1126 yield* Ref.update(state, (latest) =>
1127 Option.isNone(latest.restartFiber)
1128 ? {
1129 ...latest,
1130 restartFiber: Option.some(restartFiber),
1131 }
1132 : latest,
1133 );
1134 }),
1135 });
1136 });
1137
1138 const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: {
1139 readonly timeout?: Duration.Duration;
1140 }) {
1141 const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)(
1142 Effect.gen(function* () {
1143 const result = yield* Ref.modify(state, (latest) => {
1144 const active = Option.map(latest.active, (run) =>
1145 run.exitObserved ? run : { ...run, stopRequested: true },
1146 );
1147 return [
1148 {
1149 active,
1150 restartFiber: latest.restartFiber,
1151 notifyShutdown: latest.ready,
1152 },
1153 {
1154 ...latest,
1155 desiredRunning: false,
1156 ready: false,
1157 active,
1158 restartFiber: Option.none<Fiber.Fiber<void, never>>(),
1159 },
1160 ] as const;
1161 });
1162 return result;
1163 }),
1164 );
1165
1166 if (notifyShutdown) {
1167 yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore);
1168 }
1169 yield* Option.match(restartFiber, {
1170 onNone: () => Effect.void,
1171 onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),
1172 });
1173 yield* Option.match(active, {
1174 onNone: () => Effect.void,
1175 onSome: (run) =>
1176 Effect.gen(function* () {
1177 const closed = yield* closeRun(run, parentScope, options);
1178 if (!closed) {
1179 return;
1180 }
1181 const cleanup = yield* mutex.withPermits(1)(
1182 Ref.modify(
1183 state,
1184 (
1185 latest,
1186 ): readonly [
1187 {
1188 readonly needsCleanup: boolean;
1189 readonly shouldStart: boolean;
1190 },
1191 BackendManagerState,
1192 ] => {
1193 const current = Option.getOrUndefined(latest.active);
1194 if (current?.id !== run.id) {
1195 return [
1196 {
1197 needsCleanup: false,
1198 shouldStart:
1199 latest.desiredRunning &&
1200 Option.isNone(latest.active) &&
1201 Option.isNone(latest.restartFiber),
1202 },
1203 latest,
1204 ];
1205 }
1206 return [
1207 {
1208 needsCleanup: true,
1209 shouldStart: latest.desiredRunning,
1210 },
1211 {
1212 ...latest,
1213 active: Option.none<ActiveBackendRun>(),
1214 },
1215 ];
1216 },
1217 ),
1218 );
1219 if (cleanup.needsCleanup) {
1220 yield* desktopTelemetryPublisher.removeControlSource(spec.id);
1221 yield* backendOutputLog.discardSession;
1222 }
1223 if (cleanup.shouldStart) {
1224 yield* start;
1225 }
1226 }),
1227 });
1228 });
1229
1230 const waitForReady = (timeout: Duration.Duration): Effect.Effect<boolean> =>
1231 Effect.gen(function* () {
1232 const current = yield* Ref.get(state);
1233 // Return false early if an external `stop()` flipped desiredRunning off
1234 // — no point polling for a backend that is being torn down.
1235 if (!current.desiredRunning) return { done: true, ready: false };
1236 return current.ready ? { done: true, ready: true } : { done: false, ready: false };
1237 }).pipe(
1238 Effect.repeat({
1239 until: (status) => status.done,
1240 schedule: Schedule.spaced(Duration.millis(100)),
1241 }),
1242 Effect.map((status) => status.ready),
1243 Effect.timeoutOption(timeout),
1244 Effect.map(Option.getOrElse(() => false)),
1245 );
1246
1247 yield* Effect.addFinalizer(() => stop());
1248
1249 return {
1250 id: spec.id,
1251 label: spec.label,
1252 start,
1253 stop,
1254 currentConfig,
1255 snapshot,
1256 waitForReady,
1257 } satisfies DesktopBackendInstance;
1258});