diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 4a88be8838a..0c083889f29 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -3,12 +3,15 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -28,6 +31,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), ); +const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", @@ -57,7 +61,7 @@ const configWithObservability: DesktopBackendBootstrapValue = { function makeProcess(options?: { readonly stdout?: Stream.Stream; readonly stderr?: Stream.Stream; - readonly exitCode?: Effect.Effect; + readonly exitCode?: Effect.Effect; readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; }): ChildProcessSpawner.ChildProcessHandle { return ChildProcessSpawner.makeHandle({ @@ -145,6 +149,23 @@ function makeManagerLayer(input: { } describe("DesktopBackendManager", () => { + it("preserves the complete restart cause and schedule context", () => { + const cause = Cause.combine( + Cause.fail(new Error("start failed")), + Cause.die(new Error("restart defect")), + ); + const error = new DesktopBackendManager.DesktopBackendRestartError({ + reason: "backend exited with code 1", + delayMs: 500, + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.reason, "backend exited with code 1"); + assert.equal(error.delayMs, 500); + assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay."); + }); + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => Effect.gen(function* () { let spawnedCommand: ChildProcess.Command | undefined; @@ -218,6 +239,156 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("preserves the readiness timeout cause and process context", () => + Effect.gen(function* () { + const requested = yield* Deferred.make(); + const layer = Layer.merge( + TestClock.layer(), + httpClientLayer((request) => + Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), + ), + ); + + yield* Effect.gen(function* () { + const readiness = yield* DesktopBackendManager.waitForHttpReady({ + executablePath: baseConfig.executablePath, + entryPath: baseConfig.entryPath, + cwd: baseConfig.cwd, + httpBaseUrl: baseConfig.httpBaseUrl, + timeout: Duration.millis(50), + }).pipe(Effect.flip, Effect.forkChild); + + const request = yield* Deferred.await(requested); + assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); + + yield* TestClock.adjust(Duration.millis(50)); + const error = yield* Fiber.join(readiness); + + assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); + assert.equal(error.timeoutMs, 50); + assert.isTrue(Cause.isTimeoutError(error.cause)); + assert.equal( + error.message, + "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", + ); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("reports bootstrap encoding failures with stable process context", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + const error = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + bootstrap: { + ...baseConfig.bootstrap, + port: 0, + }, + }).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessBootstrapEncodeError") { + return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.isDefined(error.cause); + assert.equal( + error.message, + "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", + ); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves spawn failures without deriving their message from the cause", () => + Effect.gen(function* () { + const spawnCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: baseConfig.executablePath, + description: "low-level detail that must not become the public message", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(spawnCause)), + ); + const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessSpawnError") { + return assert.fail(`Expected backend spawn error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, spawnCause); + assert.equal( + error.message, + "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", + ); + assert.notInclude(error.message, spawnCause.message); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves exit-status failures without copying their detail into the message", () => + Effect.gen(function* () { + const exitCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "exitCode", + description: "exit-status-secret-sentinel", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Effect.fail(exitCause), + }), + ), + ), + ); + const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessExitStatusError") { + return assert.fail(`Expected backend exit-status error, received ${error._tag}`); + } + assert.equal(error.pid, 123); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, exitCause); + assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); + assert.notInclude(error.message, "exit-status-secret-sentinel"); + assert.isTrue(isBackendProcessError(error)); + }), + ); + it.effect("retries HTTP readiness before reporting the backend ready", () => Effect.gen(function* () { const requestUrls: Array = []; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index bc47cab37d7..8a40c9d2153 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -9,7 +9,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -43,64 +42,107 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; -export interface DesktopBackendStartConfig { +export interface BackendProcessContext { readonly executablePath: string; readonly entryPath: string; readonly cwd: string; + readonly httpBaseUrl: URL; +} + +export interface DesktopBackendStartConfig extends BackendProcessContext { readonly env: Record; readonly bootstrap: DesktopBackendBootstrapValue; - readonly httpBaseUrl: URL; readonly captureOutput: boolean; } interface BackendProcessExit { readonly code: Option.Option; readonly reason: string; - readonly result: Result.Result; } -export class BackendTimeoutError extends Schema.TaggedErrorClass()( - "BackendTimeoutError", +const backendProcessContextSchema = { + executablePath: Schema.String, + entryPath: Schema.String, + cwd: Schema.String, + httpBaseUrl: Schema.URL, +}; + +export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass()( + "BackendReadinessTimeoutError", { - url: Schema.URL, + ...backendProcessContextSchema, + readinessUrl: Schema.URL, + timeoutMs: Schema.Number, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Timed out waiting for backend readiness at ${this.url.href}.`; + return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; } } -class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( +export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( "BackendProcessBootstrapEncodeError", { - detail: Schema.String, + ...backendProcessContextSchema, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to encode desktop backend bootstrap payload: ${this.detail}`; + return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; } } -class BackendProcessSpawnError extends Schema.TaggedErrorClass()( +export class BackendProcessSpawnError extends Schema.TaggedErrorClass()( "BackendProcessSpawnError", { - detail: Schema.String, + ...backendProcessContextSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`; + } +} + +export class BackendProcessExitStatusError extends Schema.TaggedErrorClass()( + "BackendProcessExitStatusError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the exit status of desktop backend process ${this.pid}.`; + } +} + +export class DesktopBackendRestartError extends Schema.TaggedErrorClass()( + "DesktopBackendRestartError", + { + reason: Schema.String, + delayMs: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to spawn desktop backend process: ${this.detail}`; + return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`; } } -type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; +export const BackendProcessError = Schema.Union([ + BackendProcessBootstrapEncodeError, + BackendProcessSpawnError, + BackendProcessExitStatusError, +]); +export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly readinessTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onReady?: () => Effect.Effect; - readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, @@ -183,11 +225,10 @@ const closeRun = ( ).pipe(Effect.ignore); }; -const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( - baseUrl: URL, - timeout: Duration.Duration, -): Effect.fn.Return { - const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); +export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( + options: BackendProcessContext & { readonly timeout: Duration.Duration }, +): Effect.fn.Return { + const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); const client = (yield* HttpClient.HttpClient).pipe( HttpClient.filterStatusOk, HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), @@ -196,29 +237,22 @@ const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(fu yield* client.get(readinessUrl).pipe( Effect.asVoid, - Effect.timeout(timeout), - Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), + Effect.timeout(options.timeout), + Effect.mapError( + (cause) => + new BackendReadinessTimeoutError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + readinessUrl, + timeoutMs: Duration.toMillis(options.timeout), + cause, + }), + ), ); }); -function describeProcessExit( - result: Result.Result, -): BackendProcessExit { - if (Result.isSuccess(result)) { - return { - code: Option.some(result.success), - reason: `code=${result.success}`, - result, - }; - } - - return { - code: Option.none(), - reason: result.failure.message, - result, - }; -} - function drainBackendOutput( streamName: BackendProcessOutputStream, stream: Stream.Stream, @@ -232,7 +266,7 @@ function drainBackendOutput( const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); -const runBackendProcess = Effect.fn("runBackendProcess")(function* ( +export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -240,7 +274,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.mapError( (cause) => new BackendProcessBootstrapEncodeError({ - detail: cause.message, + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, cause, }), ), @@ -273,7 +310,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.mapError( (cause) => new BackendProcessSpawnError({ - detail: cause.message, + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, cause, }), ), @@ -284,16 +324,37 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); } - yield* waitForHttpReady( - options.httpBaseUrl, - options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - ).pipe( + yield* waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, + }), Effect.forkScoped, ); - return describeProcessExit(yield* Effect.result(handle.exitCode)); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError( + (cause) => + new BackendProcessExitStatusError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + cause, + }), + ), + ); + return { + code: Option.some(exitCode), + reason: `code=${exitCode}`, + } satisfies BackendProcessExit; }); export const make = Effect.gen(function* () { @@ -351,7 +412,7 @@ export const make = Effect.gen(function* () { const config = yield* configuration.resolve.pipe( Effect.tapError((error) => logBackendManagerError("failed to generate desktop backend configuration", { - cause: error.message, + cause: error, }), ), Effect.option, @@ -489,14 +550,14 @@ export const make = Effect.gen(function* () { yield* desktopWindow.handleBackendReady.pipe( Effect.catch((error) => logBackendManagerError("failed to open main window after backend readiness", { - message: error.message, + cause: error, }), ), ); }), onReadinessFailure: (error) => logBackendManagerWarning("backend readiness check failed during bootstrap", { - error: error.message, + error, }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( @@ -504,7 +565,10 @@ export const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => finalizeRun(error.message), + onFailure: (error) => + logBackendManagerError(error.message, { error }).pipe( + Effect.andThen(finalizeRun(error.message)), + ), onSuccess: (exit) => finalizeRun(exit.reason), }), Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), @@ -559,11 +623,17 @@ export const make = Effect.gen(function* () { }), ), Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), - Effect.catchCause((cause) => - logBackendManagerError("desktop backend restart fiber failed", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopBackendRestartError({ + reason, + delayMs: Duration.toMillis(delay), + cause, + }); + return logBackendManagerError(error.message, { error }); + }), ), parentScope, );