From 70f39a31dd5cbb66c02d5865a6250dc40da05560 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 20:49:01 -0400 Subject: [PATCH 1/2] fix(desktop): a backend killed by a signal is logged as killed, not as a read error Effect's Node spawner fails `exitCode` when the child exits with a null code and a signal, and runBackendProcess mapped every such failure to BackendProcessExitStatusError. A kill -9 was persisted to server-child.log and the restart event as "Failed to read the exit status", with the signal left only in the rotating trace. Recognize the signal failure and report it as an exit ("killed by SIGKILL"); genuine read failures keep the existing error. Fixes #15359 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/backend/DesktopBackendManager.test.ts | 27 ++++++++++ .../src/backend/DesktopBackendManager.ts | 49 +++++++++++++------ 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 901d9f4a27086..d28ffd7d75f9d 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -431,6 +431,33 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("reports a backend killed by a signal as a signal exit", () => + Effect.gen(function* () { + // The shape Effect's Node spawner fails `exitCode` with when the child + // exits with a null code and a signal. + const exitCause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + cause: new Error("Process interrupted due to receipt of signal: 'SIGKILL'"), + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed(makeProcess({ exitCode: Effect.fail(exitCause) })), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + + assert.isTrue(Option.isNone(exit.code)); + assert.equal(exit.reason, "killed by SIGKILL"); + }), + ); + it.effect("reports output stream failures with process and stream context", () => Effect.gen(function* () { const outputCause = PlatformError.systemError({ diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 6f1ea139bdca4..9dca67440f26f 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -213,6 +213,16 @@ export class BackendProcessExitStatusError extends Schema.TaggedError !ready }), Effect.forkScoped); const exit = 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, - }), - ), + Effect.map((exitCode): BackendProcessExit => ({ + code: Option.some(exitCode), + reason: `code=${exitCode}`, + })), + Effect.catch((cause) => { + const signal = signalFromExitCodeFailure(cause); + return signal === undefined + ? Effect.fail( + new BackendProcessExitStatusError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + cause, + }), + ) + : Effect.succeed({ + code: Option.none(), + reason: `killed by ${signal}`, + }); + }), Effect.exit, ); yield* options.onExitObserved?.() ?? Effect.void; @@ -620,11 +641,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( if (Exit.isFailure(exit)) { return yield* Effect.failCause(exit.cause); } - const exitCode = exit.value; - return { - code: Option.some(exitCode), - reason: `code=${exitCode}`, - } satisfies BackendProcessExit; + return exit.value; }); // Factory for one pooled backend instance. The returned instance owns From c96d09285c722300c0c1ccdd89ef29d1f4353e59 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:57:22 +0000 Subject: [PATCH 2/2] fix(desktop): preserve signals in backend exit logs --- .../src/backend/DesktopBackendManager.test.ts | 11 ++--- .../src/backend/DesktopBackendManager.ts | 48 ++++++++----------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index ebd0a52cc5ead..eaf7fc3084567 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -431,17 +431,15 @@ describe("DesktopBackendManager", () => { }), ); - it.effect("reports a backend killed by a signal as a signal exit", () => + it.effect("reports a SIGKILL exit without an exit code", () => Effect.gen(function* () { - // The shape Effect's Node spawner fails `exitCode` with when the child - // exits with a null code and a signal. const exitCause = PlatformError.systemError({ _tag: "Unknown", module: "ChildProcess", method: "exitCode", cause: new Error("Process interrupted due to receipt of signal: 'SIGKILL'"), }); - const spawnerLayer = Layer.succeed( + const layerSpawner = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.succeed(makeProcess({ exitCode: Effect.fail(exitCause) })), @@ -451,10 +449,9 @@ describe("DesktopBackendManager", () => { const exit = yield* DesktopBackendManager.runBackendProcess({ ...baseConfig, desktopTelemetryStream: Stream.empty, - }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient))); - assert.isTrue(Option.isNone(exit.code)); - assert.equal(exit.reason, "killed by SIGKILL"); + assert.deepEqual(exit, { code: Option.none(), reason: "killed by SIGKILL" }); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 354b3f79cb2b1..5ad864f945d78 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -178,16 +178,6 @@ export class BackendProcessExitStatusError extends Schema.TaggedError !ready }), Effect.forkScoped); const exit = yield* handle.exitCode.pipe( - Effect.map((exitCode): BackendProcessExit => ({ + Effect.map((exitCode) => ({ code: Option.some(exitCode), reason: `code=${exitCode}`, })), Effect.catch((cause) => { - const signal = signalFromExitCodeFailure(cause); - return signal === undefined - ? Effect.fail( - new BackendProcessExitStatusError({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - pid: Number(handle.pid), - cause, - }), - ) - : Effect.succeed({ - code: Option.none(), - reason: `killed by ${signal}`, - }); + // Effect's Node spawner exposes signal exits only through this cause message. + const signal = + cause.cause instanceof Error + ? /^Process interrupted due to receipt of signal: '(SIG[A-Z0-9]+)'$/.exec( + cause.cause.message, + )?.[1] + : undefined; + if (signal !== undefined) { + return Effect.succeed({ code: Option.none(), reason: `killed by ${signal}` }); + } + return Effect.fail( + new BackendProcessExitStatusError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + cause, + }), + ); }), Effect.exit, );