* 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>
79 lines
2.6 KiB
TypeScript
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" });
|
|
},
|
|
);
|