Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion crates/perry-hir/src/lower/expr_call/module_class_static.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,31 @@ pub(super) fn try_module_class_static(
let arg = args.into_iter().next().unwrap();
return Ok(Ok(Expr::ProcessStdinSetRawMode(Box::new(arg))));
}
("stdin", "on") | ("stdin", "addListener") if args.len() >= 2 => {
// `once` lowers here too. Without it, only
// `on`/`addListener` reached readline's stdin
// listener registry and `process.stdin.once(…)`
// fell through to the generic member-call path,
// which never registers with the fd-0 reader —
// so the callback simply never fired.
//
// That is not a corner case: Claude Code's `-p`
// stdin reader awaits
// `race(stdin.once("end"), timeout(3000))`, so
// with `once` dropped the `end` half could never
// win. The race fell through to the timeout, and
// because that timer is unref'd nothing kept the
// loop alive — `echo hi | claude -p …` exited 0
// having printed NOTHING (node prints the result).
//
// One-shot semantics are handled downstream by
// the pump, which takes the `end` listener list
// when it fires; `data`/`readable` listeners
// registered via `once` are a documented
// residual (they behave like `on`) — the streams
// that matter here are EOF-driven.
("stdin", "on") | ("stdin", "addListener") | ("stdin", "once")
if args.len() >= 2 =>
{
let mut iter = args.into_iter();
let event = iter.next().unwrap();
let handler = iter.next().unwrap();
Expand Down
59 changes: 59 additions & 0 deletions crates/perry-hir/tests/process_stdin_once_lowering.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! `process.stdin.once(event, handler)` must lower to `Expr::ProcessStdinOn`,
//! exactly like `on` / `addListener`.
//!
//! Only `on` and `addListener` were matched, so `once` fell through to the
//! generic member-call path and never reached `js_readline_stdin_on` — the
//! callback was never registered with the fd-0 reader and simply never fired.
//!
//! That is what broke `echo hi | claude -p "…"`. Claude Code's print-mode
//! stdin reader is:
//!
//! ```js
//! process.stdin.on("data", acc);
//! const timedOut = await race(process.stdin.once("end"), timeout(3000));
//! ```
//!
//! With `once` dropped, the `end` half of that race could never win. The race
//! always fell through to the timer — and because that timer is `unref`'d,
//! nothing kept the event loop alive, so the process exited 0 having printed
//! nothing at all (node prints the result).

use perry_diagnostics::SourceCache;
use perry_hir::{clear_current_module_source, lower_module};
use perry_parser::parse_typescript_with_cache;

fn lower_debug(src: &str) -> String {
let mut cache = SourceCache::new();
let parsed = parse_typescript_with_cache(src, "/tmp/stdin_once_test.ts", &mut cache)
.expect("parse failed");
let hir =
lower_module(&parsed.module, "test", "/tmp/stdin_once_test.ts").expect("lower failed");
clear_current_module_source();
format!("{:#?}", hir.init)
}

#[test]
fn process_stdin_once_lowers_like_on() {
let ir = lower_debug(r#"process.stdin.once("end", () => {});"#);
assert!(
ir.contains("ProcessStdinOn"),
"process.stdin.once must lower to ProcessStdinOn so the handler reaches \
readline's stdin listener registry:\n{ir}"
);
}

#[test]
fn process_stdin_on_and_add_listener_still_lower() {
let ir = lower_debug(
r#"
process.stdin.on("data", () => {});
process.stdin.addListener("end", () => {});
process.stdin.once("error", () => {});
"#,
);
assert_eq!(
ir.matches("ProcessStdinOn").count(),
3,
"on / addListener / once must all lower to ProcessStdinOn:\n{ir}"
);
}
Loading
Loading