From b7c2646dcfd4e4b75d709be3b7d2a5ec70172130 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:18:50 +0000 Subject: [PATCH] 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 --- src/process/ExecFileOutput.test.ts | 35 ++++++++++++++++++++++++++++++ src/process/ExecFileOutput.ts | 9 +++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index a385d80719..d047c5a80d 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -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, () => {});', diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 2e80e337a0..935905ac76 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -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");