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");