fix(cli): read stdin lazily so open pipes don't hang the CLI by l2ysho · Pull Request #1330 · apify/apify-cli · GitHub
Skip to content

fix(cli): read stdin lazily so open pipes don't hang the CLI - #1330

Open
l2ysho wants to merge 12 commits into
masterfrom
claude/apify-cli-1206-verify-37b57c
Open

l2ysho wants to merge 12 commits into
masterfrom
claude/apify-cli-1206-verify-37b57c

Conversation

@l2ysho

@l2ysho l2ysho commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Note

TL;DR — The CLI read stdin at startup for every command, so a pipe that stays open hung it forever. Now it only reads stdin when a command wants it, and a read nobody asked for gives up when the pipe goes quiet.

Closes #1206.

The startup read

_shared.ts read stdin at module scope (export const cachedStdinInput = await readStdin()), so every command blocked on stdin whether or not it uses it. #1294 fixed the socket case (a spawned child's stdio: 'pipe'), but a named pipe has no wait deadline — the read never ended and the command never even ran, so 1.8.0 still hangs with no output at all:

sleep 60 | apify --version   # 1.8.0: hangs, no output

Read stdin only where it's asked for: a - arg/flag, apify run without --input, push-data/push-items without an item arg. readStdin() memoizes, since stdin can only be drained once.

Confirmations keyed off hasData, which the eager read flipped to false after draining. Without that read it stays true, so apify <confirm-cmd> < /dev/null would try to prompt on a non-TTY — gated on isTTY instead.

The implicit read

That left one path still hanging. apify run without --input falls back to stdin, and waited there for an end a named pipe never reaches. actors call and actors start share it. Two ways to hang:

apify run < /some/fifo                          # nothing ever sent
(printf '{"a":1}'; sleep 30) | apify run        # sent, then held open

A read the user never asked for now stops at the first quiet gap: each chunk restarts a 2s clock, and when stdin goes silent the CLI uses what arrived. An explicit - is unchanged — it waits for the writer to close, the way cat does.

Trade-off: a writer slower than ~2.5s to its first byte is now ignored (curl slow-endpoint | apify run). Truncation mid-stream stays loud, since the JSON fails to parse. 2s is a judgement call, not a derived number.

Verification

Measured against a build of master and a build of this branch.

stdin command before after
open pipe, no data --version hang, no output 405 ms
open pipe, with data --version hang, no output 654 ms
closed pipe --version exits exits
spawn stdio: 'pipe' (#1294's case) --version exits exits
open pipe, silent run in a project hang 2621 ms, no input
open pipe, sends then holds run in a project hang 2619 ms, input used
writer delays 0 / 1 / 2 s, then closes run in a project input used input used
writer delays 4 s, then closes run in a project input used input dropped, 2603 ms

Three new e2e cases, all skipped on Windows (no mkfifo). Each fails on the pre-fix build at the 15s deadline and passes after:

  • FIFO on stdin, empty dir — the startup read
  • FIFO on stdin, real project, silent — the implicit read
  • FIFO on stdin, real project, sends then holds open — asserts the Actor received the input

pnpm run test:local green (540 passed). pnpm run test:e2e:local green (44 passed). No dependency or install-size change.

🤖 Generated with Claude Code

@github-actions github-actions Bot added this to the 147th sprint - Tooling team milestone Aug 14, 2026
@github-actions github-actions Bot added t-tooling Issues with this label are in the ownership of the tooling team. tested Temporary label used only programatically for some analytics. labels Aug 14, 2026
@l2ysho
l2ysho requested a review from vladfrangu August 14, 2026 11:00
@l2ysho l2ysho added t-builders Issues owned by the Builders team. adhoc Ad-hoc unplanned task added during the sprint. and removed t-tooling Issues with this label are in the ownership of the tooling team. labels Aug 14, 2026
@l2ysho
l2ysho marked this pull request as ready for review August 14, 2026 11:02
@l2ysho
l2ysho requested a review from DaveHanns as a code owner August 14, 2026 11:02
Every command paid for stdin: `_shared.ts` did `await readStdin()` at module
scope, so `apify --version` blocked on a stream it never reads. With a named
pipe (no wait deadline, unlike the socket a spawned child gets) the read never
ended and the command never even ran.

Read stdin only when a command asks for it — a `-` arg/flag, `apify run`
without `--input`, `push-data`/`push-items` without an item. Result is
memoized, since stdin can only be drained once.

Confirmations previously keyed off `hasData`, which the eager read flipped to
false once stdin was drained. Without that read the flag stays true, so
`apify <confirm-cmd> < /dev/null` would try to prompt on a non-TTY; gate on
`isTTY` instead.

Commands that genuinely consume stdin still wait for the writer to close, same
as `cat`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@l2ysho
l2ysho force-pushed the claude/apify-cli-1206-verify-37b57c branch from 29e4187 to 2b8ebae Compare September 3, 2026 12:25
l2ysho and others added 9 commits September 3, 2026 23:15
`apify run` without `--input` falls back to stdin, and waited there for an
end that a named pipe never reaches. A pipe the process merely inherited
hung the command forever, and so did a writer that sent data and kept the
pipe open. `actors call` and `actors start` share the path.

Reads that nobody asked for now stop at the first quiet gap: each chunk
restarts a 2s clock, and when stdin goes silent the CLI uses what arrived.
An explicit `-` is unchanged and still waits for the writer to close.

The cost is a writer slower than ~2.5s to its first byte, whose input is
now ignored. Truncation mid-stream stays loud, as the JSON fails to parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The implicit read replaced `waitDelay` with its own 2s deadline, so a
spawned child's socket stdin lost the 50ms first-byte guard. Measured on
`apify run` with a silent socket: 703ms before, 2637ms after. Keep
`waitDelay` as the first-byte deadline where stdin has one, and use the
2s constant for the idle gap and for stdin that has no deadline at all.

`push-data` and `push-items` fall back to stdin the same way and still
hung on a silent named pipe. They now use the same deadline: 2.4s to
`No item was provided.` instead of waiting forever.

A writer that stalls mid-payload leaves JSON that ends nowhere, and the
parse error blamed the data. `getInputOverride` now says stdin went
quiet and points at `--input-file=-`.

`peekStdin()` fed the bug report footer from a read that had not
happened yet, so it always printed `No`. Replaced with
`describeStdinRead()`, which distinguishes `Not read`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…57c' into claude/apify-cli-1206-verify-37b57c
…06-verify-37b57c

# Conflicts:
#	src/lib/command-framework/apify-command.ts
l2ysho added a commit that referenced this pull request Oct 19, 2026
> [!NOTE]
> **TL;DR**
> `apify init` never terminated when stdin was not a TTY. Fixed, along
with three other defects on the same path.

## What was broken

`apify init` with no name and a non-TTY stdin never exited. Not a
blocked read — a retry loop writing ~28 MB/s to stderr. Measured 152 MB
in 6 s, which also makes the process hard to kill.

`stdinCheckWrapper` throws whenever stdin cannot be read. The loop
caught that, printed it, and retried. The error is permanent, so the
retry could never succeed.

## What changed

| | Defect | Before | After |
|---|---|---|---|
| D1 | `--yes` did not skip the name prompt, though `interactiveNote`
and the `apify init --yes` example both promise it does | infinite loop
| exit 0, uses the directory name |
| D2 | The retry loop treated a permanent failure as retryable |
infinite loop | one error, exit 1 |
| D3 | The directory confirmation turned "cannot ask" into "no" | **exit
0**, nothing done | exit 1 with a message |
| D4 | The positional name was never validated | `init 'Bad Name!!'`
exited 0 and wrote that name, failing later on push | exit 1 |

D2 is fixed by removing the loop, not capping it. Validation moved
inside inquirer's own `validate`, so a typo still re-asks interactively,
while a prompt that cannot be answered fails once with a message naming
the escape hatch:

```
Error: Actor name is required. Run 'apify init <name>', or pass --yes to use the current directory name.
```

Two supporting changes: the directory name is now sanitized for every
project type rather than only Python (`my_project` → `my-project`), and
the prompt validator is shared with `apify create`, which had its own
copy.

## Behavior changes worth a reviewer's attention

- A non-Node/Python directory without a TTY goes from exit 0 to exit 1.
The old exit 0 did nothing useful, but a CI script treating 0 as success
will start failing.
- An invalid explicit name is now rejected up front. That includes two
cases where the name was previously discarded anyway: an
already-initialized directory, and a Scrapy project. Rejecting a name
the user typed beats silently dropping it.
- The default Actor name is sanitized for all project types, not just
Python.

## Tests

Three e2e tests through a new `runCliBounded` helper, plus four local
tests.

Both layers assert on the loop itself, not on a timeout. A hang and a
loop are indistinguishable by deadline and cost the full deadline either
way. The helper caps combined output at 256 KB and kills on breach, so a
regression fails in under a second.

execa's own `maxBuffer` was the obvious alternative and was measured
instead of assumed: 7–20 s to trip, against 25–500 ms for the counter.
In one run it blew past its own 15 s timeout and still reported
`timedOut: false`.

## Not in scope

- The held-open-pipe variant still hangs. That happens in `readStdin()`
at entrypoint load, before any command code runs —
[#1206](#1206), fixed by
[#1330](#1330). Verified: with
the eager read stubbed out, that case exits 0 in 1.3 s with the default
name.
- [#1451](#1451) (global
`--non-interactive` flag) is adjacent but different. It covers
pseudo-terminals, where a TTY exists; this is the plain non-TTY path.
- [#1354](#1354) — the
non-interactive error naming `--confirm`/`--no-confirm`, which do not
exist. `init` now has its own messages; other commands still print the
misleading default.
- `validateActorName` says "maximum of 30 characters" while
`ACTOR_NAME.MAX_LENGTH` is 63. Pre-existing; the new `--yes` failure
path surfaces it more often.

## Checks

`lint`, `format`, `build` clean. `test:local` 660 passed. Local e2e 44
passed. No dependency changes, so no install-size impact. `update-docs`
produces no diff — flags, args, description and examples are untouched.

CI's one red check is `actors info --input`, inherited from master. The
same assertion fails on master's own E2E runs daily back to 5 October,
and [#1473](#1473) fixes it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adhoc Ad-hoc unplanned task added during the sprint. t-builders Issues owned by the Builders team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI hangs after command completes when stdin is open but not a TTY

2 participants