fix(deadcode): clean Knip child trees on parent signal

This commit is contained in:
Vincent Koc 2026-06-20 15:07:19 +02:00
parent ad049ef083
commit aaf335af04
No known key found for this signature in database
2 changed files with 104 additions and 0 deletions

View file

@ -228,6 +228,28 @@ export async function runKnipUnusedFiles(params = {}) {
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
const parentSignalHandlers = [];
const cleanupParentSignalHandlers = () => {
for (const { signal, handler } of parentSignalHandlers) {
process.off(signal, handler);
}
parentSignalHandlers.length = 0;
};
const relayParentSignal = (signal) => {
const handler = () => {
signalProcessTree(child, signal);
signalProcessTree(child, "SIGKILL");
cleanupParentSignalHandlers();
process.kill(process.pid, signal);
};
parentSignalHandlers.push({ signal, handler });
process.once(signal, handler);
};
if (process.platform !== "win32") {
relayParentSignal("SIGINT");
relayParentSignal("SIGTERM");
relayParentSignal("SIGHUP");
}
const heartbeatTimer = setInterval(() => {
writeStatus(
@ -255,6 +277,7 @@ export async function runKnipUnusedFiles(params = {}) {
clearTimeout(timeoutTimer);
clearInterval(heartbeatTimer);
clearTimeout(killTimer);
cleanupParentSignalHandlers();
resolve({
...result,
output: output.join(""),

View file

@ -4,6 +4,7 @@ import { EventEmitter } from "node:events";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import {
checkUnusedFiles,
@ -65,6 +66,21 @@ async function waitForDead(pid: number, timeoutMs: number): Promise<void> {
throw new Error(`process still alive: ${pid}`);
}
function waitForChildClose(
child: ReturnType<typeof spawn>,
timeoutMs = 5_000,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("child did not close before timeout"));
}, timeoutMs);
child.once("close", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
describe("check-deadcode-unused-files", () => {
it("parses the compact Knip unused-file section", () => {
expect(
@ -366,6 +382,71 @@ src/a.ts: src/a.ts
},
);
it.skipIf(process.platform === "win32")(
"cleans active Knip descendants before forwarding parent SIGTERM",
async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-knip-parent-signal-"));
const childPidPath = path.join(root, "child.pid");
const readyPath = path.join(root, "child.ready");
const scriptUrl = pathToFileURL(path.resolve("scripts/check-deadcode-unused-files.mjs")).href;
let childPid = 0;
let runner: ReturnType<typeof spawn> | undefined;
try {
const childScript = [
"const fs = require('node:fs');",
"process.on('SIGTERM', () => {});",
`fs.writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
"setInterval(() => {}, 1000);",
].join("");
const parentScript = [
"const { spawn } = require('node:child_process');",
`spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
`require('node:fs').writeFileSync(${JSON.stringify(readyPath)}, 'ready');`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("");
const runnerScript = [
"import { spawn } from 'node:child_process';",
`import { runKnipUnusedFiles } from ${JSON.stringify(scriptUrl)};`,
"await runKnipUnusedFiles({",
" spawnCommand(_command, _args, options) {",
` return spawn(process.execPath, ['-e', ${JSON.stringify(parentScript)}], options);`,
" },",
" timeoutMs: 60_000,",
" writeStatus: () => {},",
"});",
].join("\n");
runner = spawn(process.execPath, ["--input-type=module", "-e", runnerScript], {
cwd: process.cwd(),
stdio: ["ignore", "ignore", "pipe"],
});
await waitForFile(readyPath, 2_000);
await waitForFile(childPidPath, 2_000);
childPid = Number.parseInt(readFileSync(childPidPath, "utf8"), 10);
expect(isProcessAlive(childPid)).toBe(true);
runner.kill("SIGTERM");
await expect(waitForChildClose(runner)).resolves.toEqual({
code: null,
signal: "SIGTERM",
});
await waitForDead(childPid, 2_000);
} finally {
if (runner?.pid && isProcessAlive(runner.pid)) {
runner.kill("SIGKILL");
}
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
rmSync(root, { recursive: true, force: true });
}
},
);
it("keeps output delivered after process exit but before stdio close", async () => {
const child = new FakeKnipProcess();
const resultPromise = runKnipUnusedFiles({