From 59c91c88fe88210b4b22b9f1365a16567513da4a Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:15:03 +0000 Subject: [PATCH 1/2] fix(process): refuse malformed timeouts for stoppable commands Since a stoppable command keeps its own deadline, execFile no longer validated its timeout. A negative, NaN, fractional or infinite value then started the command with no deadline (or one setTimeout coerced) instead of failing with ERR_OUT_OF_RANGE as execFile does. Apply execFile's unsigned-integer contract before spawning. Co-Authored-By: Claude Opus 5.5 --- src/process/ExecFileOutput.test.ts | 16 ++++++++++++++++ src/process/ExecFileOutput.ts | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 00b925007..6a5eb5b2e 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -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])( + "refuses a malformed timeout (%s) before starting the command, as execFile does", + 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, () => {});', diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 294014c19..94a73aa9f 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -53,6 +53,19 @@ const timeoutError = ( return error; }; +/** + * Node's execFile contract for `timeout`. A stoppable command keeps its own + * deadline, so it must refuse the values execFile would refuse. + */ +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 +106,14 @@ export const execFileOutput = ( ): Promise<{ readonly stdout: string; readonly stderr: string }> => new Promise((resolve, reject) => { const { stopSignal, signal, timeout, ...execOptions } = options; + const invalid = + stopSignal === undefined || timeout === undefined + ? undefined + : invalidTimeout(timeout); + if (invalid !== undefined) { + reject(invalid); + return; + } if (stopSignal !== undefined && signal?.aborted === true) { reject(abortError(signal)); return; From 3d328193d69a63a355644c51bfb70f524d99bca9 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:22:09 +0000 Subject: [PATCH 2/2] fix(process): apply one timeout rule to both execFileOutput modes Node's execFile timeout contract differs by supported runtime: Node 22 and 24 accept any non-negative integer (their timers clamp anything above 2^31-1 to 1 ms), while Node 26 caps it at Number.MAX_SAFE_INTEGER. Mirroring either left stoppable and plain commands disagreeing on one runtime. Validate a safe non-negative integer before spawning in both modes, so every supported runtime gives the same answer. Co-Authored-By: Claude Opus 5.5 --- src/process/ExecFileOutput.test.ts | 4 ++-- src/process/ExecFileOutput.ts | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 6a5eb5b2e..f4d690e33 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -123,8 +123,8 @@ describe.skipIf(process.platform === "win32")( expect(await readFile(stopped, "utf8")).toBe("SIGINT"); }); - it.each([-1, Number.NaN, 1.5, Number.POSITIVE_INFINITY])( - "refuses a malformed timeout (%s) before starting the command, as execFile does", + 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( diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 94a73aa9f..dd6aaae62 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -54,8 +54,10 @@ const timeoutError = ( }; /** - * Node's execFile contract for `timeout`. A stoppable command keeps its own - * deadline, so it must refuse the values execFile would refuse. + * 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; @@ -106,10 +108,7 @@ export const execFileOutput = ( ): Promise<{ readonly stdout: string; readonly stderr: string }> => new Promise((resolve, reject) => { const { stopSignal, signal, timeout, ...execOptions } = options; - const invalid = - stopSignal === undefined || timeout === undefined - ? undefined - : invalidTimeout(timeout); + const invalid = timeout === undefined ? undefined : invalidTimeout(timeout); if (invalid !== undefined) { reject(invalid); return;