1import * as DesktopTerminalHost from "./DesktopTerminalHost.ts";
2import {
3 SERVER_EXIT_CODE_STATE_DIR_OWNED,
4 DesktopBackendBootstrap,
5 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
6 DesktopTelemetryControlMessage,
7} from "@t3tools/contracts";
8import { assert, describe, it } from "@effect/vitest";
9import * as Deferred from "effect/Deferred";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as PlatformError from "effect/PlatformError";
17import * as Queue from "effect/Queue";
18import * as Ref from "effect/Ref";
19import * as Schema from "effect/Schema";
20import * as Sink from "effect/Sink";
21import * as Scope from "effect/Scope";
22import * as Stream from "effect/Stream";
23import * as TestClock from "effect/testing/TestClock";
24import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
25import { ChildProcess, ChildProcessSpawner } from "effect/process";
26
27import * as DesktopBackendManager from "./DesktopBackendManager.ts";
28import * as DesktopApp from "../app/DesktopApp.ts";
29import * as DesktopBackendPool from "./DesktopBackendPool.ts";
30import * as DesktopObservability from "../app/DesktopObservability.ts";
31import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
32import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
33import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
34
35const decodeDesktopBackendBootstrap = Schema.decodeEffect(
36 Schema.fromJsonString(DesktopBackendBootstrap),
37);
38const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError);
39const encodeDesktopTelemetryControl = Schema.encodeSync(
40 Schema.fromJsonString(DesktopTelemetryControlMessage),
41);
42
43const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
44 executablePath: "/electron",
45 args: ["/server/bin.mjs", "--bootstrap-fd", "3"],
46 entryPath: "/server/bin.mjs",
47 cwd: "/server",
48 env: { ELECTRON_RUN_AS_NODE: "1" },
49 bootstrap: {
50 mode: "desktop",
51 noBrowser: true,
52 port: 3773,
53 t3Home: "/tmp/t3",
54 host: "127.0.0.1",
55 desktopBootstrapToken: "token",
56 tailscaleServeEnabled: false,
57 tailscaleServePort: 443,
58 desktopTelemetryFd: 4,
59 desktopTelemetryControlFd: 5,
60 },
61 bootstrapDelivery: "fd3",
62 extendEnv: true,
63 httpBaseUrl: new URL("http://127.0.0.1:3773"),
64 captureOutput: true,
65 preflightFailure: Option.none(),
66};
67
68const configWithObservability: DesktopBackendBootstrapValue = {
69 ...baseConfig.bootstrap,
70 tailscaleServeEnabled: true,
71 desktopTelemetryFd: 4,
72 otlpTracesUrl: "http://127.0.0.1:4318/v1/traces",
73};
74
75function makeProcess(options?: {
76 readonly stdout?: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
77 readonly stderr?: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
78 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
79 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];
80 readonly getOutputFd?: ChildProcessSpawner.ChildProcessHandle["getOutputFd"];
81}): ChildProcessSpawner.ChildProcessHandle {
82 return ChildProcessSpawner.makeHandle({
83 pid: ChildProcessSpawner.ProcessId(123),
84 stdout: options?.stdout ?? Stream.empty,
85 stderr: options?.stderr ?? Stream.empty,
86 all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty),
87 exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)),
88 isRunning: Effect.succeed(false),
89 kill: options?.kill ?? (() => Effect.void),
90 stdin: Sink.drain,
91 getInputFd: () => Sink.drain,
92 getOutputFd: options?.getOutputFd ?? (() => Stream.empty),
93 unref: Effect.succeed(Effect.void),
94 });
95}
96
97function responseForRequest(
98 request: HttpClientRequest.HttpClientRequest,
99 status: number,
100): HttpClientResponse.HttpClientResponse {
101 return HttpClientResponse.fromWeb(request, new Response(null, { status }));
102}
103
104function layerHttpClient(
105 handler: (
106 request: HttpClientRequest.HttpClientRequest,
107 ) => Effect.Effect<HttpClientResponse.HttpClientResponse>,
108) {
109 return Layer.succeed(
110 HttpClient.HttpClient,
111 HttpClient.make((request) => handler(request)),
112 );
113}
114
115const layerHealthyHttpClient = layerHttpClient((request) =>
116 Effect.succeed(responseForRequest(request, 200)),
117);
118
119function decodeBootstrap(raw: string) {
120 return decodeDesktopBackendBootstrap(raw);
121}
122
123interface MakeInstanceInput {
124 readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
125 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
126 readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>;
127 readonly onReady?: Effect.Effect<void>;
128 readonly onShutdown?: Effect.Effect<void>;
129 readonly onStateDirOwned?: Effect.Effect<void>;
130 readonly onPreflightFailed?: (
131 failure: DesktopBackendManager.PreflightFailure,
132 ) => Effect.Effect<boolean>;
133 readonly config?: DesktopBackendManager.DesktopBackendStartConfig;
134 readonly configResolve?: Effect.Effect<
135 DesktopBackendManager.DesktopBackendStartConfig,
136 PlatformError.PlatformError
137 >;
138 readonly desktopTelemetryStream?: Stream.Stream<Uint8Array>;
139 readonly desktopTelemetryPublisher?: Partial<
140 DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"]
141 >;
142 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
143}
144
145// Helper that constructs a primary backend instance using the factory
146// directly. The factory's deps (FileSystem, ChildProcessSpawner,
147// HttpClient, DesktopBackendOutputLogFactory) are provided per-test via
148// a scoped layer; tests yield the returned Effect inside `Effect.scoped`
149// to drive the instance's lifecycle.
150function makeTestInstance(input: MakeInstanceInput) {
151 const stubLog: DesktopObservability.DesktopBackendOutputLogShape = {
152 beginSession: () => Effect.void,
153 writeOutputChunk: () => Effect.void,
154 persistFailureSnapshot: () => Effect.void,
155 persistFailure: () => Effect.void,
156 discardSession: Effect.void,
157 ...input.backendOutputLog,
158 };
159 const layerServices = Layer.mergeAll(
160 FileSystem.layerNoop({
161 exists: () => Effect.succeed(true),
162 }),
163 input.spawnerLayer,
164 input.httpClientLayer ?? layerHealthyHttpClient,
165 Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
166 forInstance: () => Effect.succeed(stubLog),
167 } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]),
168 Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {
169 latest: Effect.succeedNone,
170 changes: Stream.empty,
171 encoded: input.desktopTelemetryStream ?? Stream.empty,
172 handleControlForSource: () => Effect.void,
173 removeControlSource: () => Effect.void,
174 publishUpdateReport: () => Effect.void,
175 updateRequests: Stream.empty,
176 updateCommits: Stream.empty,
177 updateCancellations: Stream.empty,
178 ...input.desktopTelemetryPublisher,
179 }),
180 Layer.succeed(DesktopTerminalHost.DesktopTerminalHost, {
181 requests: Stream.empty,
182 handleLine: () => Effect.void,
183 reset: Effect.void,
184 configure: () => Effect.void,
185 }),
186 DesktopBrowserHost.layer,
187 DesktopWslEnvironment.layerTest(
188 input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },
189 ),
190 );
191
192 const instance = DesktopBackendManager.makeBackendInstance({
193 id: DesktopBackendManager.PRIMARY_INSTANCE_ID,
194 label: Effect.succeed("Windows"),
195 configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig),
196 ...(input.onReady ? { onReady: () => input.onReady! } : {}),
197 ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}),
198 ...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}),
199 ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}),
200 });
201
202 return instance.pipe(Effect.provide(layerServices));
203}
204
205describe("DesktopBackendManager", () => {
206 it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () =>
207 Effect.scoped(
208 Effect.gen(function* () {
209 let spawnedCommand: ChildProcess.Command | undefined;
210 let bootstrapJson = "";
211 let telemetryJson = "";
212 let readyCount = 0;
213 const ready = yield* Deferred.make<void>();
214 const exited = yield* Queue.unbounded<void>();
215
216 const layerSpawner = Layer.succeed(
217 ChildProcessSpawner.ChildProcessSpawner,
218 ChildProcessSpawner.make((command) =>
219 Effect.gen(function* () {
220 spawnedCommand = command;
221 if (command._tag === "StandardCommand") {
222 const fd3 = command.options.additionalFds?.fd3;
223 if (fd3?.type === "input" && fd3.stream) {
224 bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString);
225 }
226 const fd4 = command.options.additionalFds?.fd4;
227 if (fd4?.type === "input" && fd4.stream) {
228 telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString);
229 }
230 }
231
232 return makeProcess({
233 exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
234 });
235 }),
236 ),
237 );
238
239 const instance = yield* makeTestInstance({
240 config: {
241 ...baseConfig,
242 bootstrap: configWithObservability,
243 },
244 spawnerLayer: layerSpawner,
245 desktopTelemetryStream: Stream.encodeText(
246 Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'),
247 ),
248 onReady: Effect.sync(() => {
249 readyCount += 1;
250 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
251 backendOutputLog: {
252 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
253 },
254 });
255
256 yield* instance.start;
257 yield* Queue.take(exited);
258
259 assert.equal(readyCount, 1);
260 assert.isDefined(spawnedCommand);
261 if (spawnedCommand._tag !== "StandardCommand") {
262 throw new Error("Expected backend to spawn a standard command.");
263 }
264
265 assert.equal(spawnedCommand.command, "/electron");
266 assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]);
267 assert.equal(spawnedCommand.options.cwd, "/server");
268 assert.equal(spawnedCommand.options.extendEnv, true);
269 assert.equal(spawnedCommand.options.stdout, "pipe");
270 assert.equal(spawnedCommand.options.stderr, "pipe");
271 assert.equal(spawnedCommand.options.killSignal, "SIGTERM");
272 assert.isDefined(spawnedCommand.options.forceKillAfter);
273 assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input");
274 assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output");
275 assert.equal(
276 Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)),
277 5_000,
278 );
279
280 assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability);
281 assert.equal(
282 telemetryJson,
283 '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n',
284 );
285 }),
286 ),
287 );
288
289 it.effect("preserves the readiness timeout cause and process context", () =>
290 Effect.gen(function* () {
291 const requested = yield* Deferred.make<HttpClientRequest.HttpClientRequest>();
292 const layer = Layer.merge(
293 TestClock.layer(),
294 layerHttpClient((request) =>
295 Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)),
296 ),
297 );
298
299 yield* Effect.gen(function* () {
300 const readiness = yield* DesktopBackendManager.waitForHttpReady({
301 executablePath: baseConfig.executablePath,
302 entryPath: baseConfig.entryPath,
303 cwd: baseConfig.cwd,
304 httpBaseUrl: baseConfig.httpBaseUrl,
305 timeout: Duration.millis(50),
306 }).pipe(Effect.flip, Effect.forkChild);
307
308 const request = yield* Deferred.await(requested);
309 assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment");
310
311 yield* TestClock.adjust(Duration.millis(50));
312 const error = yield* Fiber.join(readiness);
313
314 assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError);
315 assert.equal(error.executablePath, "/electron");
316 assert.equal(error.entryPath, "/server/bin.mjs");
317 assert.equal(error.cwd, "/server");
318 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
319 assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment");
320 assert.equal(error.timeoutMs, 50);
321 assert.isDefined(error.cause);
322 assert.equal(
323 error.message,
324 "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.",
325 );
326 }).pipe(Effect.provide(layer));
327 }),
328 );
329
330 it.effect("reports bootstrap encoding failures with stable process context", () =>
331 Effect.gen(function* () {
332 const layerSpawner = Layer.succeed(
333 ChildProcessSpawner.ChildProcessSpawner,
334 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
335 );
336 const error = yield* DesktopBackendManager.runBackendProcess({
337 ...baseConfig,
338 desktopTelemetryStream: Stream.empty,
339 bootstrap: {
340 ...baseConfig.bootstrap,
341 port: 0,
342 },
343 }).pipe(
344 Effect.flip,
345 Effect.scoped,
346 Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)),
347 );
348
349 if (error._tag !== "BackendProcessBootstrapEncodeError") {
350 return assert.fail(`Expected bootstrap encode error, received ${error._tag}`);
351 }
352 assert.equal(error.executablePath, "/electron");
353 assert.equal(error.entryPath, "/server/bin.mjs");
354 assert.equal(error.cwd, "/server");
355 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
356 assert.isDefined(error.cause);
357 assert.equal(
358 error.message,
359 "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.",
360 );
361 assert.isTrue(isBackendProcessError(error));
362 }),
363 );
364
365 it.effect("preserves spawn failures without deriving their message from the cause", () =>
366 Effect.gen(function* () {
367 const spawnCause = PlatformError.systemError({
368 _tag: "PermissionDenied",
369 module: "ChildProcessSpawner",
370 method: "spawn",
371 pathOrDescriptor: baseConfig.executablePath,
372 description: "low-level detail that must not become the public message",
373 });
374 const layerSpawner = Layer.succeed(
375 ChildProcessSpawner.ChildProcessSpawner,
376 ChildProcessSpawner.make(() => Effect.fail(spawnCause)),
377 );
378 const error = yield* DesktopBackendManager.runBackendProcess({
379 ...baseConfig,
380 desktopTelemetryStream: Stream.empty,
381 }).pipe(
382 Effect.flip,
383 Effect.scoped,
384 Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)),
385 );
386
387 if (error._tag !== "BackendProcessSpawnError") {
388 return assert.fail(`Expected backend spawn error, received ${error._tag}`);
389 }
390 assert.equal(error.executablePath, "/electron");
391 assert.equal(error.entryPath, "/server/bin.mjs");
392 assert.equal(error.cwd, "/server");
393 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
394 assert.strictEqual(error.cause, spawnCause);
395 assert.equal(
396 error.message,
397 "Failed to spawn desktop backend entry /server/bin.mjs with /electron.",
398 );
399 assert.notInclude(error.message, spawnCause.message);
400 assert.isTrue(isBackendProcessError(error));
401 }),
402 );
403
404 it.effect("preserves exit-status failures without copying their detail into the message", () =>
405 Effect.gen(function* () {
406 const exitCause = PlatformError.systemError({
407 _tag: "PermissionDenied",
408 module: "ChildProcess",
409 method: "exitCode",
410 description: "exit-status-secret-sentinel",
411 });
412 const layerSpawner = Layer.succeed(
413 ChildProcessSpawner.ChildProcessSpawner,
414 ChildProcessSpawner.make(() =>
415 Effect.succeed(
416 makeProcess({
417 exitCode: Effect.fail(exitCause),
418 }),
419 ),
420 ),
421 );
422 const error = yield* DesktopBackendManager.runBackendProcess({
423 ...baseConfig,
424 desktopTelemetryStream: Stream.empty,
425 }).pipe(
426 Effect.flip,
427 Effect.scoped,
428 Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)),
429 );
430
431 if (error._tag !== "BackendProcessExitStatusError") {
432 return assert.fail(`Expected backend exit-status error, received ${error._tag}`);
433 }
434 assert.equal(error.pid, 123);
435 assert.equal(error.executablePath, "/electron");
436 assert.equal(error.entryPath, "/server/bin.mjs");
437 assert.equal(error.cwd, "/server");
438 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
439 assert.strictEqual(error.cause, exitCause);
440 assert.equal(error.message, "Failed to read the exit status of desktop backend process 123.");
441 assert.notInclude(error.message, "exit-status-secret-sentinel");
442 assert.isTrue(isBackendProcessError(error));
443 }),
444 );
445
446 it.effect("reports output stream failures with process and stream context", () =>
447 Effect.gen(function* () {
448 const outputCause = PlatformError.systemError({
449 _tag: "BadResource",
450 module: "ChildProcess",
451 method: "stdout",
452 description: "output-stream-secret-sentinel",
453 });
454 const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>();
455 const layerSpawner = Layer.succeed(
456 ChildProcessSpawner.ChildProcessSpawner,
457 ChildProcessSpawner.make(() =>
458 Effect.succeed(
459 makeProcess({
460 stdout: Stream.fail(outputCause),
461 exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
462 }),
463 ),
464 ),
465 );
466
467 const exit = yield* DesktopBackendManager.runBackendProcess({
468 ...baseConfig,
469 desktopTelemetryStream: Stream.empty,
470 onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid),
471 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)));
472 const error = yield* Deferred.await(reported);
473
474 assert.equal(exit.code.pipe(Option.getOrUndefined), 0);
475 if (error._tag !== "BackendProcessOutputReadError") {
476 return assert.fail(`Expected output read error, received ${error._tag}`);
477 }
478 assert.equal(error.executablePath, "/electron");
479 assert.equal(error.entryPath, "/server/bin.mjs");
480 assert.equal(error.cwd, "/server");
481 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
482 assert.equal(error.pid, 123);
483 assert.equal(error.streamName, "stdout");
484 assert.strictEqual(error.cause, outputCause);
485 assert.equal(error.message, "Failed to read stdout from desktop backend process 123.");
486 assert.notInclude(error.message, "output-stream-secret-sentinel");
487 }),
488 );
489
490 it.effect("reports output handler failures separately from stream read failures", () =>
491 Effect.gen(function* () {
492 const chunk = new TextEncoder().encode("backend output");
493 const nextChunk = new TextEncoder().encode("still draining");
494 const outputCause = new Error("output-handler-secret-sentinel");
495 const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>();
496 const drained = yield* Deferred.make<void>();
497 let outputCount = 0;
498 const layerSpawner = Layer.succeed(
499 ChildProcessSpawner.ChildProcessSpawner,
500 ChildProcessSpawner.make(() =>
501 Effect.succeed(
502 makeProcess({
503 stdout: Stream.make(chunk, nextChunk),
504 exitCode: Deferred.await(drained).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
505 }),
506 ),
507 ),
508 );
509
510 const exit = yield* DesktopBackendManager.runBackendProcess({
511 ...baseConfig,
512 desktopTelemetryStream: Stream.empty,
513 onOutput: () => {
514 outputCount += 1;
515 return outputCount === 1
516 ? Effect.fail(outputCause)
517 : Deferred.succeed(drained, void 0).pipe(Effect.asVoid);
518 },
519 onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid),
520 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)));
521 const error = yield* Deferred.await(reported);
522
523 assert.equal(exit.code.pipe(Option.getOrUndefined), 0);
524 if (error._tag !== "BackendProcessOutputHandlingError") {
525 return assert.fail(`Expected output handling error, received ${error._tag}`);
526 }
527 assert.equal(error.executablePath, "/electron");
528 assert.equal(error.entryPath, "/server/bin.mjs");
529 assert.equal(error.cwd, "/server");
530 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
531 assert.equal(error.pid, 123);
532 assert.equal(error.streamName, "stdout");
533 assert.equal(error.chunkByteLength, chunk.byteLength);
534 assert.strictEqual(error.cause, outputCause);
535 assert.equal(
536 error.message,
537 `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`,
538 );
539 assert.notInclude(error.message, "output-handler-secret-sentinel");
540 assert.equal(outputCount, 2);
541 }),
542 );
543
544 it.effect("reports child exit before waiting for trailing output to drain", () =>
545 Effect.scoped(
546 Effect.gen(function* () {
547 const exitObserved = yield* Deferred.make<void>();
548 const finishOutputDrain = yield* Deferred.make<void>();
549 const layerSpawner = Layer.succeed(
550 ChildProcessSpawner.ChildProcessSpawner,
551 ChildProcessSpawner.make(() =>
552 Effect.succeed(
553 makeProcess({
554 stdout: Stream.fromEffect(
555 Deferred.await(finishOutputDrain).pipe(
556 Effect.as(new TextEncoder().encode("trailing output\n")),
557 ),
558 ),
559 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
560 }),
561 ),
562 ),
563 );
564
565 const runFiber = yield* DesktopBackendManager.runBackendProcess({
566 ...baseConfig,
567 desktopTelemetryStream: Stream.empty,
568 onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid),
569 }).pipe(
570 Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)),
571 Effect.forkChild,
572 );
573
574 yield* Deferred.await(exitObserved);
575 assert.isUndefined(runFiber.pollUnsafe());
576
577 yield* Deferred.succeed(finishOutputDrain, void 0);
578 assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1);
579 }),
580 ),
581 );
582
583 it.effect("continues routing desktop telemetry control messages after an invalid line", () =>
584 Effect.scoped(
585 Effect.gen(function* () {
586 const handled = yield* Deferred.make<boolean>();
587 const controlMessage = encodeDesktopTelemetryControl({
588 version: 1,
589 type: "setDiagnosticsDemand",
590 enabled: true,
591 });
592 const layerSpawner = Layer.succeed(
593 ChildProcessSpawner.ChildProcessSpawner,
594 ChildProcessSpawner.make(() =>
595 Effect.succeed(
596 makeProcess({
597 getOutputFd: (fd) =>
598 fd === 5
599 ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`))
600 : Stream.empty,
601 exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
602 }),
603 ),
604 ),
605 );
606 const instance = yield* makeTestInstance({
607 spawnerLayer: layerSpawner,
608 desktopTelemetryPublisher: {
609 handleControlForSource: (_sourceId, message) =>
610 message.type === "setDiagnosticsDemand"
611 ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid)
612 : Effect.void,
613 },
614 });
615
616 yield* instance.start;
617 assert.isTrue(yield* Deferred.await(handled));
618 }),
619 ),
620 );
621
622 it.effect("drains trailing child output before persisting an unexpected exit", () =>
623 Effect.scoped(
624 Effect.gen(function* () {
625 const persistedOutput = yield* Deferred.make<ReadonlyArray<string>>();
626 const outputDrainStarted = yield* Deferred.make<void>();
627 const outputChunks = yield* Ref.make<Array<string>>([]);
628 const layerSpawner = Layer.succeed(
629 ChildProcessSpawner.ChildProcessSpawner,
630 ChildProcessSpawner.make(() =>
631 Effect.succeed(
632 makeProcess({
633 stdout: Stream.fromEffect(
634 Deferred.succeed(outputDrainStarted, void 0).pipe(
635 Effect.andThen(Effect.sleep(Duration.seconds(1))),
636 Effect.as(new TextEncoder().encode("trailing output\n")),
637 ),
638 ),
639 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
640 }),
641 ),
642 ),
643 );
644 const instance = yield* makeTestInstance({
645 spawnerLayer: layerSpawner,
646 httpClientLayer: layerHttpClient(() => Effect.never),
647 backendOutputLog: {
648 writeOutputChunk: (_streamName, chunk) =>
649 Ref.update(outputChunks, (current) => [...current, new TextDecoder().decode(chunk)]),
650 persistFailure: () =>
651 Ref.get(outputChunks).pipe(
652 Effect.flatMap((chunks) => Deferred.succeed(persistedOutput, chunks)),
653 Effect.asVoid,
654 ),
655 },
656 });
657
658 yield* instance.start;
659 yield* Deferred.await(outputDrainStarted);
660 yield* TestClock.adjust(Duration.seconds(1));
661
662 assert.deepEqual(yield* Deferred.await(persistedOutput), ["trailing output\n"]);
663 }).pipe(Effect.provide(TestClock.layer())),
664 ),
665 );
666
667 it.effect("retries HTTP readiness before reporting the backend ready", () =>
668 Effect.scoped(
669 Effect.gen(function* () {
670 const requestUrls: Array<string> = [];
671 const prunedRuntimes: Array<[string | null, string]> = [];
672 const statuses = [503, 200];
673 let readyCount = 0;
674 const firstRequest = yield* Deferred.make<void>();
675 const backendReady = yield* Deferred.make<void>();
676 const processExit = yield* Deferred.make<void>();
677 const pruneComplete = yield* Deferred.make<void>();
678 const exited = yield* Queue.unbounded<void>();
679
680 const layerSpawner = Layer.succeed(
681 ChildProcessSpawner.ChildProcessSpawner,
682 ChildProcessSpawner.make(() =>
683 Effect.succeed(
684 makeProcess({
685 exitCode: Deferred.await(processExit).pipe(
686 Effect.as(ChildProcessSpawner.ExitCode(0)),
687 ),
688 }),
689 ),
690 ),
691 );
692
693 const instance = yield* makeTestInstance({
694 spawnerLayer: layerSpawner,
695 config: {
696 ...baseConfig,
697 runningDistro: "Ubuntu",
698 wslRuntimeId: "1.2.3-x64",
699 },
700 pruneRuntimes: (distro, runtimeId) =>
701 Effect.sync(() => {
702 prunedRuntimes.push([distro, runtimeId]);
703 }).pipe(Effect.andThen(Deferred.succeed(pruneComplete, void 0)), Effect.asVoid),
704 httpClientLayer: layerHttpClient((request) =>
705 Effect.gen(function* () {
706 const status = statuses.shift();
707 assert.isDefined(status);
708 requestUrls.push(request.url);
709 yield* Deferred.succeed(firstRequest, void 0);
710 return responseForRequest(request, status);
711 }),
712 ),
713 onReady: Effect.sync(() => {
714 readyCount += 1;
715 }).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid),
716 backendOutputLog: {
717 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
718 },
719 });
720
721 yield* instance.start;
722 yield* Deferred.await(firstRequest);
723
724 assert.equal(readyCount, 0);
725 assert.deepEqual(prunedRuntimes, []);
726 assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]);
727
728 yield* TestClock.adjust(Duration.millis(100));
729 yield* Deferred.await(backendReady);
730 yield* Deferred.await(pruneComplete);
731 yield* Deferred.succeed(processExit, void 0);
732 yield* Queue.take(exited);
733
734 assert.equal(readyCount, 1);
735 assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]);
736 assert.deepEqual(requestUrls, [
737 "http://127.0.0.1:3773/.well-known/t3/environment",
738 "http://127.0.0.1:3773/.well-known/t3/environment",
739 ]);
740 }).pipe(Effect.provide(TestClock.layer())),
741 ),
742 );
743
744 it.effect(
745 "re-probes readiness after the first budget expires while the backend is still alive",
746 () =>
747 Effect.scoped(
748 Effect.gen(function* () {
749 const requestUrls: Array<string> = [];
750 let requestCount = 0;
751 let readyCount = 0;
752 let readinessTimeoutCount = 0;
753 const firstProbe = yield* Deferred.make<void>();
754 const childExit = yield* Deferred.make<void>();
755
756 const layerSpawner = Layer.succeed(
757 ChildProcessSpawner.ChildProcessSpawner,
758 ChildProcessSpawner.make(() =>
759 Effect.succeed(
760 makeProcess({
761 exitCode: Deferred.await(childExit).pipe(
762 Effect.as(ChildProcessSpawner.ExitCode(0)),
763 ),
764 }),
765 ),
766 ),
767 );
768
769 // The backend stays 503 through the first *two* readiness budgets
770 // and only becomes healthy (200) for the third round, i.e. it comes
771 // up well after the initial 50ms budget has expired.
772 const layerHttp = layerHttpClient((request) =>
773 Effect.gen(function* () {
774 requestCount += 1;
775 requestUrls.push(request.url);
776 yield* Deferred.succeed(firstProbe, void 0);
777 return responseForRequest(request, requestCount <= 2 ? 503 : 200);
778 }),
779 );
780
781 const runFiber = yield* DesktopBackendManager.runBackendProcess({
782 ...baseConfig,
783 desktopTelemetryStream: Stream.empty,
784 readinessTimeout: Duration.millis(50),
785 onReady: () =>
786 Effect.sync(() => {
787 readyCount += 1;
788 }),
789 onReadinessFailure: () =>
790 Effect.sync(() => {
791 readinessTimeoutCount += 1;
792 }),
793 }).pipe(Effect.provide(Layer.merge(layerSpawner, layerHttp)), Effect.forkChild);
794
795 yield* Deferred.await(firstProbe);
796 assert.equal(readyCount, 0);
797 assert.equal(readinessTimeoutCount, 0);
798
799 // The first 50ms readiness budget expires while the backend still
800 // answers 503. The child is alive and may yet become healthy, so the
801 // probe must start a fresh round instead of stopping permanently —
802 // the pre-fix behavior left the app stuck on "Connecting to WSL…"
803 // forever even though the backend kept running.
804 yield* TestClock.adjust(Duration.millis(50));
805 assert.equal(readinessTimeoutCount, 1);
806 assert.equal(readyCount, 0);
807
808 // The second budget also expires (backend still 503), then the third
809 // round connects. The point is the probe persisted across budgets
810 // while the process was alive instead of giving up after the first.
811 yield* TestClock.adjust(Duration.millis(100));
812 assert.equal(readinessTimeoutCount, 2);
813 assert.equal(readyCount, 1);
814 assert.equal(requestUrls.length, 3);
815
816 yield* Deferred.succeed(childExit, void 0);
817 assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 0);
818 }).pipe(Effect.provide(TestClock.layer())),
819 ),
820 );
821
822 it.effect("starts the configured backend and closes the scoped process on stop", () =>
823 Effect.scoped(
824 Effect.gen(function* () {
825 let startCount = 0;
826 let closedCount = 0;
827 const closed = yield* Deferred.make<void>();
828 const teardownStarted = yield* Deferred.make<void>();
829 const finishTeardown = yield* Deferred.make<void>();
830 const startedPids = yield* Queue.unbounded<number>();
831 const ready = yield* Deferred.make<void>();
832 const backendReadyFlag = yield* Ref.make(false);
833 let shutdownCount = 0;
834 let persistedFailureCount = 0;
835 let discardedSessionCount = 0;
836 let removedTelemetrySources = 0;
837
838 const layerSpawner = Layer.succeed(
839 ChildProcessSpawner.ChildProcessSpawner,
840 ChildProcessSpawner.make(() =>
841 Effect.gen(function* () {
842 const scope = yield* Scope.Scope;
843 startCount += 1;
844 yield* Queue.offer(startedPids, 123);
845 const close = Deferred.succeed(teardownStarted, undefined).pipe(
846 Effect.andThen(Deferred.await(finishTeardown)),
847 Effect.andThen(
848 Effect.sync(() => {
849 closedCount += 1;
850 }),
851 ),
852 Effect.andThen(Deferred.succeed(closed, void 0)),
853 Effect.asVoid,
854 );
855
856 yield* Scope.addFinalizer(scope, close);
857
858 return makeProcess({
859 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
860 kill: () => close,
861 });
862 }),
863 ),
864 );
865
866 const instance = yield* makeTestInstance({
867 spawnerLayer: layerSpawner,
868 onReady: Ref.set(backendReadyFlag, true).pipe(
869 Effect.andThen(Deferred.succeed(ready, void 0)),
870 Effect.asVoid,
871 ),
872 onShutdown: Ref.set(backendReadyFlag, false).pipe(
873 Effect.andThen(
874 Effect.sync(() => {
875 shutdownCount += 1;
876 }),
877 ),
878 ),
879 backendOutputLog: {
880 persistFailure: () =>
881 Effect.sync(() => {
882 persistedFailureCount += 1;
883 }),
884 discardSession: Effect.sync(() => {
885 discardedSessionCount += 1;
886 }),
887 },
888 desktopTelemetryPublisher: {
889 removeControlSource: () =>
890 Effect.sync(() => {
891 removedTelemetrySources += 1;
892 }),
893 },
894 });
895 assert.isTrue(Option.isNone(yield* instance.currentConfig));
896
897 yield* instance.start;
898 assert.equal(yield* Queue.take(startedPids), 123);
899 yield* Deferred.await(ready);
900 assert.isTrue(yield* Ref.get(backendReadyFlag));
901 assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig));
902
903 const runningSnapshot = yield* instance.snapshot;
904 assert.equal(runningSnapshot.ready, true);
905 assert.deepEqual(runningSnapshot.activePid, Option.some(123));
906
907 const stopFiber = yield* instance.stop().pipe(Effect.forkChild);
908 yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second"));
909 assert.isFalse(yield* Ref.get(backendReadyFlag));
910 assert.equal(shutdownCount, 1);
911 yield* Deferred.succeed(finishTeardown, undefined);
912 yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second"));
913 assert.equal(startCount, 1);
914 assert.equal(closedCount, 1);
915 assert.equal(persistedFailureCount, 0);
916 assert.equal(discardedSessionCount, 1);
917 assert.equal(removedTelemetrySources, 1);
918
919 const stoppedSnapshot = yield* instance.snapshot;
920 assert.isFalse(yield* Ref.get(backendReadyFlag));
921 assert.equal(shutdownCount, 1);
922 assert.equal(stoppedSnapshot.desiredRunning, false);
923 assert.equal(stoppedSnapshot.ready, false);
924 assert.equal(Option.isNone(stoppedSnapshot.activePid), true);
925 }),
926 ),
927 );
928
929 it.effect("restarts when start is requested during stop teardown", () =>
930 Effect.scoped(
931 Effect.gen(function* () {
932 const starts = yield* Queue.unbounded<number>();
933 const teardownStarted = yield* Deferred.make<void>();
934 const finishTeardown = yield* Deferred.make<void>();
935 let startCount = 0;
936
937 const layerSpawner = Layer.succeed(
938 ChildProcessSpawner.ChildProcessSpawner,
939 ChildProcessSpawner.make(() =>
940 Effect.gen(function* () {
941 const scope = yield* Scope.Scope;
942 const closed = yield* Deferred.make<void>();
943 startCount += 1;
944 yield* Queue.offer(starts, startCount);
945 if (startCount === 1) {
946 yield* Scope.addFinalizer(
947 scope,
948 Deferred.succeed(teardownStarted, undefined).pipe(
949 Effect.andThen(Deferred.await(finishTeardown)),
950 Effect.andThen(Deferred.succeed(closed, undefined)),
951 Effect.asVoid,
952 ),
953 );
954 } else {
955 yield* Scope.addFinalizer(
956 scope,
957 Deferred.succeed(closed, undefined).pipe(Effect.asVoid),
958 );
959 }
960 return makeProcess({
961 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
962 });
963 }),
964 ),
965 );
966
967 const instance = yield* makeTestInstance({
968 spawnerLayer: layerSpawner,
969 httpClientLayer: layerHttpClient(() => Effect.never),
970 });
971
972 yield* instance.start;
973 assert.equal(yield* Queue.take(starts), 1);
974
975 const stopFiber = yield* instance.stop().pipe(Effect.forkChild);
976 yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second"));
977 yield* instance.start;
978 assert.equal((yield* instance.snapshot).desiredRunning, true);
979
980 yield* Deferred.succeed(finishTeardown, undefined);
981 yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second"));
982 yield* TestClock.adjust(Duration.millis(500));
983
984 assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2);
985 const restarted = yield* instance.snapshot;
986 assert.equal(restarted.desiredRunning, true);
987 assert.deepEqual(restarted.activePid, Option.some(123));
988 }).pipe(Effect.provide(TestClock.layer())),
989 ),
990 );
991
992 it.effect("retries config resolution after a start request during stop teardown", () =>
993 Effect.scoped(
994 Effect.gen(function* () {
995 const starts = yield* Queue.unbounded<number>();
996 const teardownStarted = yield* Deferred.make<void>();
997 const finishTeardown = yield* Deferred.make<void>();
998 const configAttempts = yield* Ref.make(0);
999 let startCount = 0;
1000
1001 const configFailure = PlatformError.systemError({
1002 _tag: "Unknown",
1003 module: "DesktopBackendManager",
1004 method: "configResolve",
1005 description: "transient configuration failure",
1006 });
1007 const layerSpawner = Layer.succeed(
1008 ChildProcessSpawner.ChildProcessSpawner,
1009 ChildProcessSpawner.make(() =>
1010 Effect.gen(function* () {
1011 const scope = yield* Scope.Scope;
1012 const closed = yield* Deferred.make<void>();
1013 startCount += 1;
1014 yield* Queue.offer(starts, startCount);
1015 if (startCount === 1) {
1016 yield* Scope.addFinalizer(
1017 scope,
1018 Deferred.succeed(teardownStarted, undefined).pipe(
1019 Effect.andThen(Deferred.await(finishTeardown)),
1020 Effect.andThen(Deferred.succeed(closed, undefined)),
1021 Effect.asVoid,
1022 ),
1023 );
1024 } else {
1025 yield* Scope.addFinalizer(
1026 scope,
1027 Deferred.succeed(closed, undefined).pipe(Effect.asVoid),
1028 );
1029 }
1030 return makeProcess({
1031 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
1032 });
1033 }),
1034 ),
1035 );
1036 const configResolve = Ref.updateAndGet(configAttempts, (attempt) => attempt + 1).pipe(
1037 Effect.flatMap((attempt) =>
1038 attempt === 2 ? Effect.fail(configFailure) : Effect.succeed(baseConfig),
1039 ),
1040 );
1041 const instance = yield* makeTestInstance({
1042 spawnerLayer: layerSpawner,
1043 configResolve,
1044 httpClientLayer: layerHttpClient(() => Effect.never),
1045 });
1046
1047 yield* instance.start;
1048 assert.equal(yield* Queue.take(starts), 1);
1049
1050 const stopFiber = yield* instance.stop().pipe(Effect.forkChild);
1051 yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second"));
1052 yield* instance.start;
1053 yield* Deferred.succeed(finishTeardown, undefined);
1054 yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second"));
1055
1056 const pendingRestart = yield* instance.snapshot;
1057 assert.equal(pendingRestart.desiredRunning, true);
1058 assert.equal(pendingRestart.restartScheduled, true);
1059
1060 yield* TestClock.adjust(Duration.seconds(2));
1061
1062 assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2);
1063 assert.equal(yield* Ref.get(configAttempts), 3);
1064 assert.equal((yield* instance.snapshot).desiredRunning, true);
1065 }).pipe(Effect.provide(TestClock.layer())),
1066 ),
1067 );
1068
1069 it.effect("keeps a timed-out run active until its process exits", () =>
1070 Effect.scoped(
1071 Effect.gen(function* () {
1072 const starts = yield* Queue.unbounded<number>();
1073 const teardownStarted = yield* Deferred.make<void>();
1074 const finishTeardown = yield* Deferred.make<void>();
1075 let startCount = 0;
1076
1077 const layerSpawner = Layer.succeed(
1078 ChildProcessSpawner.ChildProcessSpawner,
1079 ChildProcessSpawner.make(() =>
1080 Effect.gen(function* () {
1081 const scope = yield* Scope.Scope;
1082 const closed = yield* Deferred.make<void>();
1083 startCount += 1;
1084 yield* Queue.offer(starts, startCount);
1085 if (startCount === 1) {
1086 yield* Scope.addFinalizer(
1087 scope,
1088 Deferred.succeed(teardownStarted, undefined).pipe(
1089 Effect.andThen(Deferred.await(finishTeardown)),
1090 Effect.andThen(Deferred.succeed(closed, undefined)),
1091 Effect.asVoid,
1092 ),
1093 );
1094 } else {
1095 yield* Scope.addFinalizer(
1096 scope,
1097 Deferred.succeed(closed, undefined).pipe(Effect.asVoid),
1098 );
1099 }
1100 return makeProcess({
1101 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
1102 });
1103 }),
1104 ),
1105 );
1106
1107 const instance = yield* makeTestInstance({
1108 spawnerLayer: layerSpawner,
1109 httpClientLayer: layerHttpClient(() => Effect.never),
1110 });
1111
1112 yield* instance.start;
1113 assert.equal(yield* Queue.take(starts), 1);
1114
1115 const stopFiber = yield* instance
1116 .stop({ timeout: Duration.millis(100) })
1117 .pipe(Effect.forkChild);
1118 yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second"));
1119 yield* instance.start;
1120 yield* TestClock.adjust(Duration.millis(100));
1121 yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second"));
1122
1123 assert.equal(startCount, 1);
1124 const timedOut = yield* instance.snapshot;
1125 assert.equal(timedOut.desiredRunning, true);
1126 assert.deepEqual(timedOut.activePid, Option.some(123));
1127
1128 yield* Deferred.succeed(finishTeardown, undefined);
1129 yield* TestClock.adjust(Duration.millis(500));
1130
1131 assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2);
1132 }).pipe(Effect.provide(TestClock.layer())),
1133 ),
1134 );
1135
1136 it.effect("does not notify shutdown before the first start has prior state", () =>
1137 Effect.scoped(
1138 Effect.gen(function* () {
1139 let shutdownCount = 0;
1140 const closed = yield* Deferred.make<void>();
1141 const startedPids = yield* Queue.unbounded<number>();
1142
1143 const layerSpawner = Layer.succeed(
1144 ChildProcessSpawner.ChildProcessSpawner,
1145 ChildProcessSpawner.make(() =>
1146 Effect.gen(function* () {
1147 yield* Queue.offer(startedPids, 123);
1148 const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid);
1149 return makeProcess({
1150 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
1151 kill: () => close,
1152 });
1153 }),
1154 ),
1155 );
1156
1157 const instance = yield* makeTestInstance({
1158 spawnerLayer: layerSpawner,
1159 httpClientLayer: layerHttpClient(() => Effect.never),
1160 onShutdown: Effect.sync(() => {
1161 shutdownCount += 1;
1162 }),
1163 });
1164
1165 yield* instance.start;
1166 assert.equal(yield* Queue.take(startedPids), 123);
1167 assert.equal(shutdownCount, 0);
1168 }),
1169 ),
1170 );
1171
1172 it.effect("restarts an unexpectedly exited backend with the Effect clock", () =>
1173 Effect.scoped(
1174 Effect.gen(function* () {
1175 const starts = yield* Queue.unbounded<number>();
1176 const failures = yield* Queue.unbounded<string>();
1177 let startCount = 0;
1178
1179 const layerSpawner = Layer.succeed(
1180 ChildProcessSpawner.ChildProcessSpawner,
1181 ChildProcessSpawner.make(() =>
1182 Effect.sync(() => {
1183 startCount += 1;
1184 return makeProcess({
1185 exitCode: Queue.offer(starts, startCount).pipe(
1186 Effect.as(ChildProcessSpawner.ExitCode(1)),
1187 ),
1188 });
1189 }),
1190 ),
1191 );
1192
1193 const instance = yield* makeTestInstance({
1194 spawnerLayer: layerSpawner,
1195 httpClientLayer: layerHttpClient(() => Effect.never),
1196 backendOutputLog: {
1197 persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid),
1198 },
1199 });
1200
1201 yield* instance.start;
1202
1203 assert.equal(yield* Queue.take(starts), 1);
1204 assert.equal(yield* Queue.take(failures), "pid=123 code=1");
1205
1206 yield* TestClock.adjust(Duration.millis(499));
1207 assert.equal(yield* Queue.size(starts), 0);
1208 yield* TestClock.adjust(Duration.millis(1));
1209 assert.equal(yield* Queue.take(starts), 2);
1210
1211 yield* TestClock.adjust(Duration.millis(999));
1212 assert.equal(yield* Queue.size(starts), 0);
1213 yield* TestClock.adjust(Duration.millis(1));
1214 assert.equal(yield* Queue.take(starts), 3);
1215 }).pipe(Effect.provide(TestClock.layer())),
1216 ),
1217 );
1218
1219 it.effect("reports state-directory ownership once without scheduling another backend", () =>
1220 Effect.scoped(
1221 Effect.gen(function* () {
1222 let startCount = 0;
1223 const refused = yield* Deferred.make<void>();
1224 const instance = yield* makeTestInstance({
1225 spawnerLayer: Layer.succeed(
1226 ChildProcessSpawner.ChildProcessSpawner,
1227 ChildProcessSpawner.make(() =>
1228 Effect.sync(() => {
1229 startCount += 1;
1230 return makeProcess({
1231 exitCode: Effect.succeed(
1232 ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED),
1233 ),
1234 });
1235 }),
1236 ),
1237 ),
1238 httpClientLayer: layerHttpClient(() => Effect.never),
1239 onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid),
1240 });
1241 yield* instance.start;
1242 yield* Deferred.await(refused);
1243 const snapshot = yield* instance.snapshot;
1244 assert.isFalse(snapshot.desiredRunning);
1245 assert.isFalse(snapshot.restartScheduled);
1246 yield* TestClock.adjust(Duration.minutes(1));
1247 assert.equal(startCount, 1);
1248 }).pipe(Effect.provide(TestClock.layer())),
1249 ),
1250 );
1251
1252 it.effect("does not notify shutdown when a scheduled restart starts from non-ready state", () =>
1253 Effect.scoped(
1254 Effect.gen(function* () {
1255 let shutdownCount = 0;
1256
1257 const layerSpawner = Layer.succeed(
1258 ChildProcessSpawner.ChildProcessSpawner,
1259 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
1260 );
1261
1262 const instance = yield* makeTestInstance({
1263 spawnerLayer: layerSpawner,
1264 config: {
1265 ...baseConfig,
1266 preflightFailure: Option.some({ reason: "preflight failed", fatal: false }),
1267 },
1268 onShutdown: Effect.sync(() => {
1269 shutdownCount += 1;
1270 }),
1271 });
1272
1273 yield* instance.start;
1274 assert.equal(shutdownCount, 0);
1275
1276 yield* TestClock.adjust(Duration.millis(500));
1277 assert.equal(shutdownCount, 0);
1278 }).pipe(Effect.provide(TestClock.layer())),
1279 ),
1280 );
1281
1282 it.effect("surfaces a fatal preflight failure once and stops looping after the cap", () =>
1283 Effect.scoped(
1284 Effect.gen(function* () {
1285 const failures: string[] = [];
1286 const layerSpawner = Layer.succeed(
1287 ChildProcessSpawner.ChildProcessSpawner,
1288 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
1289 );
1290
1291 const instance = yield* makeTestInstance({
1292 spawnerLayer: layerSpawner,
1293 config: {
1294 ...baseConfig,
1295 preflightFailure: Option.some({ reason: "Node.js not found", fatal: true }),
1296 },
1297 onPreflightFailed: (failure) =>
1298 Effect.sync(() => {
1299 failures.push(failure.reason);
1300 }).pipe(Effect.as(false)),
1301 });
1302
1303 yield* instance.start;
1304 assert.deepEqual(failures, []);
1305
1306 // Five fatal attempts with exponential backoff (500ms, 1s, 2s, 4s) reach
1307 // the cap, at which point the failure is surfaced exactly once.
1308 yield* TestClock.adjust(Duration.millis(500));
1309 yield* TestClock.adjust(Duration.seconds(1));
1310 yield* TestClock.adjust(Duration.seconds(2));
1311 yield* TestClock.adjust(Duration.seconds(4));
1312 assert.deepEqual(failures, ["Node.js not found"]);
1313
1314 // Past the cap the loop stops and nothing else is surfaced.
1315 yield* TestClock.adjust(Duration.seconds(8));
1316 yield* TestClock.adjust(Duration.seconds(30));
1317 assert.deepEqual(failures, ["Node.js not found"]);
1318 }).pipe(Effect.provide(TestClock.layer())),
1319 ),
1320 );
1321
1322 it.effect("can be started again after a fatal preflight cap once config recovers", () =>
1323 Effect.scoped(
1324 Effect.gen(function* () {
1325 const failing = yield* Ref.make(true);
1326 const starts = yield* Queue.unbounded<number>();
1327 const layerSpawner = Layer.succeed(
1328 ChildProcessSpawner.ChildProcessSpawner,
1329 ChildProcessSpawner.make(() =>
1330 Queue.offer(starts, 123).pipe(
1331 Effect.as(
1332 makeProcess({
1333 exitCode: Effect.never,
1334 }),
1335 ),
1336 ),
1337 ),
1338 );
1339
1340 const instance = yield* makeTestInstance({
1341 spawnerLayer: layerSpawner,
1342 configResolve: Ref.get(failing).pipe(
1343 Effect.map((isFailing) =>
1344 isFailing
1345 ? {
1346 ...baseConfig,
1347 preflightFailure: Option.some({
1348 reason: "Node.js not found",
1349 fatal: true,
1350 }),
1351 }
1352 : baseConfig,
1353 ),
1354 ),
1355 });
1356
1357 yield* instance.start;
1358 yield* TestClock.adjust(Duration.millis(500));
1359 yield* TestClock.adjust(Duration.seconds(1));
1360 yield* TestClock.adjust(Duration.seconds(2));
1361 yield* TestClock.adjust(Duration.seconds(4));
1362 yield* TestClock.adjust(Duration.seconds(8));
1363
1364 const parked = yield* instance.snapshot;
1365 assert.equal(parked.desiredRunning, false);
1366 assert.equal(parked.ready, false);
1367 assert.isTrue(Option.isNone(parked.activePid));
1368 assert.equal(parked.restartScheduled, false);
1369 assert.equal(yield* Queue.size(starts), 0);
1370
1371 yield* Ref.set(failing, false);
1372 yield* instance.start;
1373
1374 assert.equal(yield* Queue.take(starts), 123);
1375 const running = yield* instance.snapshot;
1376 assert.equal(running.desiredRunning, true);
1377 assert.deepEqual(running.activePid, Option.some(123));
1378 }).pipe(Effect.provide(TestClock.layer())),
1379 ),
1380 );
1381
1382 it.effect("keeps retrying a transient (non-fatal) preflight failure without surfacing", () =>
1383 Effect.scoped(
1384 Effect.gen(function* () {
1385 const failures: string[] = [];
1386 const layerSpawner = Layer.succeed(
1387 ChildProcessSpawner.ChildProcessSpawner,
1388 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
1389 );
1390
1391 const instance = yield* makeTestInstance({
1392 spawnerLayer: layerSpawner,
1393 config: {
1394 ...baseConfig,
1395 preflightFailure: Option.some({ reason: "wslpath conversion failed", fatal: false }),
1396 },
1397 onPreflightFailed: (failure) =>
1398 Effect.sync(() => {
1399 failures.push(failure.reason);
1400 }).pipe(Effect.as(false)),
1401 });
1402
1403 yield* instance.start;
1404 // Well beyond the fatal cap's worth of time: a transient failure must
1405 // keep retrying (self-heal) and never surface.
1406 yield* TestClock.adjust(Duration.minutes(2));
1407 assert.deepEqual(failures, []);
1408 }).pipe(Effect.provide(TestClock.layer())),
1409 ),
1410 );
1411
1412 it.effect("surfaces a bounded transient preflight failure after its retry limit", () =>
1413 Effect.scoped(
1414 Effect.gen(function* () {
1415 const failures: string[] = [];
1416 const layerSpawner = Layer.succeed(
1417 ChildProcessSpawner.ChildProcessSpawner,
1418 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
1419 );
1420
1421 const instance = yield* makeTestInstance({
1422 spawnerLayer: layerSpawner,
1423 config: {
1424 ...baseConfig,
1425 preflightFailure: Option.some({
1426 reason: "WSL toolchain probe timed out",
1427 fatal: false,
1428 retryLimit: 3,
1429 }),
1430 },
1431 onPreflightFailed: (failure) =>
1432 Effect.sync(() => {
1433 failures.push(failure.reason);
1434 }).pipe(Effect.as(false)),
1435 });
1436
1437 yield* instance.start;
1438 yield* TestClock.adjust(Duration.millis(500));
1439 assert.deepEqual(failures, []);
1440
1441 yield* TestClock.adjust(Duration.seconds(1));
1442 assert.deepEqual(failures, ["WSL toolchain probe timed out"]);
1443 }).pipe(Effect.provide(TestClock.layer())),
1444 ),
1445 );
1446
1447 it.effect("cancels a scheduled restart when start is requested manually", () =>
1448 Effect.scoped(
1449 Effect.gen(function* () {
1450 const starts = yield* Queue.unbounded<number>();
1451 const secondClosed = yield* Deferred.make<void>();
1452 let startCount = 0;
1453
1454 const layerSpawner = Layer.succeed(
1455 ChildProcessSpawner.ChildProcessSpawner,
1456 ChildProcessSpawner.make(() =>
1457 Effect.gen(function* () {
1458 startCount += 1;
1459 yield* Queue.offer(starts, startCount);
1460
1461 if (startCount === 1) {
1462 return makeProcess({
1463 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
1464 });
1465 }
1466
1467 const scope = yield* Scope.Scope;
1468 const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid);
1469 yield* Scope.addFinalizer(scope, close);
1470 return makeProcess({
1471 exitCode: Deferred.await(secondClosed).pipe(
1472 Effect.as(ChildProcessSpawner.ExitCode(0)),
1473 ),
1474 kill: () => close,
1475 });
1476 }),
1477 ),
1478 );
1479
1480 const instance = yield* makeTestInstance({
1481 spawnerLayer: layerSpawner,
1482 httpClientLayer: layerHttpClient(() => Effect.never),
1483 });
1484
1485 yield* instance.start;
1486
1487 assert.equal(yield* Queue.take(starts), 1);
1488 let restartScheduled = false;
1489 while (!restartScheduled) {
1490 restartScheduled = (yield* instance.snapshot).restartScheduled;
1491 if (!restartScheduled) {
1492 yield* Effect.yieldNow;
1493 }
1494 }
1495
1496 yield* instance.start;
1497 assert.equal(yield* Queue.take(starts), 2);
1498
1499 yield* instance.stop();
1500 yield* TestClock.adjust(Duration.millis(500));
1501
1502 assert.equal(yield* Queue.size(starts), 0);
1503 }).pipe(Effect.provide(TestClock.layer())),
1504 ),
1505 );
1506
1507 it.effect("does not restart after stop cancels a scheduled restart", () =>
1508 Effect.scoped(
1509 Effect.gen(function* () {
1510 const starts = yield* Queue.unbounded<number>();
1511 let startCount = 0;
1512
1513 const layerSpawner = Layer.succeed(
1514 ChildProcessSpawner.ChildProcessSpawner,
1515 ChildProcessSpawner.make(() =>
1516 Effect.sync(() => {
1517 startCount += 1;
1518 return makeProcess({
1519 exitCode: Queue.offer(starts, startCount).pipe(
1520 Effect.as(ChildProcessSpawner.ExitCode(1)),
1521 ),
1522 });
1523 }),
1524 ),
1525 );
1526
1527 const instance = yield* makeTestInstance({
1528 spawnerLayer: layerSpawner,
1529 httpClientLayer: layerHttpClient(() => Effect.never),
1530 });
1531
1532 yield* instance.start;
1533 assert.equal(yield* Queue.take(starts), 1);
1534
1535 let restartScheduled = false;
1536 while (!restartScheduled) {
1537 restartScheduled = (yield* instance.snapshot).restartScheduled;
1538 if (!restartScheduled) {
1539 yield* Effect.yieldNow;
1540 }
1541 }
1542
1543 yield* instance.stop();
1544 yield* TestClock.adjust(Duration.millis(500));
1545
1546 assert.equal(yield* Queue.size(starts), 0);
1547 assert.equal((yield* instance.snapshot).desiredRunning, false);
1548 }).pipe(Effect.provide(TestClock.layer())),
1549 ),
1550 );
1551
1552 it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () =>
1553 Effect.scoped(
1554 Effect.gen(function* () {
1555 // Each backend's process-scope finalizer reports when it starts and
1556 // when it finishes, keyed by instance name, so the test can prove
1557 // both backends reached each milestone instead of inferring it from
1558 // a shared flag or a clock advance.
1559 const teardownStarted = yield* Queue.unbounded<string>();
1560 const teardownFinished = yield* Queue.unbounded<string>();
1561 const allowTeardown = yield* Deferred.make<void>();
1562
1563 const makeInstance = (name: string) =>
1564 makeTestInstance({
1565 spawnerLayer: Layer.succeed(
1566 ChildProcessSpawner.ChildProcessSpawner,
1567 ChildProcessSpawner.make(() =>
1568 Effect.gen(function* () {
1569 const scope = yield* Scope.Scope;
1570 yield* Scope.addFinalizer(
1571 scope,
1572 Queue.offer(teardownStarted, name).pipe(
1573 Effect.andThen(Deferred.await(allowTeardown)),
1574 Effect.andThen(Queue.offer(teardownFinished, name)),
1575 Effect.asVoid,
1576 ),
1577 );
1578 return makeProcess({ exitCode: Effect.never });
1579 }),
1580 ),
1581 ),
1582 httpClientLayer: layerHttpClient(() => Effect.never),
1583 });
1584
1585 const instance1 = yield* makeInstance("instance1");
1586 const instance2 = yield* makeInstance("instance2");
1587
1588 yield* instance1.start;
1589 yield* instance2.start;
1590
1591 const layerMockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
1592 list: Effect.succeed([instance1, instance2]),
1593 get: () => Effect.succeedNone,
1594 primary: Effect.die(new Error("primary not implemented")),
1595 register: () => Effect.die(new Error("register not implemented")),
1596 unregister: () => Effect.die(new Error("unregister not implemented")),
1597 });
1598
1599 // Mirror the quit path: register stopAllPoolInstances as a scope
1600 // finalizer and let the scope close run it, rather than calling it
1601 // as an ordinary interruptible effect.
1602 const quitFiber = yield* Effect.scoped(
1603 Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()),
1604 ).pipe(Effect.provide(layerMockPool), Effect.forkChild);
1605
1606 const started = yield* Queue.takeN(teardownStarted, 2);
1607 assert.deepEqual(started.toSorted(), ["instance1", "instance2"]);
1608
1609 // Both backends are now hung in teardown. Advancing past the 5s
1610 // budget must let the quit finalizer return without them.
1611 yield* TestClock.adjust(Duration.seconds(5));
1612 yield* Fiber.join(quitFiber);
1613 assert.equal(yield* Queue.size(teardownFinished), 0);
1614
1615 // The timed-out closes keep running in the background and finish
1616 // once the backends unblock.
1617 yield* Deferred.succeed(allowTeardown, undefined);
1618 const finished = yield* Queue.takeN(teardownFinished, 2);
1619 assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]);
1620 }).pipe(Effect.provide(TestClock.layer())),
1621 ),
1622 );
1623});