Repository navigation
fix(cli): read stdin lazily so open pipes don't hang the CLI #1330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
2b8ebae
c76a616
3bd52f4
fdf077c
f46e491
e017c4a
882d99a
8e9494c
b89e421
6218b29
23df965
22ebb9a
feadd51
98d35ba
8186094
be243a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,37 +2,102 @@ import { once } from 'node:events'; | |
|
|
||
| import { useStdin } from '../hooks/useStdin.js'; | ||
|
|
||
| export async function readStdin() { | ||
| const dataRef = await useStdin(); | ||
| /** Long enough for a writer that has to fetch its next bytes, short enough not to look like a hang. */ | ||
| const IMPLICIT_STDIN_IDLE_TIMEOUT_MILLIS = 2_000; | ||
|
|
||
| let readPromise: Promise<Buffer | undefined> | undefined; | ||
| let readResult: Buffer | undefined; | ||
| let readFinished = false; | ||
| let readCutShort = false; | ||
|
|
||
| interface ReadStdinOptions { | ||
| /** | ||
| * Stop at the first quiet gap instead of waiting for the writer to close stdin. Set it where | ||
| * stdin is a fallback the user never asked for, so the command cannot hang on a pipe it merely | ||
| * inherited (#1206). Leave it off for an explicit `-`, which waits for as long as the writer | ||
| * wants, the way `cat` does. | ||
| */ | ||
| implicit?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Reads stdin, at most once per process. Call it only when the command actually wants stdin data. | ||
| * The first call decides the options; later ones reuse its result. | ||
| */ | ||
| export async function readStdin(options: ReadStdinOptions = {}) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: nothing unit-tests this module; the e2e cases need a built |
||
| readPromise ??= _readStdin(options).then((data) => { | ||
| readResult = data; | ||
| readFinished = true; | ||
| return data; | ||
| }); | ||
|
|
||
| return readPromise; | ||
| } | ||
|
|
||
| const { hasData, waitDelay, stream } = dataRef; | ||
| /** | ||
| * Whether an implicit read gave up before the writer closed stdin. Explains both a payload that | ||
| * ends where nothing should end and a read that came back with nothing. | ||
| */ | ||
| export function stdinWasCutShort() { | ||
| return readCutShort; | ||
| } | ||
|
|
||
| /** | ||
| * What a finished read found, for the bug report footer. Says `Not read` while no command has asked | ||
| * for stdin, which is most of them. | ||
| */ | ||
| export function describeStdinRead() { | ||
| if (!readFinished) { | ||
| return 'Not read'; | ||
| } | ||
|
|
||
| return readResult ? 'Yes' : 'No'; | ||
| } | ||
|
|
||
| async function _readStdin({ implicit }: ReadStdinOptions) { | ||
| const { hasData, waitDelay, stream } = await useStdin(); | ||
|
|
||
| if (!hasData) { | ||
| return; | ||
| } | ||
|
|
||
| // An inherited pipe may send nothing and stay open, so waiting for the end never finishes | ||
| // (#1206). Keep a shorter platform deadline where there is one: it speeds up every spawned CLI. | ||
| const firstByteTimeout = implicit ? waitDelay || IMPLICIT_STDIN_IDLE_TIMEOUT_MILLIS : waitDelay; | ||
|
|
||
| const bufferChunks: Buffer[] = []; | ||
|
|
||
| const controller = new AbortController(); | ||
|
|
||
| let timeout: NodeJS.Timeout | null = null; | ||
|
|
||
| if (waitDelay) { | ||
| timeout = setTimeout(() => { | ||
| controller.abort(); | ||
| }, waitDelay).unref(); | ||
| } | ||
|
|
||
| const onData = (chunk: Buffer) => { | ||
| bufferChunks.push(chunk); | ||
| const armTimeout = (delay: number) => { | ||
| if (delay) { | ||
| timeout = setTimeout(() => controller.abort(), delay).unref(); | ||
| } | ||
| }; | ||
|
|
||
| // If we got some data already, we can clear the timeout, as we will get more | ||
| const disarmTimeout = () => { | ||
| if (timeout) { | ||
| clearTimeout(timeout); | ||
| timeout = null; | ||
| } | ||
| }; | ||
|
|
||
| armTimeout(firstByteTimeout); | ||
|
|
||
| const onData = (chunk: Buffer) => { | ||
| bufferChunks.push(chunk); | ||
|
|
||
| disarmTimeout(); | ||
|
|
||
| // An implicit read has no other way to tell the writer is done, so every chunk restarts the | ||
| // clock. An explicit one waits for the real end of the stream. | ||
| if (implicit) { | ||
| armTimeout(IMPLICIT_STDIN_IDLE_TIMEOUT_MILLIS); | ||
| } | ||
| }; | ||
|
|
||
| stream.on('data', onData); | ||
|
|
||
| try { | ||
|
|
@@ -41,24 +106,23 @@ export async function readStdin() { | |
| const casted = error as Error; | ||
|
|
||
| if (casted.name === 'AbortError') { | ||
| return; | ||
| // An explicit read that runs out its deadline saw nothing at all, so it has nothing to give | ||
| // back. An implicit one keeps whatever arrived before stdin went quiet. | ||
| if (!implicit) { | ||
| return; | ||
| } | ||
|
|
||
| // A socket's short first-byte guard fires for every spawned CLI, so only a read that | ||
| // outwaited the idle timeout counts as given up on. | ||
| readCutShort = bufferChunks.length > 0 || firstByteTimeout === IMPLICIT_STDIN_IDLE_TIMEOUT_MILLIS; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: the module logs nothing. A |
||
| } | ||
| } finally { | ||
| // Stop reading from stdin so its open handle can't keep the event loop (and | ||
| // the CLI) alive after the command finishes (#1206). This only helps when the | ||
| // await above settles ('end' or the no-data abort). A writer that sends data | ||
| // but never closes stdin still hangs up there; that needs the lazy stdin | ||
| // reading discussed in #1206. | ||
| // Stop reading so an open stdin handle can't keep the CLI alive after the command (#1206). | ||
| stream.off('data', onData); | ||
| stream.pause(); | ||
| } | ||
|
|
||
| if (timeout) { | ||
| clearTimeout(timeout); | ||
| } | ||
|
|
||
| // Mark further uses of useStdin / readStdin as having no more data since we've read it all | ||
| dataRef.hasData = false; | ||
| disarmTimeout(); | ||
|
|
||
| const concat = Buffer.concat(bufferChunks); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,10 +4,10 @@ import process from 'node:process'; | |||||
|
|
||||||
| import mime from 'mime'; | ||||||
|
|
||||||
| import { cachedStdinInput } from '../../entrypoints/_shared.js'; | ||||||
| import { CommandExitCodes } from '../consts.js'; | ||||||
| import { error } from '../outputs.js'; | ||||||
| import { error, warning } from '../outputs.js'; | ||||||
| import { getLocalInput } from '../utils.js'; | ||||||
| import { readStdin, stdinWasCutShort } from './read-stdin.js'; | ||||||
|
|
||||||
| interface InputOverrideOptions { | ||||||
| schemaHint?: string; | ||||||
|
|
@@ -58,8 +58,18 @@ export async function getInputOverride( | |||||
| const { schemaHint } = options; | ||||||
|
|
||||||
| if (!inputFlag && !inputFileFlag) { | ||||||
| // Try reading stdin | ||||||
| const stdin = cachedStdinInput; | ||||||
| // Nobody asked for stdin here, so it must not block: this command is reachable with a pipe | ||||||
| // it only inherited from whatever spawned it. | ||||||
| const stdin = await readStdin({ implicit: true }); | ||||||
|
|
||||||
| // Giving up on a silent pipe is the only way not to hang on one, but a writer that was just | ||||||
| // slow would otherwise see its input vanish with nothing said. | ||||||
| if (!stdin && stdinWasCutShort()) { | ||||||
| warning({ | ||||||
| message: | ||||||
| 'Nothing arrived on standard input, so it was ignored. Use `--input-file=-` to wait for a slow writer.', | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| if (stdin) { | ||||||
| try { | ||||||
|
|
@@ -76,9 +86,15 @@ export async function getInputOverride( | |||||
| input = parsed; | ||||||
| source = 'stdin'; | ||||||
| } catch (err) { | ||||||
| // Standard input is only waited on for as long as it keeps talking, so a writer that stalls | ||||||
| // mid-payload leaves behind JSON that ends nowhere. Say so, or the message blames the data. | ||||||
| const cutShortHint = stdinWasCutShort() | ||||||
| ? '\nStandard input went quiet before it closed, so only part of it was read. Use `--input-file=-` to wait for all of it.' | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: in this branch
Suggested change
|
||||||
| : ''; | ||||||
|
|
||||||
| error({ | ||||||
| message: withSchemaHint( | ||||||
| `Cannot parse JSON input from standard input.\n ${(err as Error).message}`, | ||||||
| `Cannot parse JSON input from standard input.\n ${(err as Error).message}${cutShortHint}`, | ||||||
| schemaHint, | ||||||
| ), | ||||||
| }); | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: this one number does two jobs. Between chunks 2 s is generous, since pipe writes of a JSON blob arrive within milliseconds. For the first byte it is tight (
curlto a cold API,jqover a big file); the warning inresolve-input.tsnow says so, but the input is still ignored. Consider splitting it into a first-byte wait (longer, 5 s or so) and an idle wait (1 s or so), plus an env var override as an escape hatch. Either way the cutoff and the implicit fallback deserve a sentence in the--input-filehelp, since neither is documented today and #704 deliberately made the CLI wait for slow writers.