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", + }); }, );