diff --git a/src/commands/actor/push-data.ts b/src/commands/actor/push-data.ts index b6ae01d57..a36dac65b 100644 --- a/src/commands/actor/push-data.ts +++ b/src/commands/actor/push-data.ts @@ -1,7 +1,7 @@ -import { cachedStdinInput } from '../../entrypoints/_shared.js'; import { APIFY_STORAGE_TYPES, getApifyStorageClient, getDefaultStorageId } from '../../lib/actor.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; +import { readStdin } from '../../lib/commands/read-stdin.js'; import { error } from '../../lib/outputs.js'; export class ActorPushDataCommand extends ApifyCommand { @@ -34,7 +34,7 @@ export class ActorPushDataCommand extends ApifyCommand | Record[]; - const item = _item || cachedStdinInput; + const item = _item || (await readStdin()); if (!item) { error({ message: 'No items were provided.' }); diff --git a/src/entrypoints/_shared.ts b/src/entrypoints/_shared.ts index 363801f71..a48884133 100644 --- a/src/entrypoints/_shared.ts +++ b/src/entrypoints/_shared.ts @@ -11,7 +11,6 @@ import type { BuiltApifyCommand } from '../lib/command-framework/apify-command.j import { commandRegistry, internalRunCommand } from '../lib/command-framework/apify-command.js'; import { CommandError } from '../lib/command-framework/CommandError.js'; import { renderMainHelpMenu } from '../lib/command-framework/help.js'; -import { readStdin } from '../lib/commands/read-stdin.js'; import { SUPPORTED_NODEJS_VERSION } from '../lib/consts.js'; import { useCLIMetadata } from '../lib/hooks/useCLIMetadata.js'; import { shouldSkipVersionCheck } from '../lib/hooks/useCLIVersionCheck.js'; @@ -19,8 +18,6 @@ import { useCommandSuggestions } from '../lib/hooks/useCommandSuggestions.js'; import { error } from '../lib/outputs.js'; import { cliDebugPrint } from '../lib/utils/cliDebugPrint.js'; -export const cachedStdinInput = await readStdin(); - const cliMetadata = useCLIMetadata(); export const USER_AGENT = `Apify CLI/${cliMetadata.version} (https://github.com/apify/apify-cli)`; diff --git a/src/lib/command-framework/CommandError.ts b/src/lib/command-framework/CommandError.ts index f73a204de..60392c3b2 100644 --- a/src/lib/command-framework/CommandError.ts +++ b/src/lib/command-framework/CommandError.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; -import { cachedStdinInput } from '../../entrypoints/_shared.js'; +import { peekStdin } from '../commands/read-stdin.js'; import { useCLIMetadata } from '../hooks/useCLIMetadata.js'; import type { BuiltApifyCommand } from './apify-command.js'; import { selectiveRenderHelpForCommand } from './help.js'; @@ -218,7 +218,7 @@ export class CommandError extends Error { '', `- CLI version: \`${cliMetadata.fullVersionString}\``, `- CLI debug logs (process.env.APIFY_CLI_DEBUG): ${process.env.APIFY_CLI_DEBUG ? 'Enabled' : 'Disabled'}`, - `- Stdin data? ${cachedStdinInput ? 'Yes' : 'No'}`, + `- Stdin data? ${peekStdin() ? 'Yes' : 'No'}`, ].join('\n'); } } diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 43c81c937..b32e89854 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -9,7 +9,7 @@ import indentString from 'indent-string'; import widestLine from 'widest-line'; import wrapAnsi from 'wrap-ansi'; -import { cachedStdinInput } from '../../entrypoints/_shared.js'; +import { readStdin } from '../commands/read-stdin.js'; import { detectAiAgent, detectCi, detectIsInteractive } from '../hooks/telemetry/detectEnvironment.js'; import type { TrackEventMap } from '../hooks/telemetry/trackEvent.js'; import { trackEvent } from '../hooks/telemetry/trackEvent.js'; @@ -360,7 +360,7 @@ export abstract class ApifyCommand | undefined; +let readResult: Buffer | undefined; + +/** + * Reads stdin to its end, at most once per process. Callers must only call this when the command + * actually wants stdin data — a pipe that stays open never ends, so this waits for as long as the + * writer keeps it open (#1206). + */ export async function readStdin() { - const dataRef = await useStdin(); + readPromise ??= _readStdin().then((data) => { + readResult = data; + return data; + }); - const { hasData, waitDelay, stream } = dataRef; + return readPromise; +} + +/** + * Stdin data read so far, without triggering a read. For diagnostics only. + */ +export function peekStdin() { + return readResult; +} + +async function _readStdin() { + const { hasData, waitDelay, stream } = await useStdin(); if (!hasData) { return; @@ -45,10 +67,7 @@ export async function readStdin() { } } 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. + // the CLI) alive after the command finishes (#1206). stream.off('data', onData); stream.pause(); } @@ -57,9 +76,6 @@ export async function readStdin() { clearTimeout(timeout); } - // Mark further uses of useStdin / readStdin as having no more data since we've read it all - dataRef.hasData = false; - const concat = Buffer.concat(bufferChunks); if (concat.length) { diff --git a/src/lib/commands/resolve-input.ts b/src/lib/commands/resolve-input.ts index 0fd04769e..1b2538d41 100644 --- a/src/lib/commands/resolve-input.ts +++ b/src/lib/commands/resolve-input.ts @@ -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 { getLocalInput } from '../utils.js'; +import { readStdin } from './read-stdin.js'; interface InputOverrideOptions { schemaHint?: string; @@ -59,7 +59,7 @@ export async function getInputOverride( if (!inputFlag && !inputFileFlag) { // Try reading stdin - const stdin = cachedStdinInput; + const stdin = await readStdin(); if (stdin) { try { diff --git a/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts b/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts index 6cf3d9854..021650301 100644 --- a/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts +++ b/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts @@ -39,11 +39,13 @@ export function stdinCheckWrapper any>( }: StdinCheckWrapperOptions = {}, ): (...args: NewFunctionArgs) => Promise>> { return async (input, ...rest) => { - const { isTTY, hasData } = await useStdin(); + const { isTTY } = await useStdin(); const casted = input as StdinCheckWrapperInput>>; - if (isCI || (!isTTY && !hasData)) { + // Prompts need a terminal to read the answer from. Piped stdin is command input, not an + // answer source — before stdin became lazy (#1206) it was always drained by then anyway. + if (isCI || !isTTY) { if (typeof casted.providedConfirmFromStdin === 'undefined') { throw new Error( casted.errorMessageForStdin ?? diff --git a/test/e2e/commands/stdin-held-open.test.ts b/test/e2e/commands/stdin-held-open.test.ts index 11d58db37..29bc32b78 100644 --- a/test/e2e/commands/stdin-held-open.test.ts +++ b/test/e2e/commands/stdin-held-open.test.ts @@ -1,5 +1,6 @@ -import { mkdir, rm } from 'node:fs/promises'; +import { mkdir, open, rm } from 'node:fs/promises'; import path from 'node:path'; +import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { execa } from 'execa'; @@ -55,4 +56,36 @@ describe('[e2e] stdin held open (#1206)', () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Actor is of an unknown format'); }); + + // A named pipe is the harsher case: unlike the socket a spawned child gets, it has no wait + // deadline, so the old eager startup read blocked forever and the command never even ran. + // Opening the FIFO read-write keeps a writer attached, so it never reaches EOF, with no + // second process to manage. Windows has no mkfifo. + it.skipIf(process.platform === 'win32')('exits on its own when stdin is a named pipe with no writer', async () => { + const fifo = path.join(emptyDir, 'stdin.fifo'); + await execa('mkfifo', [fifo]); + + const handle = await open(fifo, 'r+'); + + try { + const result = await execa('node', [DistApify, 'run'], { + cwd: emptyDir, + reject: false, + timeout: EXIT_DEADLINE_MS, + stdin: handle.fd, + env: { + APIFY_CLI_DISABLE_TELEMETRY: '1', + APIFY_CLI_SKIP_UPDATE_CHECK: '1', + APIFY_DISABLE_KEYRING: '1', + }, + }); + + expect(result.timedOut, `stderr: ${result.stderr}`).toBe(false); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Actor is of an unknown format'); + } finally { + await handle.close(); + await rm(fifo, { force: true }); + } + }); });