Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@metabase/cli",
"version": "0.1.11",
"version": "0.1.12",
"description": "Metabase CLI",
"license": "AGPL-3.0",
"repository": {
Expand Down
39 changes: 39 additions & 0 deletions src/runtime/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import { readInput } from "./input";

interface MockStdin {
isTTY: boolean;
pause: () => void;
unref: () => void;
[Symbol.asyncIterator]: () => AsyncIterator<string>;
}

const noop = (): void => {};

const originalStdin = process.stdin;

function setStdin(replacement: MockStdin | NodeJS.ReadStream): void {
Expand All @@ -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]();
},
Expand All @@ -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<IteratorResult<string>>(() => {});
},
};
},
};
}

describe("readInput precedence", () => {
let tempDir: string;
let filePath: string;
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 37 additions & 3 deletions src/runtime/input.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";

import { ConfigError, isNotFoundError } from "../core/errors";

Expand All @@ -25,7 +26,7 @@ export async function readInput(sources: InputSources): Promise<string> {
}

if (!process.stdin.isTTY) {
const piped = await readStdin();
const piped = await readPipedStdin();
if (piped) {
return piped;
}
Expand All @@ -44,7 +45,7 @@ export async function readInput(sources: InputSources): Promise<string> {

async function readFileSource(path: string): Promise<string> {
if (path === "-") {
return await readStdin();
return await drainStdin();
}
try {
return await readFile(path, "utf8");
Expand All @@ -56,10 +57,43 @@ async function readFileSource(path: string): Promise<string> {
}
}

async function readStdin(): Promise<string> {
async function drainStdin(): Promise<string> {
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<string | null> {
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;
}
Loading