Merge pull request #1225 from morluto/fix/exec-stop-timeout-validation

fix(process): refuse malformed timeouts for stoppable commands
This commit is contained in:
Kaoru0822 2026-10-09 01:33:25 -07:00 committed by GitHub
commit 136d4aab18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 36 additions and 0 deletions

View File

@ -123,6 +123,22 @@ describe.skipIf(process.platform === "win32")(
expect(await readFile(stopped, "utf8")).toBe("SIGINT");
});
it.each([-1, Number.NaN, 1.5, Number.POSITIVE_INFINITY, 2 ** 53])(
"refuses a malformed timeout (%s) in both modes before starting the command",
async (timeout) => {
// Starting this command would fail with ENOENT instead.
await expect(
execFileOutput("/nonexistent/rea-command", [], {
timeout,
stopSignal: "SIGINT",
}),
).rejects.toMatchObject({ code: "ERR_OUT_OF_RANGE" });
await expect(
execFileOutput("/nonexistent/rea-command", [], { timeout }),
).rejects.toMatchObject({ code: "ERR_OUT_OF_RANGE" });
},
);
it("kills a timed-out process that ignores the stop signal", async () => {
const script = [
'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});',

View File

@ -53,6 +53,21 @@ const timeoutError = (
return error;
};
/**
* The `timeout` contract for both modes, checked before spawning. A stoppable
* command keeps its own deadline, so execFile never validates it. Node 26's
* execFile also caps it at Number.MAX_SAFE_INTEGER; Node 22 and 24 accept
* larger integers, which their timers then clamp to 1 ms.
*/
const invalidTimeout = (timeout: number): RangeError | undefined => {
if (Number.isSafeInteger(timeout) && timeout >= 0) return undefined;
const error = new RangeError(
`The value of "timeout" is out of range. It must be an unsigned integer. Received ${String(timeout)}`,
);
Reflect.set(error, "code", "ERR_OUT_OF_RANGE");
return error;
};
/** Captured subprocess output read from an execFileOutput rejection. */
export interface ExecFileOutputFailure {
readonly stdout: string;
@ -93,6 +108,11 @@ export const execFileOutput = (
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
new Promise((resolve, reject) => {
const { stopSignal, signal, timeout, ...execOptions } = options;
const invalid = timeout === undefined ? undefined : invalidTimeout(timeout);
if (invalid !== undefined) {
reject(invalid);
return;
}
if (stopSignal !== undefined && signal?.aborted === true) {
reject(abortError(signal));
return;