Skip to content

Commit f2a46b0

Browse files
committed
fix(tooling): bound deadcode knip subprocesses
1 parent 0fa384c commit f2a46b0

2 files changed

Lines changed: 312 additions & 51 deletions

File tree

scripts/check-deadcode-unused-files.mjs

Lines changed: 161 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env node
2-
import { spawnSync } from "node:child_process";
2+
import { spawn } from "node:child_process";
33
import { fileURLToPath } from "node:url";
44
import {
55
KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST,
@@ -8,6 +8,8 @@ import {
88

99
const KNIP_VERSION = "6.8.0";
1010
export const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
11+
export const KNIP_KILL_GRACE_MS = 5_000;
12+
export const KNIP_HEARTBEAT_MS = 60_000;
1113
export const KNIP_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
1214
const KNIP_ARGS = [
1315
"--config",
@@ -114,35 +116,163 @@ function spawnErrorCode(error) {
114116
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
115117
}
116118

117-
export function runKnipUnusedFiles(params = {}) {
118-
const run = params.spawnSyncCommand ?? spawnSync;
119-
const result = run(
120-
"pnpm",
121-
[
122-
"--config.minimum-release-age=0",
123-
"dlx",
124-
"--package",
125-
`knip@${KNIP_VERSION}`,
126-
"knip",
127-
...KNIP_ARGS,
128-
],
129-
{
130-
encoding: "utf8",
131-
killSignal: "SIGTERM",
132-
maxBuffer: params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES,
133-
stdio: ["ignore", "pipe", "pipe"],
134-
timeout: params.timeoutMs ?? KNIP_TIMEOUT_MS,
135-
},
136-
);
137-
return {
138-
status: result.status,
139-
signal: result.signal,
140-
errorCode: spawnErrorCode(result.error),
141-
errorMessage: result.error?.message,
142-
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
143-
};
119+
function signalProcessTree(child, signal) {
120+
if (!child.pid) {
121+
return;
122+
}
123+
try {
124+
if (process.platform === "win32") {
125+
process.kill(child.pid, signal);
126+
} else {
127+
process.kill(-child.pid, signal);
128+
}
129+
} catch {
130+
// The child may have exited between the timeout and signal delivery.
131+
}
144132
}
145133

134+
export async function runKnipUnusedFiles(params = {}) {
135+
const run = params.spawnCommand ?? spawn;
136+
const timeoutMs = params.timeoutMs ?? KNIP_TIMEOUT_MS;
137+
const heartbeatMs = params.heartbeatMs ?? KNIP_HEARTBEAT_MS;
138+
const maxBufferBytes = params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES;
139+
const killGraceMs = params.killGraceMs ?? KNIP_KILL_GRACE_MS;
140+
const writeStatus = params.writeStatus ?? ((message) => process.stderr.write(`${message}\n`));
141+
const args = [
142+
"--config.minimum-release-age=0",
143+
"dlx",
144+
"--package",
145+
`knip@${KNIP_VERSION}`,
146+
"knip",
147+
...KNIP_ARGS,
148+
];
149+
150+
return await new Promise((resolve) => {
151+
const startedAt = Date.now();
152+
let settled = false;
153+
let timedOut = false;
154+
let bufferExceeded = false;
155+
let outputBytes = 0;
156+
const output = [];
157+
let killTimer;
158+
let exitStatus = null;
159+
let exitSignal = null;
160+
161+
const child = run("pnpm", args, {
162+
detached: process.platform !== "win32",
163+
stdio: ["ignore", "pipe", "pipe"],
164+
});
165+
166+
const heartbeatTimer = setInterval(() => {
167+
writeStatus(
168+
`[deadcode] Knip unused-file scan still running after ${Math.round(
169+
(Date.now() - startedAt) / 1000,
170+
)}s.`,
171+
);
172+
}, heartbeatMs);
173+
174+
const timeoutTimer = setTimeout(() => {
175+
timedOut = true;
176+
clearInterval(heartbeatTimer);
177+
writeStatus(
178+
`[deadcode] Knip unused-file scan timed out after ${Math.round(timeoutMs / 1000)}s; terminating.`,
179+
);
180+
signalProcessTree(child, "SIGTERM");
181+
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
182+
}, timeoutMs);
183+
184+
const finish = (result) => {
185+
if (settled) {
186+
return;
187+
}
188+
settled = true;
189+
clearTimeout(timeoutTimer);
190+
clearInterval(heartbeatTimer);
191+
clearTimeout(killTimer);
192+
resolve({
193+
...result,
194+
output: output.join(""),
195+
});
196+
};
197+
198+
const appendOutput = (chunk) => {
199+
if (settled) {
200+
return;
201+
}
202+
if (bufferExceeded) {
203+
return;
204+
}
205+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
206+
const remainingBytes = maxBufferBytes - outputBytes;
207+
if (buffer.length <= remainingBytes) {
208+
output.push(buffer.toString("utf8"));
209+
outputBytes += buffer.length;
210+
return;
211+
}
212+
if (remainingBytes > 0) {
213+
output.push(buffer.subarray(0, remainingBytes).toString("utf8"));
214+
outputBytes = maxBufferBytes;
215+
}
216+
if (!bufferExceeded) {
217+
bufferExceeded = true;
218+
writeStatus(
219+
`[deadcode] Knip unused-file scan exceeded ${maxBufferBytes} output bytes; terminating.`,
220+
);
221+
child.stdout?.off?.("data", appendOutput);
222+
child.stderr?.off?.("data", appendOutput);
223+
child.stdout?.destroy?.();
224+
child.stderr?.destroy?.();
225+
clearInterval(heartbeatTimer);
226+
signalProcessTree(child, "SIGTERM");
227+
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
228+
}
229+
};
230+
231+
child.stdout?.on("data", appendOutput);
232+
child.stderr?.on("data", appendOutput);
233+
child.on("error", (error) =>
234+
finish({
235+
errorCode: spawnErrorCode(error),
236+
errorMessage: error.message,
237+
signal: null,
238+
status: null,
239+
}),
240+
);
241+
child.on("exit", (status, signal) => {
242+
exitStatus = status;
243+
exitSignal = signal;
244+
});
245+
child.on("close", (status, signal) => {
246+
exitStatus = exitStatus ?? status;
247+
exitSignal = exitSignal ?? signal;
248+
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
249+
if (timedOut) {
250+
finish({
251+
errorCode: "ETIMEDOUT",
252+
errorMessage: `Knip unused-file scan timed out after ${elapsedSeconds}s`,
253+
signal: exitSignal,
254+
status: exitStatus,
255+
});
256+
return;
257+
}
258+
if (bufferExceeded) {
259+
finish({
260+
errorCode: "ENOBUFS",
261+
errorMessage: `Knip unused-file scan exceeded ${maxBufferBytes} output bytes`,
262+
signal: exitSignal,
263+
status: exitStatus,
264+
});
265+
return;
266+
}
267+
finish({
268+
errorCode: undefined,
269+
errorMessage: undefined,
270+
signal: exitSignal,
271+
status: exitStatus,
272+
});
273+
});
274+
});
275+
}
146276
export function checkUnusedFiles(
147277
output,
148278
allowlistFiles = KNIP_UNUSED_FILE_ALLOWLIST,
@@ -161,8 +291,8 @@ export function checkUnusedFiles(
161291
};
162292
}
163293

164-
function main() {
165-
const result = runKnipUnusedFiles();
294+
async function main() {
295+
const result = await runKnipUnusedFiles();
166296
if (result.errorCode || result.status === null) {
167297
console.error(
168298
`deadcode unused-file scan failed: ${result.errorCode ?? result.signal ?? "unknown"}${
@@ -190,5 +320,5 @@ function main() {
190320
}
191321

192322
if (process.argv[1] === fileURLToPath(import.meta.url)) {
193-
main();
323+
await main();
194324
}

0 commit comments

Comments
 (0)