Skip to content

fix(runtime): a process.stdin resume() racing the stopping fd-0 reader stranded piped stdin without a reader (#10895) - #10913

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/10895-stdin-pipe-stall
Closed

proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/10895-stdin-pipe-stall

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #10895

What broke

for await (const chunk of process.stdin) on a pipe stopped part-way through the input and never resumed: 0 % CPU, the writer blocked, the process idle forever.

The async iterator pauses its source after every delivered chunk and resumes it on the next pull (node_stream/async_iterator.rs, ns_readable_iter_on_data / ns_readable_iterator_next). On process.stdin, pause() latches STDIN_DETACHED — the fd-0 reader thread exits when it sees the latch at the top of its loop — and resume() clears the latch and respawns the reader unless STDIN_READER_STARTED is still true. The reader's stop decision and its STARTED reset (a drop guard) were two separate steps:

reader: sees STDIN_DETACHED == true, decides to exit
main:   resume() → STDIN_DETACHED = false; CAS(STARTED false→true) FAILS — the dying reader has not cleared it yet
reader: clears STARTED, gone

fd 0 then has no reader at all (a stalled process shows only the main thread, in js_wait_for_event), while js_readline_has_active still reports a started, un-paused, flowing stdin — so the loop stays alive and idles forever.

Why it is timing- and platform-dependent

One roll of that dice per delivered chunk. A 16 KiB macOS pipe fed in small writes delivers ~170 chunks/MiB, so 1 MiB is a coin flip there and 4 MiB is near-certain. A 64 KiB Linux pipe delivers ~16 chunks/MiB and the window is a handful of instructions, so it needs preemption to land: 1 hang in 350 runs at 16 MiB, under load average ~30.

Introduced by bb57392 (2026-09-04, "unify process.stdin reader surface"). Before it a piped stdin was read by perry-stdlib readline's own reader, which never consulted the latch — that is why the published 0.5.1520 is clean.

The fix

crates/perry-runtime/src/os_process_streams.rs: one lifecycle lock makes the reader's check-and-clear (stdin_reader_claim_stop) and the restart CAS (stdin_reader_claim_start) atomic with respect to each other. A restart request now runs either entirely before the stop decision (the reader sees the cleared latch and keeps going — no second reader on fd 0) or entirely after it (STARTED is already false, a fresh reader is spawned). The lock is never held across read(). The detach exit releases the slot itself and disarms the drop guard, which otherwise could clobber the flag of a reader respawned in between.

Hang rates, same driver (communicate() of N bytes, 15 s deadline)

host / build 64 KiB 1 MiB 4 MiB 8 MiB (256-byte writes) 16 MiB 64 MiB
macOS arm64, main 841b605c97 0/6 3/6 6/6 5/8
macOS arm64, this PR 0/6 0/30 0/30 0/30 0/5
Linux x86_64, main 841b605c97 0/30 0/30 0/30 0/20 (512-byte) 1/350 0/20
Linux x86_64, this PR 0/30 0/30 0/30 0/30 0/30 0/20

Binaries hashed on both hosts; the two arms are distinct.

Tests

  • crates/perry/tests/issue_10895_stdin_pipe_stall.rs + test-files/test_issue_10895_stdin_pipe_stall.ts: pipes 8 MiB in 256-byte writes, 12 rounds, 60 s deadline each, asserts every byte reaches the iterator. Red on unpatched main (841b605c97 + this test only, macOS): round 0: piped stdin stalled — the child was still alive after 60s with 2170368 of 8388608 bytes not even accepted by the pipe. Green with the fix: 12/12 on macOS (--release), 12/12 on Linux run CI-style (cargo test -p perry with PERRY_RUNTIME_DIR=target/release). The fixture is inert when undriven (RESULT:idle), so the parity sweep never waits on stdin.
  • reader_lifecycle_tests in os_process_streams.rs: replays the exact interleaving on local flags (deterministic), the opposite order (no second reader), and a 200k-iteration pause/resume storm from two threads.

Verification

  • cargo test --lib -p perry-runtime (full, RUST_TEST_THREADS=1, Linux): 4203 passed, 0 failed, 4 ignored.
  • Adjacent suites, both hosts: issue_9692_stdin_surface, issue_9676_stdin_unref_ref_keeps_reader, issue_9594_readline_close_pauses_stdin, issue_9588_readline_reader_notifies_the_pump, issue_9593_readline_escape_timeout, issue_stdin_end_listener — all green.
  • cargo fmt --check, product lint RUSTFLAGS="-D warnings" cargo check -p perry --bins — green (Linux).
  • Behaviour: aliased s.on("data") with a pause/resume storm delivers every byte (4 MiB); a program that never touches stdin exits immediately with stdin held open; process.stdin.unref() still does not stop the reader (TUI input dies after real use: bytes reach the process and are consumed, but never reach JS — live forensics point at the GC (swept listener) #9676 kept).
  • Pre-existing on main, not touched here: warning: function relevant_box_roots is never used (box.rs:1038) in the release build of both arms.

Version not bumped (merge train). Found while getting the Native Messaging host from https://github.com/guest271314/NativeMessagingHosts to run (jlucaso1/js-compiled#2); the "garbage frame header" noted in the issue is a separate, deterministic bug in process.stdout.write (#10903, owned separately).

…eader no longer strands stdin without a reader (PerryTS#10895)

The async iterator pauses its source after every delivered chunk and resumes
it on the next pull. On process.stdin, pause() latches STDIN_DETACHED — the
fd-0 reader thread exits when it sees it at the top of its loop — and resume()
clears the latch and respawns the reader unless STDIN_READER_STARTED says one
is still running. The reader's stop decision and its STARTED reset were two
separate steps, so a resume() that landed between them found STARTED still
true, spawned nothing, and the old reader then left: fd 0 had no reader while
every liveness view still reported an open, flowing stdin, and the process
idled forever with input unread.

Make the reader's check-and-clear and the restart CAS atomic with respect to
each other under one lifecycle lock (never held across read()). The detach
exit now releases the reader slot itself and disarms the drop guard, which
otherwise could clobber the flag of a reader respawned in between.

Introduced by bb57392 (2026-09-04, unified fd-0 reader): before it, a piped
stdin was read by readline's own reader, which never consulted the latch, so
v0.5.1520 does not reproduce.

Fixes PerryTS#10895
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 447181c2-4ef1-44a7-9d39-73540cad0524

📥 Commits

Reviewing files that changed from the base of the PR and between 841b605 and 735f9b1.

📒 Files selected for processing (4)
  • changelog.d/10913-stdin-reader-restart-race.md
  • crates/perry-runtime/src/os_process_streams.rs
  • crates/perry/tests/issue_10895_stdin_pipe_stall.rs
  • test-files/test_issue_10895_stdin_pipe_stall.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

The fd-0 stdin reader now synchronizes stop, cleanup, and restart claims with a lifecycle mutex. New runtime and end-to-end tests cover pause/resume races and stalled pipe input.

Changes

Stdin reader lifecycle fix

Layer / File(s) Summary
Synchronize stdin reader lifecycle
crates/perry-runtime/src/os_process_streams.rs, changelog.d/10913-stdin-reader-restart-race.md
A lifecycle lock makes reader stop checks, slot release, and restart claims atomic. Detach exits release the reader slot without clearing a replacement reader’s claim.
Validate concurrent lifecycle behavior
crates/perry-runtime/src/os_process_streams.rs
Lifecycle tests cover restart timing, resume ordering, and 200,000 concurrent pause/resume iterations.
Exercise piped stdin consumption
crates/perry/tests/issue_10895_stdin_pipe_stall.rs, test-files/test_issue_10895_stdin_pipe_stall.ts
The regression tests compile a fixture, stream 8 MiB of piped input over 12 rounds, verify complete reads, and verify immediate idle exit when stdin is not consumed.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 3 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses #10895. os_process_streams.rs serializes reader stop decisions and restart claims with STDIN_READER_LIFECYCLE. The detach path releases the reader slot before exit and disarms the…
Out of Scope Changes check ✅ Passed The changed runtime code, lifecycle unit tests, pipe regression tests, fixture, and changelog entry support #10895 and the stated stdin lifecycle fix. The diff does not add fixes for the separate down…
Title check ✅ Passed The title clearly identifies the stdin resume race and the resulting stranded piped stdin. It is specific and directly matches the main change.
Description check ✅ Passed The description provides a detailed summary, root cause, fix, related issue, implementation changes, tests, verification results, and scope boundaries. It does not use the repository template headings…
Full details: Docstring Coverage

Explanation

Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 3 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🧪 Generate unit tests (beta)
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main in merge train 254 (#10930, a022cf2e41, released as v0.5.1634) — your commits are on main verbatim; the train cherry-picked them rather than merging this branch, so GitHub cannot mark it merged. Closing as landed, not as rejected.

The train was validated as one tree: all ratchets, cargo check --workspace --all-targets under -D warnings, cargo audit (0 vulnerabilities), the 83-gate run_lint_gates.sh (only the known-red public baseline failing), 6,679 unit tests + 1,150 CLI tests + 8 acceptance tests with zero failures, both compiler-output regressions, the repsel census, and a 174-test gap sweep with no unexplained regressions. Artifacts were pinned by sha256 before the test phase and still matched after it.

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.

for await (const chunk of process.stdin) intermittently stalls forever on piped input ≥ ~1 MiB (4 MiB+ hangs ~always)

1 participant