diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 00b925007..f4d690e33 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, 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, () => {});', diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 294014c19..dd6aaae62 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -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;