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
This commit is contained in:
Muhammad Hamza authored and GitHub committed 2026-10-23 14:40:01 +08:00
1 parent 6d6cbdf8c4
commit 4cfc61147b
5 files changed
+134 -24

No files matched your search

+4
View File
@@ -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
+29 -14
View File
@@ -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),
),
}),
});
+2 -9
View File
@@ -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,
@@ -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,
);
@@ -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",
});
},
);