Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 b7c2646dcf fix(process): bound output draining after a stoppable command's grace
execFileOutput settles only when every output pipe closes. When a stoppable
command printed, exited, and left a descendant holding its stdout, the
deadline and SIGKILL grace had no process left to stop, so callers such as
the Kuna doctor version probe waited for the descendant instead of settling
within the deadline plus grace. Destroy the command's output streams when
the grace expires so the timeout is reported on time.

Fixes #5557

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:18:50 +00:00
2 changed files with 43 additions and 1 deletions

No files matched your search

+35
View File
@@ -272,6 +272,41 @@ describe.skipIf(process.platform === "win32")(
}
});
it("settles after the grace when an exited command's descendant holds its output open", async () => {
const directory = await createTestTempDirectory("rea-exec-descendant-");
const pidFile = join(directory, "descendant.pid");
// The command prints, then exits at once, leaving a descendant that
// inherited stdout and stderr and keeps both pipes open for 60 s.
const script = [
'const { spawn } = require("node:child_process");',
'const fs = require("node:fs");',
'const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: ["ignore", "inherit", "inherit"] });',
`fs.writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));`,
"child.unref();",
'process.stdout.write("kuna 1.833\\n");',
].join("\n");
const started = Date.now();
try {
await expect(
execFileOutput(process.execPath, ["-e", script], {
timeout: 500,
stopSignal: "SIGTERM",
}),
).rejects.toMatchObject({ code: "ETIMEDOUT", killed: true });
// Deadline plus the 5 s stop grace, with scheduling slack.
expect(Date.now() - started).toBeLessThan(15_000);
} finally {
const pid = Number(await readFile(pidFile, "utf8").catch(() => ""));
if (Number.isSafeInteger(pid) && pid > 0)
try {
process.kill(pid, "SIGKILL");
} catch {
// The descendant may already have exited.
}
await rm(directory, { recursive: true, force: true });
}
}, 30_000);
it("kills a timed-out process that ignores the stop signal", async () => {
const script = [
'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});',
+8 -1
View File
@@ -186,7 +186,14 @@ export const execFileBytes = (
if (stopped !== undefined) return;
stopped = reason;
child.kill(stopSignal);
grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
grace = setTimeout(() => {
child.kill("SIGKILL");
// execFile settles only once every output pipe closes. A descendant
// that inherited them can hold them open after the command exits, so
// stop reading at the end of the grace instead of waiting for it.
child.stdout?.destroy();
child.stderr?.destroy();
}, STOP_GRACE_MS);
};
const onAbort = (): void => {
stop("abort");