authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:55:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:55:50-07:00
log6ff6c13fc8bff69e2967a4f768a076bbb1b88aa5
treea1d1dfcef9b583afc86fe037421d400182eb4254
parent60c9ca0e33dcb30c9c8348b37615a10615fc4e5f
signature Signed by PGP key B5690EEEBB952194

[codex] structure desktop backend process errors (#3254)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 301 insertions(+), 60 deletions(-)

apps/desktop/src/backend/DesktopBackendManager.test.ts+172-1
...@@ -3,12 +3,15 @@ import {...@@ -3,12 +3,15 @@ import {
3 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,3 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
4} from "@t3tools/contracts";4} from "@t3tools/contracts";
5import { assert, describe, it } from "@effect/vitest";5import { assert, describe, it } from "@effect/vitest";
6import * as Cause from "effect/Cause";
6import * as Deferred from "effect/Deferred";7import * as Deferred from "effect/Deferred";
7import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";10import * as FileSystem from "effect/FileSystem";
11import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as PlatformError from "effect/PlatformError";
12import * as Queue from "effect/Queue";15import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";16import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
...@@ -28,6 +31,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts";...@@ -28,6 +31,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts";
28const decodeDesktopBackendBootstrap = Schema.decodeEffect(31const decodeDesktopBackendBootstrap = Schema.decodeEffect(
29 Schema.fromJsonString(DesktopBackendBootstrap),32 Schema.fromJsonString(DesktopBackendBootstrap),
30);33);
34const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError);
3135
32const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {36const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
33 executablePath: "/electron",37 executablePath: "/electron",
...@@ -57,7 +61,7 @@ const configWithObservability: DesktopBackendBootstrapValue = {...@@ -57,7 +61,7 @@ const configWithObservability: DesktopBackendBootstrapValue = {
57function makeProcess(options?: {61function makeProcess(options?: {
58 readonly stdout?: Stream.Stream<Uint8Array>;62 readonly stdout?: Stream.Stream<Uint8Array>;
59 readonly stderr?: Stream.Stream<Uint8Array>;63 readonly stderr?: Stream.Stream<Uint8Array>;
60 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;64 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
61 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];65 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];
62}): ChildProcessSpawner.ChildProcessHandle {66}): ChildProcessSpawner.ChildProcessHandle {
63 return ChildProcessSpawner.makeHandle({67 return ChildProcessSpawner.makeHandle({
...@@ -145,6 +149,23 @@ function makeManagerLayer(input: {...@@ -145,6 +149,23 @@ function makeManagerLayer(input: {
145}149}
146150
147describe("DesktopBackendManager", () => {151describe("DesktopBackendManager", () => {
152 it("preserves the complete restart cause and schedule context", () => {
153 const cause = Cause.combine(
154 Cause.fail(new Error("start failed")),
155 Cause.die(new Error("restart defect")),
156 );
157 const error = new DesktopBackendManager.DesktopBackendRestartError({
158 reason: "backend exited with code 1",
159 delayMs: 500,
160 cause,
161 });
162
163 assert.strictEqual(error.cause, cause);
164 assert.equal(error.reason, "backend exited with code 1");
165 assert.equal(error.delayMs, 500);
166 assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay.");
167 });
168
148 it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () =>169 it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () =>
149 Effect.gen(function* () {170 Effect.gen(function* () {
150 let spawnedCommand: ChildProcess.Command | undefined;171 let spawnedCommand: ChildProcess.Command | undefined;
...@@ -218,6 +239,156 @@ describe("DesktopBackendManager", () => {...@@ -218,6 +239,156 @@ describe("DesktopBackendManager", () => {
218 }),239 }),
219 );240 );
220241
242 it.effect("preserves the readiness timeout cause and process context", () =>
243 Effect.gen(function* () {
244 const requested = yield* Deferred.make<HttpClientRequest.HttpClientRequest>();
245 const layer = Layer.merge(
246 TestClock.layer(),
247 httpClientLayer((request) =>
248 Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)),
249 ),
250 );
251
252 yield* Effect.gen(function* () {
253 const readiness = yield* DesktopBackendManager.waitForHttpReady({
254 executablePath: baseConfig.executablePath,
255 entryPath: baseConfig.entryPath,
256 cwd: baseConfig.cwd,
257 httpBaseUrl: baseConfig.httpBaseUrl,
258 timeout: Duration.millis(50),
259 }).pipe(Effect.flip, Effect.forkChild);
260
261 const request = yield* Deferred.await(requested);
262 assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment");
263
264 yield* TestClock.adjust(Duration.millis(50));
265 const error = yield* Fiber.join(readiness);
266
267 assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError);
268 assert.equal(error.executablePath, "/electron");
269 assert.equal(error.entryPath, "/server/bin.mjs");
270 assert.equal(error.cwd, "/server");
271 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
272 assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment");
273 assert.equal(error.timeoutMs, 50);
274 assert.isTrue(Cause.isTimeoutError(error.cause));
275 assert.equal(
276 error.message,
277 "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.",
278 );
279 }).pipe(Effect.provide(layer));
280 }),
281 );
282
283 it.effect("reports bootstrap encoding failures with stable process context", () =>
284 Effect.gen(function* () {
285 const spawnerLayer = Layer.succeed(
286 ChildProcessSpawner.ChildProcessSpawner,
287 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
288 );
289 const error = yield* DesktopBackendManager.runBackendProcess({
290 ...baseConfig,
291 bootstrap: {
292 ...baseConfig.bootstrap,
293 port: 0,
294 },
295 }).pipe(
296 Effect.flip,
297 Effect.scoped,
298 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
299 );
300
301 if (error._tag !== "BackendProcessBootstrapEncodeError") {
302 return assert.fail(`Expected bootstrap encode error, received ${error._tag}`);
303 }
304 assert.equal(error.executablePath, "/electron");
305 assert.equal(error.entryPath, "/server/bin.mjs");
306 assert.equal(error.cwd, "/server");
307 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
308 assert.isDefined(error.cause);
309 assert.equal(
310 error.message,
311 "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.",
312 );
313 assert.isTrue(isBackendProcessError(error));
314 }),
315 );
316
317 it.effect("preserves spawn failures without deriving their message from the cause", () =>
318 Effect.gen(function* () {
319 const spawnCause = PlatformError.systemError({
320 _tag: "PermissionDenied",
321 module: "ChildProcessSpawner",
322 method: "spawn",
323 pathOrDescriptor: baseConfig.executablePath,
324 description: "low-level detail that must not become the public message",
325 });
326 const spawnerLayer = Layer.succeed(
327 ChildProcessSpawner.ChildProcessSpawner,
328 ChildProcessSpawner.make(() => Effect.fail(spawnCause)),
329 );
330 const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe(
331 Effect.flip,
332 Effect.scoped,
333 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
334 );
335
336 if (error._tag !== "BackendProcessSpawnError") {
337 return assert.fail(`Expected backend spawn error, received ${error._tag}`);
338 }
339 assert.equal(error.executablePath, "/electron");
340 assert.equal(error.entryPath, "/server/bin.mjs");
341 assert.equal(error.cwd, "/server");
342 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
343 assert.strictEqual(error.cause, spawnCause);
344 assert.equal(
345 error.message,
346 "Failed to spawn desktop backend entry /server/bin.mjs with /electron.",
347 );
348 assert.notInclude(error.message, spawnCause.message);
349 assert.isTrue(isBackendProcessError(error));
350 }),
351 );
352
353 it.effect("preserves exit-status failures without copying their detail into the message", () =>
354 Effect.gen(function* () {
355 const exitCause = PlatformError.systemError({
356 _tag: "PermissionDenied",
357 module: "ChildProcess",
358 method: "exitCode",
359 description: "exit-status-secret-sentinel",
360 });
361 const spawnerLayer = Layer.succeed(
362 ChildProcessSpawner.ChildProcessSpawner,
363 ChildProcessSpawner.make(() =>
364 Effect.succeed(
365 makeProcess({
366 exitCode: Effect.fail(exitCause),
367 }),
368 ),
369 ),
370 );
371 const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe(
372 Effect.flip,
373 Effect.scoped,
374 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
375 );
376
377 if (error._tag !== "BackendProcessExitStatusError") {
378 return assert.fail(`Expected backend exit-status error, received ${error._tag}`);
379 }
380 assert.equal(error.pid, 123);
381 assert.equal(error.executablePath, "/electron");
382 assert.equal(error.entryPath, "/server/bin.mjs");
383 assert.equal(error.cwd, "/server");
384 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
385 assert.strictEqual(error.cause, exitCause);
386 assert.equal(error.message, "Failed to read the exit status of desktop backend process 123.");
387 assert.notInclude(error.message, "exit-status-secret-sentinel");
388 assert.isTrue(isBackendProcessError(error));
389 }),
390 );
391
221 it.effect("retries HTTP readiness before reporting the backend ready", () =>392 it.effect("retries HTTP readiness before reporting the backend ready", () =>
222 Effect.gen(function* () {393 Effect.gen(function* () {
223 const requestUrls: Array<string> = [];394 const requestUrls: Array<string> = [];
apps/desktop/src/backend/DesktopBackendManager.ts+129-59
...@@ -9,7 +9,6 @@ import * as Layer from "effect/Layer";...@@ -9,7 +9,6 @@ import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";10import * as PlatformError from "effect/PlatformError";
11import * as Ref from "effect/Ref";11import * as Ref from "effect/Ref";
12import * as Result from "effect/Result";
13import * as Schedule from "effect/Schedule";12import * as Schedule from "effect/Schedule";
14import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
15import * as Semaphore from "effect/Semaphore";14import * as Semaphore from "effect/Semaphore";
...@@ -43,64 +42,107 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;...@@ -43,64 +42,107 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;
4342
44export type BackendProcessOutputStream = "stdout" | "stderr";43export type BackendProcessOutputStream = "stdout" | "stderr";
4544
46export interface DesktopBackendStartConfig {45export interface BackendProcessContext {
47 readonly executablePath: string;46 readonly executablePath: string;
48 readonly entryPath: string;47 readonly entryPath: string;
49 readonly cwd: string;48 readonly cwd: string;
49 readonly httpBaseUrl: URL;
50}
51
52export interface DesktopBackendStartConfig extends BackendProcessContext {
50 readonly env: Record<string, string | undefined>;53 readonly env: Record<string, string | undefined>;
51 readonly bootstrap: DesktopBackendBootstrapValue;54 readonly bootstrap: DesktopBackendBootstrapValue;
52 readonly httpBaseUrl: URL;
53 readonly captureOutput: boolean;55 readonly captureOutput: boolean;
54}56}
5557
56interface BackendProcessExit {58interface BackendProcessExit {
57 readonly code: Option.Option<number>;59 readonly code: Option.Option<number>;
58 readonly reason: string;60 readonly reason: string;
59 readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
60}61}
6162
62export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()(63const backendProcessContextSchema = {
63 "BackendTimeoutError",64 executablePath: Schema.String,
65 entryPath: Schema.String,
66 cwd: Schema.String,
67 httpBaseUrl: Schema.URL,
68};
69
70export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass<BackendReadinessTimeoutError>()(
71 "BackendReadinessTimeoutError",
64 {72 {
65 url: Schema.URL,73 ...backendProcessContextSchema,
74 readinessUrl: Schema.URL,
75 timeoutMs: Schema.Number,
76 cause: Schema.Defect(),
66 },77 },
67) {78) {
68 override get message(): string {79 override get message(): string {
69 return `Timed out waiting for backend readiness at ${this.url.href}.`;80 return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`;
70 }81 }
71}82}
7283
73class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(84export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
74 "BackendProcessBootstrapEncodeError",85 "BackendProcessBootstrapEncodeError",
75 {86 {
76 detail: Schema.String,87 ...backendProcessContextSchema,
77 cause: Schema.Defect(),88 cause: Schema.Defect(),
78 },89 },
79) {90) {
80 override get message(): string {91 override get message(): string {
81 return `Failed to encode desktop backend bootstrap payload: ${this.detail}`;92 return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`;
82 }93 }
83}94}
8495
85class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(96export class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
86 "BackendProcessSpawnError",97 "BackendProcessSpawnError",
87 {98 {
88 detail: Schema.String,99 ...backendProcessContextSchema,
100 cause: Schema.Defect(),
101 },
102) {
103 override get message(): string {
104 return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`;
105 }
106}
107
108export class BackendProcessExitStatusError extends Schema.TaggedErrorClass<BackendProcessExitStatusError>()(
109 "BackendProcessExitStatusError",
110 {
111 ...backendProcessContextSchema,
112 pid: Schema.Number,
113 cause: Schema.Defect(),
114 },
115) {
116 override get message(): string {
117 return `Failed to read the exit status of desktop backend process ${this.pid}.`;
118 }
119}
120
121export class DesktopBackendRestartError extends Schema.TaggedErrorClass<DesktopBackendRestartError>()(
122 "DesktopBackendRestartError",
123 {
124 reason: Schema.String,
125 delayMs: Schema.Number,
89 cause: Schema.Defect(),126 cause: Schema.Defect(),
90 },127 },
91) {128) {
92 override get message(): string {129 override get message(): string {
93 return `Failed to spawn desktop backend process: ${this.detail}`;130 return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`;
94 }131 }
95}132}
96133
97type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;134export const BackendProcessError = Schema.Union([
135 BackendProcessBootstrapEncodeError,
136 BackendProcessSpawnError,
137 BackendProcessExitStatusError,
138]);
139export type BackendProcessError = typeof BackendProcessError.Type;
98140
99interface RunBackendProcessOptions extends DesktopBackendStartConfig {141interface RunBackendProcessOptions extends DesktopBackendStartConfig {
100 readonly readinessTimeout?: Duration.Duration;142 readonly readinessTimeout?: Duration.Duration;
101 readonly onStarted?: (pid: number) => Effect.Effect<void>;143 readonly onStarted?: (pid: number) => Effect.Effect<void>;
102 readonly onReady?: () => Effect.Effect<void>;144 readonly onReady?: () => Effect.Effect<void>;
103 readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>;145 readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>;
104 readonly onOutput?: (146 readonly onOutput?: (
105 streamName: BackendProcessOutputStream,147 streamName: BackendProcessOutputStream,
106 chunk: Uint8Array,148 chunk: Uint8Array,
...@@ -183,11 +225,10 @@ const closeRun = (...@@ -183,11 +225,10 @@ const closeRun = (
183 ).pipe(Effect.ignore);225 ).pipe(Effect.ignore);
184};226};
185227
186const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* (228export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* (
187 baseUrl: URL,229 options: BackendProcessContext & { readonly timeout: Duration.Duration },
188 timeout: Duration.Duration,230): Effect.fn.Return<void, BackendReadinessTimeoutError, HttpClient.HttpClient> {
189): Effect.fn.Return<void, BackendTimeoutError, HttpClient.HttpClient> {231 const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl);
190 const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl);
191 const client = (yield* HttpClient.HttpClient).pipe(232 const client = (yield* HttpClient.HttpClient).pipe(
192 HttpClient.filterStatusOk,233 HttpClient.filterStatusOk,
193 HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)),234 HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)),
...@@ -196,29 +237,22 @@ const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(fu...@@ -196,29 +237,22 @@ const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(fu
196237
197 yield* client.get(readinessUrl).pipe(238 yield* client.get(readinessUrl).pipe(
198 Effect.asVoid,239 Effect.asVoid,
199 Effect.timeout(timeout),240 Effect.timeout(options.timeout),
200 Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })),241 Effect.mapError(
242 (cause) =>
243 new BackendReadinessTimeoutError({
244 executablePath: options.executablePath,
245 entryPath: options.entryPath,
246 cwd: options.cwd,
247 httpBaseUrl: options.httpBaseUrl,
248 readinessUrl,
249 timeoutMs: Duration.toMillis(options.timeout),
250 cause,
251 }),
252 ),
201 );253 );
202});254});
203255
204function describeProcessExit(
205 result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>,
206): BackendProcessExit {
207 if (Result.isSuccess(result)) {
208 return {
209 code: Option.some(result.success),
210 reason: `code=${result.success}`,
211 result,
212 };
213 }
214
215 return {
216 code: Option.none(),
217 reason: result.failure.message,
218 result,
219 };
220}
221
222function drainBackendOutput(256function drainBackendOutput(
223 streamName: BackendProcessOutputStream,257 streamName: BackendProcessOutputStream,
224 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,258 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
...@@ -232,7 +266,7 @@ function drainBackendOutput(...@@ -232,7 +266,7 @@ function drainBackendOutput(
232266
233const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));267const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
234268
235const runBackendProcess = Effect.fn("runBackendProcess")(function* (269export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
236 options: RunBackendProcessOptions,270 options: RunBackendProcessOptions,
237): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {271): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
238 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;272 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
...@@ -240,7 +274,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (...@@ -240,7 +274,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
240 Effect.mapError(274 Effect.mapError(
241 (cause) =>275 (cause) =>
242 new BackendProcessBootstrapEncodeError({276 new BackendProcessBootstrapEncodeError({
243 detail: cause.message,277 executablePath: options.executablePath,
278 entryPath: options.entryPath,
279 cwd: options.cwd,
280 httpBaseUrl: options.httpBaseUrl,
244 cause,281 cause,
245 }),282 }),
246 ),283 ),
...@@ -273,7 +310,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (...@@ -273,7 +310,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
273 Effect.mapError(310 Effect.mapError(
274 (cause) =>311 (cause) =>
275 new BackendProcessSpawnError({312 new BackendProcessSpawnError({
276 detail: cause.message,313 executablePath: options.executablePath,
314 entryPath: options.entryPath,
315 cwd: options.cwd,
316 httpBaseUrl: options.httpBaseUrl,
277 cause,317 cause,
278 }),318 }),
279 ),319 ),
...@@ -284,16 +324,37 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (...@@ -284,16 +324,37 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
284 yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);324 yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);
285 yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);325 yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);
286 }326 }
287 yield* waitForHttpReady(327 yield* waitForHttpReady({
288 options.httpBaseUrl,328 executablePath: options.executablePath,
289 options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,329 entryPath: options.entryPath,
290 ).pipe(330 cwd: options.cwd,
331 httpBaseUrl: options.httpBaseUrl,
332 timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
333 }).pipe(
291 Effect.tap(() => options.onReady?.() ?? Effect.void),334 Effect.tap(() => options.onReady?.() ?? Effect.void),
292 Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void),335 Effect.catchTags({
336 BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void,
337 }),
293 Effect.forkScoped,338 Effect.forkScoped,
294 );339 );
295340
296 return describeProcessExit(yield* Effect.result(handle.exitCode));341 const exitCode = yield* handle.exitCode.pipe(
342 Effect.mapError(
343 (cause) =>
344 new BackendProcessExitStatusError({
345 executablePath: options.executablePath,
346 entryPath: options.entryPath,
347 cwd: options.cwd,
348 httpBaseUrl: options.httpBaseUrl,
349 pid: Number(handle.pid),
350 cause,
351 }),
352 ),
353 );
354 return {
355 code: Option.some(exitCode),
356 reason: `code=${exitCode}`,
357 } satisfies BackendProcessExit;
297});358});
298359
299export const make = Effect.gen(function* () {360export const make = Effect.gen(function* () {
...@@ -351,7 +412,7 @@ export const make = Effect.gen(function* () {...@@ -351,7 +412,7 @@ export const make = Effect.gen(function* () {
351 const config = yield* configuration.resolve.pipe(412 const config = yield* configuration.resolve.pipe(
352 Effect.tapError((error) =>413 Effect.tapError((error) =>
353 logBackendManagerError("failed to generate desktop backend configuration", {414 logBackendManagerError("failed to generate desktop backend configuration", {
354 cause: error.message,415 cause: error,
355 }),416 }),
356 ),417 ),
357 Effect.option,418 Effect.option,
...@@ -489,14 +550,14 @@ export const make = Effect.gen(function* () {...@@ -489,14 +550,14 @@ export const make = Effect.gen(function* () {
489 yield* desktopWindow.handleBackendReady.pipe(550 yield* desktopWindow.handleBackendReady.pipe(
490 Effect.catch((error) =>551 Effect.catch((error) =>
491 logBackendManagerError("failed to open main window after backend readiness", {552 logBackendManagerError("failed to open main window after backend readiness", {
492 message: error.message,553 cause: error,
493 }),554 }),
494 ),555 ),
495 );556 );
496 }),557 }),
497 onReadinessFailure: (error) =>558 onReadinessFailure: (error) =>
498 logBackendManagerWarning("backend readiness check failed during bootstrap", {559 logBackendManagerWarning("backend readiness check failed during bootstrap", {
499 error: error.message,560 error,
500 }),561 }),
501 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),562 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),
502 }).pipe(563 }).pipe(
...@@ -504,7 +565,10 @@ export const make = Effect.gen(function* () {...@@ -504,7 +565,10 @@ export const make = Effect.gen(function* () {
504 Effect.provideService(HttpClient.HttpClient, httpClient),565 Effect.provideService(HttpClient.HttpClient, httpClient),
505 Scope.provide(runScope),566 Scope.provide(runScope),
506 Effect.matchEffect({567 Effect.matchEffect({
507 onFailure: (error) => finalizeRun(error.message),568 onFailure: (error) =>
569 logBackendManagerError(error.message, { error }).pipe(
570 Effect.andThen(finalizeRun(error.message)),
571 ),
508 onSuccess: (exit) => finalizeRun(exit.reason),572 onSuccess: (exit) => finalizeRun(exit.reason),
509 }),573 }),
510 Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)),574 Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)),
...@@ -559,11 +623,17 @@ export const make = Effect.gen(function* () {...@@ -559,11 +623,17 @@ export const make = Effect.gen(function* () {
559 }),623 }),
560 ),624 ),
561 Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)),625 Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)),
562 Effect.catchCause((cause) =>626 Effect.catchCause((cause) => {
563 logBackendManagerError("desktop backend restart fiber failed", {627 if (Cause.hasInterruptsOnly(cause)) {
564 cause: Cause.pretty(cause),628 return Effect.void;
565 }),629 }
566 ),630 const error = new DesktopBackendRestartError({
631 reason,
632 delayMs: Duration.toMillis(delay),
633 cause,
634 });
635 return logBackendManagerError(error.message, { error });
636 }),
567 ),637 ),
568 parentScope,638 parentScope,
569 );639 );