Skip to content

fix(cli): exit cleanly when stdin is an open pipe that never closes#1294

Open
artogahr wants to merge 2 commits into
apify:masterfrom
artogahr:fix/stdin-hang-1206
Open

fix(cli): exit cleanly when stdin is an open pipe that never closes#1294
artogahr wants to merge 2 commits into
apify:masterfrom
artogahr:fix/stdin-hang-1206

Conversation

@artogahr

Copy link
Copy Markdown

Hi folks, this is something that was bothering me for a while, so I thought I would go ahead and make a PR for it.

What's wrong

When stdin is connected but never closed, which is what you get from anything spawning the CLI with stdio: ['pipe', ...] (e.g. claude code), every command finishes its work, prints all of its output, and then never exits. Reported in #1206, and it's the visible symptom in #1187 as well.

Why it happens

src/entrypoints/_shared.ts awaits readStdin() before running any command. For socket-type stdin there's a 50ms timeout so the CLI doesn't wait forever, but the abort path returned with the 'data' listener still attached and the stream still flowing. That single open handle keeps the event loop from draining. There's no process.exit() on the normal path, the process relies on the loop emptying out, so it hangs after the command is done. A stack sample of a hung process shows the main thread idle in uv__io_poll.

The fix

readStdin now removes its listener and pauses the stream in a finally block. pause() releases the loop for every stdin type Node hands out, and it's the safest of the options: fs.ReadStream stdin (from < file) has no unref(), and destroying fd 0 would break set-value, which streams process.stdin as the upload body.

A nice side effect: the leftover flowing listener used to eat any stdin bytes that arrived after the 50ms abort, which could silently truncate set-value uploads. Those bytes now stay buffered in the paused stream and upload in full.

Regression test

test/e2e/commands/stdin-held-open.test.ts spawns the built CLI with stdin held open and checks that it exits on its own. It passes on this branch in about half a second, and fails with a timeout if you strip the fix back out of the bundle. It needs dist/ built, so it lives in the e2e suite and runs with pnpm run test:e2e, not test:local.

What this doesn't fix

These all predate the change and behave the same before and after:

The first two need the lazy stdin reading discussed in #1206.

I've verified this change separately with Fable and Kimi K3.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants