diff --git a/package.json b/package.json index 41f93bb..b430192 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metabase/cli", - "version": "0.1.11", + "version": "0.1.12", "description": "Metabase CLI", "license": "AGPL-3.0", "repository": { diff --git a/src/runtime/input.test.ts b/src/runtime/input.test.ts index 1c72a24..b778211 100644 --- a/src/runtime/input.test.ts +++ b/src/runtime/input.test.ts @@ -10,9 +10,13 @@ import { readInput } from "./input"; interface MockStdin { isTTY: boolean; + pause: () => void; + unref: () => void; [Symbol.asyncIterator]: () => AsyncIterator; } +const noop = (): void => {}; + const originalStdin = process.stdin; function setStdin(replacement: MockStdin | NodeJS.ReadStream): void { @@ -26,6 +30,8 @@ function setStdin(replacement: MockStdin | NodeJS.ReadStream): void { function tty(): MockStdin { return { isTTY: true, + pause: noop, + unref: noop, [Symbol.asyncIterator]() { return Readable.from([])[Symbol.asyncIterator](); }, @@ -35,12 +41,29 @@ function tty(): MockStdin { function piped(content: string): MockStdin { return { isTTY: false, + pause: noop, + unref: noop, [Symbol.asyncIterator]() { return Readable.from([content])[Symbol.asyncIterator](); }, }; } +function idlePipe(): MockStdin { + return { + isTTY: false, + pause: noop, + unref: noop, + [Symbol.asyncIterator]() { + return { + next() { + return new Promise>(() => {}); + }, + }; + }, + }; +} + describe("readInput precedence", () => { let tempDir: string; let filePath: string; @@ -115,6 +138,22 @@ describe("readInput precedence", () => { expect(result).toBe("from-positional"); }); + it("times out an idle non-TTY stdin instead of hanging, then falls through to positional", async () => { + setStdin(idlePipe()); + const result = await readInput({ positional: "from-positional" }); + expect(result).toBe("from-positional"); + }); + + it("times out an idle non-TTY stdin and throws the required-input error", async () => { + setStdin(idlePipe()); + const error = await readInput({}).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ConfigError); + assert(error instanceof ConfigError, "expected ConfigError"); + expect(error.message).toBe( + "input required: provide one of --body, --file, stdin, or a positional argument", + ); + }); + it("throws ConfigError listing all sources when required and all empty", async () => { const error = await readInput({}).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(ConfigError); diff --git a/src/runtime/input.ts b/src/runtime/input.ts index db243d7..7252478 100644 --- a/src/runtime/input.ts +++ b/src/runtime/input.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; import { ConfigError, isNotFoundError } from "../core/errors"; @@ -25,7 +26,7 @@ export async function readInput(sources: InputSources): Promise { } if (!process.stdin.isTTY) { - const piped = await readStdin(); + const piped = await readPipedStdin(); if (piped) { return piped; } @@ -44,7 +45,7 @@ export async function readInput(sources: InputSources): Promise { async function readFileSource(path: string): Promise { if (path === "-") { - return await readStdin(); + return await drainStdin(); } try { return await readFile(path, "utf8"); @@ -56,10 +57,43 @@ async function readFileSource(path: string): Promise { } } -async function readStdin(): Promise { +async function drainStdin(): Promise { let data = ""; for await (const chunk of process.stdin) { data += chunk; } return data; } + +const STDIN_FIRST_CHUNK_TIMEOUT_MS = 500; +const STDIN_IDLE = Symbol("stdin-idle"); + +// A non-TTY stdin we were never asked to read may be an inherited pipe that holds +// the fd open without ever sending data or EOF — draining it blocks forever. So we +// race only the first chunk against a deadline: nothing in time means release stdin +// and report no input; once a chunk arrives we drain in full, so a large or slow +// body is never truncated. Explicit stdin (`--file -`) skips this and blocks — there +// the caller has promised data. +async function readPipedStdin(): Promise { + const iterator = process.stdin[Symbol.asyncIterator](); + const controller = new AbortController(); + const idle = delay(STDIN_FIRST_CHUNK_TIMEOUT_MS, STDIN_IDLE, { + signal: controller.signal, + }).catch(() => STDIN_IDLE); + + const first = await Promise.race([iterator.next(), idle]); + if (typeof first === "symbol") { + process.stdin.pause(); + process.stdin.unref(); + return null; + } + controller.abort(); + + let data = ""; + let chunk = first; + while (chunk.done !== true) { + data += chunk.value; + chunk = await iterator.next(); + } + return data; +}