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 {
33 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
44} from "@t3tools/contracts";
55import { assert, describe, it } from "@effect/vitest";
6import * as Cause from "effect/Cause";
67import * as Deferred from "effect/Deferred";
78import * as Duration from "effect/Duration";
89import * as Effect from "effect/Effect";
910import * as FileSystem from "effect/FileSystem";
11import * as Fiber from "effect/Fiber";
1012import * as Layer from "effect/Layer";
1113import * as Option from "effect/Option";
14import * as PlatformError from "effect/PlatformError";
1215import * as Queue from "effect/Queue";
1316import * as Ref from "effect/Ref";
1417import * as Schema from "effect/Schema";
......@@ -28,6 +31,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts";
2831const decodeDesktopBackendBootstrap = Schema.decodeEffect(
2932 Schema.fromJsonString(DesktopBackendBootstrap),
3033);
34const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError);
3135
3236const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
3337 executablePath: "/electron",
......@@ -57,7 +61,7 @@ const configWithObservability: DesktopBackendBootstrapValue = {
5761function makeProcess(options?: {
5862 readonly stdout?: Stream.Stream<Uint8Array>;
5963 readonly stderr?: Stream.Stream<Uint8Array>;
60 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
64 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
6165 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];
6266}): ChildProcessSpawner.ChildProcessHandle {
6367 return ChildProcessSpawner.makeHandle({
......@@ -145,6 +149,23 @@ function makeManagerLayer(input: {
145149}
146150
147151describe("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
148169 it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () =>
149170 Effect.gen(function* () {
150171 let spawnedCommand: ChildProcess.Command | undefined;
......@@ -218,6 +239,156 @@ describe("DesktopBackendManager", () => {
218239 }),
219240 );
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
221392 it.effect("retries HTTP readiness before reporting the backend ready", () =>
222393 Effect.gen(function* () {
223394 const requestUrls: Array<string> = [];
apps/desktop/src/backend/DesktopBackendManager.ts+129-59
......@@ -9,7 +9,6 @@ import * as Layer from "effect/Layer";
99import * as Option from "effect/Option";
1010import * as PlatformError from "effect/PlatformError";
1111import * as Ref from "effect/Ref";
12import * as Result from "effect/Result";
1312import * as Schedule from "effect/Schedule";
1413import * as Schema from "effect/Schema";
1514import * as Semaphore from "effect/Semaphore";
......@@ -43,64 +42,107 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;
4342
4443export type BackendProcessOutputStream = "stdout" | "stderr";
4544
46export interface DesktopBackendStartConfig {
45export interface BackendProcessContext {
4746 readonly executablePath: string;
4847 readonly entryPath: string;
4948 readonly cwd: string;
49 readonly httpBaseUrl: URL;
50}
51
52export interface DesktopBackendStartConfig extends BackendProcessContext {
5053 readonly env: Record<string, string | undefined>;
5154 readonly bootstrap: DesktopBackendBootstrapValue;
52 readonly httpBaseUrl: URL;
5355 readonly captureOutput: boolean;
5456}
5557
5658interface BackendProcessExit {
5759 readonly code: Option.Option<number>;
5860 readonly reason: string;
59 readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
6061}
6162
62export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()(
63 "BackendTimeoutError",
63const backendProcessContextSchema = {
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",
6472 {
65 url: Schema.URL,
73 ...backendProcessContextSchema,
74 readinessUrl: Schema.URL,
75 timeoutMs: Schema.Number,
76 cause: Schema.Defect(),
6677 },
6778) {
6879 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}.`;
7081 }
7182}
7283
73class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
84export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
7485 "BackendProcessBootstrapEncodeError",
7586 {
76 detail: Schema.String,
87 ...backendProcessContextSchema,
7788 cause: Schema.Defect(),
7889 },
7990) {
8091 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}.`;
8293 }
8394}
8495
85class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
96export class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
8697 "BackendProcessSpawnError",
8798 {
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,
89126 cause: Schema.Defect(),
90127 },
91128) {
92129 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.`;
94131 }
95132}
96133
97type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;
134export const BackendProcessError = Schema.Union([
135 BackendProcessBootstrapEncodeError,
136 BackendProcessSpawnError,
137 BackendProcessExitStatusError,
138]);
139export type BackendProcessError = typeof BackendProcessError.Type;
98140
99141interface RunBackendProcessOptions extends DesktopBackendStartConfig {
100142 readonly readinessTimeout?: Duration.Duration;
101143 readonly onStarted?: (pid: number) => Effect.Effect<void>;
102144 readonly onReady?: () => Effect.Effect<void>;
103 readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>;
145 readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>;
104146 readonly onOutput?: (
105147 streamName: BackendProcessOutputStream,
106148 chunk: Uint8Array,
......@@ -183,11 +225,10 @@ const closeRun = (
183225 ).pipe(Effect.ignore);
184226};
185227
186const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* (
187 baseUrl: URL,
188 timeout: Duration.Duration,
189): Effect.fn.Return<void, BackendTimeoutError, HttpClient.HttpClient> {
190 const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl);
228export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* (
229 options: BackendProcessContext & { readonly timeout: Duration.Duration },
230): Effect.fn.Return<void, BackendReadinessTimeoutError, HttpClient.HttpClient> {
231 const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl);
191232 const client = (yield* HttpClient.HttpClient).pipe(
192233 HttpClient.filterStatusOk,
193234 HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)),
......@@ -196,29 +237,22 @@ const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(fu
196237
197238 yield* client.get(readinessUrl).pipe(
198239 Effect.asVoid,
199 Effect.timeout(timeout),
200 Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })),
240 Effect.timeout(options.timeout),
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 ),
201253 );
202254});
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
222256function drainBackendOutput(
223257 streamName: BackendProcessOutputStream,
224258 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
......@@ -232,7 +266,7 @@ function drainBackendOutput(
232266
233267const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
234268
235const runBackendProcess = Effect.fn("runBackendProcess")(function* (
269export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
236270 options: RunBackendProcessOptions,
237271): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
238272 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
......@@ -240,7 +274,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
240274 Effect.mapError(
241275 (cause) =>
242276 new BackendProcessBootstrapEncodeError({
243 detail: cause.message,
277 executablePath: options.executablePath,
278 entryPath: options.entryPath,
279 cwd: options.cwd,
280 httpBaseUrl: options.httpBaseUrl,
244281 cause,
245282 }),
246283 ),
......@@ -273,7 +310,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
273310 Effect.mapError(
274311 (cause) =>
275312 new BackendProcessSpawnError({
276 detail: cause.message,
313 executablePath: options.executablePath,
314 entryPath: options.entryPath,
315 cwd: options.cwd,
316 httpBaseUrl: options.httpBaseUrl,
277317 cause,
278318 }),
279319 ),
......@@ -284,16 +324,37 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
284324 yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);
285325 yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);
286326 }
287 yield* waitForHttpReady(
288 options.httpBaseUrl,
289 options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
290 ).pipe(
327 yield* waitForHttpReady({
328 executablePath: options.executablePath,
329 entryPath: options.entryPath,
330 cwd: options.cwd,
331 httpBaseUrl: options.httpBaseUrl,
332 timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
333 }).pipe(
291334 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 }),
293338 Effect.forkScoped,
294339 );
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;
297358});
298359
299360export const make = Effect.gen(function* () {
......@@ -351,7 +412,7 @@ export const make = Effect.gen(function* () {
351412 const config = yield* configuration.resolve.pipe(
352413 Effect.tapError((error) =>
353414 logBackendManagerError("failed to generate desktop backend configuration", {
354 cause: error.message,
415 cause: error,
355416 }),
356417 ),
357418 Effect.option,
......@@ -489,14 +550,14 @@ export const make = Effect.gen(function* () {
489550 yield* desktopWindow.handleBackendReady.pipe(
490551 Effect.catch((error) =>
491552 logBackendManagerError("failed to open main window after backend readiness", {
492 message: error.message,
553 cause: error,
493554 }),
494555 ),
495556 );
496557 }),
497558 onReadinessFailure: (error) =>
498559 logBackendManagerWarning("backend readiness check failed during bootstrap", {
499 error: error.message,
560 error,
500561 }),
501562 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),
502563 }).pipe(
......@@ -504,7 +565,10 @@ export const make = Effect.gen(function* () {
504565 Effect.provideService(HttpClient.HttpClient, httpClient),
505566 Scope.provide(runScope),
506567 Effect.matchEffect({
507 onFailure: (error) => finalizeRun(error.message),
568 onFailure: (error) =>
569 logBackendManagerError(error.message, { error }).pipe(
570 Effect.andThen(finalizeRun(error.message)),
571 ),
508572 onSuccess: (exit) => finalizeRun(exit.reason),
509573 }),
510574 Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)),
......@@ -559,11 +623,17 @@ export const make = Effect.gen(function* () {
559623 }),
560624 ),
561625 Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)),
562 Effect.catchCause((cause) =>
563 logBackendManagerError("desktop backend restart fiber failed", {
564 cause: Cause.pretty(cause),
565 }),
566 ),
626 Effect.catchCause((cause) => {
627 if (Cause.hasInterruptsOnly(cause)) {
628 return Effect.void;
629 }
630 const error = new DesktopBackendRestartError({
631 reason,
632 delayMs: Duration.toMillis(delay),
633 cause,
634 });
635 return logBackendManagerError(error.message, { error });
636 }),
567637 ),
568638 parentScope,
569639 );