From 4cfc61147b3f4ec5f6d320e62167ea34fc9c1784 Mon Sep 17 00:00:00 2001 From: Muhammad Hamza <32600505+mhamzasakhi@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:40:01 +0500 Subject: [PATCH] fix(process): record no normal exit code for signal-terminated targets (#6486) capture-process recorded exit.code 0 for a target a scenario event killed with SIGKILL or SIGTERM, beside the nonzero exit.signal. The result builder and the retained observation each kept any nonnegative PTY exit code for an exited capture without consulting the signal; only finalization checked it. Project the exit once in captureExitObservation, whose normal exit code is null when a nonzero signal ended the target, and use the same rule for finalization. Normal exits keep their codes, including 0 and 3, and deadline exits remain null. Fixes #6445 --- docs/process-capture.md | 4 + .../capture/ProcessCaptureLifecycle.ts | 43 ++++++--- src/process/capture/ProcessHarness.ts | 11 +-- .../process/processSignalExit.test.ts | 94 +++++++++++++++++++ .../process/processCaptureLifecycle.test.ts | 6 +- 5 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 tests/acceptance/process/processSignalExit.test.ts diff --git a/docs/process-capture.md b/docs/process-capture.md index 7e7fd48ca5..92a8303584 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -335,6 +335,10 @@ validation is recorded as `unverified` with its reason. Cancellation before any deadline keeps the existing terminal kill. Descendants are not signalled during finalization; owned-process cleanup handles them afterwards. +For an `exited` capture, `exit.code` is the target's normal exit code. It is +`null` when the target ended on a signal, such as one sent by a scenario +`signal` event, and the observed signal stays in `exit.signal`. + `exit.reason` keeps the initiating deadline (`timeout` or `idle_timeout`). `exit.code` is `null` for every deadline exit; the code of an exit observed during finalization is `exit.finalization.exit_code`, and the observed signal diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index a2b8e64a86..22134e9601 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -124,6 +124,33 @@ export type TerminalExitResult = readonly finalization: ProcessCaptureFinalization; }; +/** + * The target's normal exit code. A signal-terminated process has none, so a + * nonzero signal yields null even when the PTY reports a numeric code. + */ +const normalExitCode = (exit: { + readonly exitCode: number; + readonly signal?: number; +}): number | null => + exit.exitCode >= 0 && (exit.signal ?? 0) === 0 ? exit.exitCode : null; + +/** Project an observed exit; only an `exited` reason carries a normal code. */ +export const captureExitObservation = < + Reason extends TerminalExitResult["reason"], +>(exit: { + readonly exitCode: number; + readonly signal?: number; + readonly reason: Reason; + readonly finalization?: ProcessCaptureFinalization; +}) => ({ + code: exit.reason === "exited" ? normalExitCode(exit) : null, + signal: exit.signal ?? null, + reason: exit.reason, + ...(exit.finalization === undefined + ? {} + : { finalization: exit.finalization }), +}); + interface CaptureResultOptions { readonly frames: readonly TerminalFrame[]; readonly exit: { @@ -330,17 +357,7 @@ export const buildCaptureResult = ( frames: options.frames, rendered_frames: options.renderedFrames, interaction_events: options.interactions, - exit: { - code: - options.exit.reason === "exited" && options.exit.exitCode >= 0 - ? options.exit.exitCode - : null, - signal: options.exit.signal ?? null, - reason: options.exit.reason, - ...(options.exit.finalization === undefined - ? {} - : { finalization: options.exit.finalization }), - }, + exit: captureExitObservation(options.exit), settlement: options.settlement, process_samples: normalizeProcessSamples( options.samples, @@ -985,9 +1002,7 @@ export const awaitTerminalExit = async ({ : { finalization: snapshot( finalizationElapsedMs, - exit.exitCode >= 0 && (exit.signal ?? 0) === 0 - ? exit.exitCode - : null, + normalExitCode(exit), ), }), }); diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index 1580a1cb77..67e47eaea7 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -53,6 +53,7 @@ import { type ProcessCaptureResourceCleanupOptions, ProcessCaptureResourceScope, type TerminalCaptureRetention, + captureExitObservation, } from "./ProcessCaptureLifecycle.js"; import { assertNotCancelled, @@ -801,15 +802,7 @@ const runProcessScenario = async ( }; observations.exit = { state: "available", - value: { - code: - exit.reason === "exited" && exit.exitCode >= 0 ? exit.exitCode : null, - signal: exit.signal ?? null, - reason: exit.reason, - ...(exit.finalization === undefined - ? {} - : { finalization: exit.finalization }), - }, + value: captureExitObservation(exit), }; capture = await completeCapture({ scenario, diff --git a/tests/acceptance/process/processSignalExit.test.ts b/tests/acceptance/process/processSignalExit.test.ts new file mode 100644 index 0000000000..8a4b24c2c8 --- /dev/null +++ b/tests/acceptance/process/processSignalExit.test.ts @@ -0,0 +1,94 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; +import { runCliJson } from "../../fixtures/cliJsonProcess.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { itWithCaptureCapability as captureTest } from "../../boundary/process/processCaptureCapability.js"; + +const hang = ["-e", "setTimeout(() => {}, 5000)"]; + +captureTest( + "records no normal exit code for a target a scenario signal terminated", + async () => { + const root = await createTestTempDirectory("rea-process-signal-exit-"); + for (const [name, additions, expected] of [ + [ + "SIGKILL", + { events: [{ type: "signal", at_ms: 200, signal: "SIGKILL" }] }, + { code: null, signal: 9, reason: "exited" }, + ], + [ + "SIGTERM", + { events: [{ type: "signal", at_ms: 200, signal: "SIGTERM" }] }, + { code: null, signal: 15, reason: "exited" }, + ], + [ + "exit 3", + { arguments: ["-e", "process.exit(3)"] }, + { code: 3, signal: 0, reason: "exited" }, + ], + [ + "exit 0", + { arguments: ["-e", "process.exit(0)"] }, + { code: 0, signal: 0, reason: "exited" }, + ], + ["deadline", { timeout_ms: 300 }, { code: null, reason: "timeout" }], + ] as const) { + const scenarioPath = join(root, `${name.replace(" ", "-")}.json`); + await writeFile( + scenarioPath, + JSON.stringify({ + executable: process.execPath, + arguments: hang, + timeout_ms: 5_000, + settle_ms: 300, + ...additions, + }), + ); + const document = await runCliJson([ + "capture-process", + scenarioPath, + "--format", + "json", + ]); + const capture = parseProcessCapture( + parseEvidence(document).normalized_result, + ); + expect(capture.exit, name).toMatchObject(expected); + } + }, + 60_000, +); + +captureTest( + "records no normal exit code for a signal-terminated target through MCP", + async () => { + const { call } = await connectLocalToolsMcp(); + const response = await call("capture_process_scenario", { + executable: process.execPath, + arguments: hang, + timeout_ms: 5_000, + settle_ms: 300, + events: [{ type: "signal", at_ms: 200, signal: "SIGKILL" }], + }); + expect(response.isError).not.toBe(true); + const capture = parseProcessCapture( + parseEvidence( + toolContract("capture_process_scenario").outputSchema.parse( + response.structuredContent, + ), + ).normalized_result, + ); + expect(capture.exit).toMatchObject({ + code: null, + signal: 9, + reason: "exited", + }); + }, + 30_000, +); diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index efad455e81..68694d876b 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -1179,7 +1179,11 @@ itWithCaptureCapability( expect(capture.interaction_events).toMatchObject([ { type: "signal", data: "SIGTERM", outcome: "dispatched" }, ]); - expect(capture.exit).toMatchObject({ signal: 15, reason: "exited" }); + expect(capture.exit).toMatchObject({ + code: null, + signal: 15, + reason: "exited", + }); }, );