mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user