From aaf335af04cd2f6909dd402928751d4276e3177f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 20 Jun 2026 15:07:19 +0200 Subject: [PATCH] fix(deadcode): clean Knip child trees on parent signal --- scripts/check-deadcode-unused-files.mjs | 23 ++++++ .../check-deadcode-unused-files.test.ts | 81 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/scripts/check-deadcode-unused-files.mjs b/scripts/check-deadcode-unused-files.mjs index 6dad061c481..b55755130a3 100644 --- a/scripts/check-deadcode-unused-files.mjs +++ b/scripts/check-deadcode-unused-files.mjs @@ -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(""), diff --git a/test/scripts/check-deadcode-unused-files.test.ts b/test/scripts/check-deadcode-unused-files.test.ts index 0c03fc4074a..2af2e5cfca4 100644 --- a/test/scripts/check-deadcode-unused-files.test.ts +++ b/test/scripts/check-deadcode-unused-files.test.ts @@ -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 { throw new Error(`process still alive: ${pid}`); } +function waitForChildClose( + child: ReturnType, + 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 | 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({