Files
rea/tests/boundary/process/directAnalysisSignals.test.ts
Sravan AvvaruandN0zoM1z0 911152a04d fix(cli): complete cleanup after relayed interrupts (#1202)
* fix: Handle relayed SIGINT gracefully in withProcessCancellation

Changes `process.once` to `process.on` for SIGINT in `DirectAnalysis.ts`.
When REA is launched via npx, a single process-group SIGINT triggers two
SIGINT events (one direct, one relayed). Previously, the first event would
consume the handler and the second would abruptly terminate the Node process,
abandoning Ghidra resource cleanup.

This change ensures subsequent SIGINTs idempotently request cancellation
without crashing the process, matching the behaviour of `withCommandCancellation`.

* fix(cli): keep termination guards through provider cleanup

* test(cli): exercise repeated analysis signals on macOS

---------

Co-authored-by: N0zoM1z0 <1705004398@qq.com>
2026-10-09 12:45:23 +08:00

79 lines
2.6 KiB
TypeScript

import { spawn } from "node:child_process";
import { access, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { expect, it, onTestFinished } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform === "win32").each([
["direct", "SIGINT"],
["native", "SIGINT"],
["managed", "SIGINT"],
["direct", "SIGTERM"],
["native", "SIGTERM"],
["managed", "SIGTERM"],
] as const)(
"finishes %s cleanup when %s is delivered again",
async (mode, signal) => {
const root = await createTestTempDirectory("rea-direct-analysis-signals-");
const child = spawn(
process.execPath,
["tests/fixtures/directAnalysisInterrupt.mjs", root, mode],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let stdout = "",
stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const closed = new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, exitSignal) =>
resolve({ code, signal: exitSignal }),
);
});
onTestFinished(async () => {
await writeFile(join(root, "release-cleanup"), "release");
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
await closed;
});
const waitFor = async (name: string) => {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if (child.exitCode !== null || child.signalCode !== null)
throw new Error(`Child exited before ${name}: ${stderr}`);
try {
await access(join(root, name));
return;
} catch (cause: unknown) {
if (
!(cause instanceof Error) ||
!("code" in cause) ||
cause.code !== "ENOENT"
)
throw cause;
}
await delay(20);
}
throw new Error(`Child did not reach ${name}: ${stderr}`);
};
await waitFor("executing");
child.kill(signal);
await waitFor("cleanup-started");
child.kill(signal);
await writeFile(join(root, "release-cleanup"), "release");
expect(await closed, stderr).toEqual({ code: 0, signal: null });
expect(await readFile(join(root, "cleanup-complete"), "utf8")).toBe(
"complete",
);
expect(JSON.parse(stdout)).toMatchObject({ code: "cancelled" });
},
);