From 9c5d0ac8c9153824a76ccc540b6bebd87b239492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 16:20:42 +0000 Subject: [PATCH] fix(stdlib): clock pending Escape with timeout (#9593) Hold incomplete ANSI escape prefixes against an explicit 500 ms deadline registered with the event pump. Unrelated loop turns no longer flush early, idle waits no longer flush late, and completing bytes cancel the one-shot.\n\nCover bare Escape under idle and interval-driven schedules plus split and complete arrow sequences on a real PTY. --- changelog.d/9593-readline-escape-timeout.md | 11 + crates/perry-runtime/src/event_pump.rs | 47 ++-- crates/perry-runtime/src/lib.rs | 26 ++ .../perry-stdlib/src/common/async_bridge.rs | 8 +- crates/perry-stdlib/src/readline/mod.rs | 11 + crates/perry-stdlib/src/readline/mod_tests.rs | 33 ++- crates/perry-stdlib/src/readline/pump.rs | 81 +++++- .../perry-stdlib/src/readline/test_support.rs | 1 + .../issue_9593_readline_escape_timeout.rs | 240 ++++++++++++++++++ 9 files changed, 419 insertions(+), 39 deletions(-) create mode 100644 changelog.d/9593-readline-escape-timeout.md create mode 100644 crates/perry/tests/issue_9593_readline_escape_timeout.rs diff --git a/changelog.d/9593-readline-escape-timeout.md b/changelog.d/9593-readline-escape-timeout.md new file mode 100644 index 0000000000..86d6972eaa --- /dev/null +++ b/changelog.d/9593-readline-escape-timeout.md @@ -0,0 +1,11 @@ +**fix(stdlib): clock bare Escape with readline's 500 ms timeout (#9593)** + +A raw-mode Escape keypress no longer flushes on an arbitrary event-loop turn. +The readline pump now holds a torn escape prefix until an explicit 500 ms +deadline, matching Node's default `escapeCodeTimeout`. Bytes that finish an +arrow or other ANSI sequence cancel the one-shot, while expiry delivers a bare +Escape. + +The deadline participates in the event pump's wait budget. Bare Escape therefore +arrives at the same time whether the loop is otherwise idle or an unrelated +timer is firing, and arrows split across reads remain a single keypress. diff --git a/crates/perry-runtime/src/event_pump.rs b/crates/perry-runtime/src/event_pump.rs index e2e9fd91d0..8b2796cd20 100644 --- a/crates/perry-runtime/src/event_pump.rs +++ b/crates/perry-runtime/src/event_pump.rs @@ -7,7 +7,7 @@ //! - a cross-thread event source (tokio worker, `std::thread::spawn`) //! calls `js_notify_main_thread` after pushing into a queue that the //! pump drains, or -//! - the next timer / interval deadline elapses, or +//! - the next timer, interval, or registered native deadline elapses, or //! - a 1-second safety cap elapses (heartbeat). //! //! Result: cross-thread async-op latency on the event loop drops from @@ -418,7 +418,7 @@ pub(crate) fn js_notify_promise_progress() { // * `perry_poll()` — drains microtasks + stdlib // * `perry_has_work()` — true while anything is pending (microtasks, // timers across all 3 queues, stdlib handles) -// * `perry_next_wake_ms()` — minimum across the 3 timer queues, or -1 +// * `perry_next_wake_ms()` — minimum timer/native deadline, or -1 // // Pair with `perry_set_wake_callback` for polling-free integration. // ============================================================================ @@ -482,21 +482,30 @@ pub extern "C" fn perry_has_work() -> i32 { 0 } -/// Returns the closest pending wake-up across all 3 timer queues, in -/// milliseconds from now. Returns -1.0 when no timers are scheduled — -/// the host can then sleep indefinitely (or until an OS event / a wake -/// callback fires). +/// Return every source that can put a deadline on the next event-loop park. +/// The fourth slot is an optional callback rather than a hard stdlib reference, +/// so runtime-only binaries retain their existing link surface. +#[inline] +fn next_wake_sources_ms() -> [f64; 4] { + [ + js_timer_next_deadline(), + js_callback_timer_next_deadline(), + js_interval_timer_next_deadline(), + crate::stdlib_pump::stdlib_next_wake_ms(), + ] +} + +/// Returns the closest pending wake-up across all timer queues and registered +/// stdlib one-shots, in milliseconds from now. Returns -1.0 when no deadline is +/// scheduled — the host can then sleep indefinitely (or until an OS event / a +/// wake callback fires). /// /// NaN is *not* returned — keeps the return shape printable and avoids /// surprising hosts that compare with `<`. #[no_mangle] pub extern "C" fn perry_next_wake_ms() -> f64 { let mut best: f64 = -1.0; - for d in [ - js_timer_next_deadline(), - js_callback_timer_next_deadline(), - js_interval_timer_next_deadline(), - ] { + for d in next_wake_sources_ms() { if d < 0.0 { continue; } @@ -526,11 +535,11 @@ pub extern "C" fn js_event_loop_host_driven() -> i32 { EVENT_LOOP_HOST_DRIVEN.load(Ordering::Relaxed) as i32 } -/// Block until the next scheduled timer fires, a notify arrives, or the -/// 1-second idle cap elapses — whichever is earliest. Returns immediately -/// if a notify arrived since the last call (the flag is cleared on -/// return). Replaces the old `js_sleep_ms` in the generated event loop -/// and `await` busy-wait. +/// Block until the next scheduled timer/native deadline fires, a notify +/// arrives, or the 1-second idle cap elapses — whichever is earliest. Returns +/// immediately if a notify arrived since the last call (the flag is cleared on +/// return). Replaces the old `js_sleep_ms` in the generated event loop and +/// `await` busy-wait. #[no_mangle] pub extern "C" fn js_wait_for_event() { if crate::promise::mt_profile_enabled() { @@ -571,11 +580,7 @@ pub extern "C" fn js_wait_for_event() { } let mut budget_ms: u64 = IDLE_CAP_MS; - for d in [ - js_timer_next_deadline(), - js_callback_timer_next_deadline(), - js_interval_timer_next_deadline(), - ] { + for d in next_wake_sources_ms() { if d >= 0.0 { let d_ms = d as u64; if d_ms < budget_ms { diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index e4e3016db2..7cc3b28249 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -367,6 +367,11 @@ pub(crate) mod stdlib_pump { use std::sync::Mutex; static STDLIB_PUMP_FN: AtomicPtr<()> = AtomicPtr::new(null_mut()); + /// Optional deadline provider for stdlib-owned one-shot work. The callback + /// returns milliseconds until its next wake, or -1 when it has no deadline. + /// Kept as a function pointer for the same reason as `STDLIB_PUMP_FN`: the + /// runtime must not hard-link perry-stdlib into runtime-only binaries. + static STDLIB_NEXT_WAKE_FN: AtomicPtr<()> = AtomicPtr::new(null_mut()); // Runtime-internal reactor pumps (child_process, node-pty) register here // when their first live handle appears, mirroring `STDLIB_PUMP_FN`. The @@ -517,6 +522,27 @@ pub(crate) mod stdlib_pump { STDLIB_PUMP_FN.store(f as *mut (), Ordering::Release); } + /// Register the stdlib's nearest-deadline provider. This lets native + /// one-shots participate in `js_wait_for_event` without manufacturing a JS + /// timer callback or relying on the one-second idle heartbeat. + #[no_mangle] + pub extern "C" fn js_register_stdlib_next_wake(f: extern "C" fn() -> f64) { + STDLIB_NEXT_WAKE_FN.store(f as *mut (), Ordering::Release); + } + + /// Milliseconds until the closest registered stdlib deadline, or -1 when + /// perry-stdlib is absent or currently owns no timed work. + pub(crate) fn stdlib_next_wake_ms() -> f64 { + let f = STDLIB_NEXT_WAKE_FN.load(Ordering::Acquire); + if f.is_null() { + return -1.0; + } + // SAFETY: `js_register_stdlib_next_wake` only stores callbacks with + // this exact ABI and signature. + let func: extern "C" fn() -> f64 = unsafe { std::mem::transmute(f) }; + func() + } + /// Run the registered stdlib pump if available. Safe to call even if perry-stdlib /// is not linked (no-op in that case). #[no_mangle] diff --git a/crates/perry-stdlib/src/common/async_bridge.rs b/crates/perry-stdlib/src/common/async_bridge.rs index 793a640b0a..b855d16313 100644 --- a/crates/perry-stdlib/src/common/async_bridge.rs +++ b/crates/perry-stdlib/src/common/async_bridge.rs @@ -290,9 +290,9 @@ where /// whichever first. Because the producing task ran in this same tick, its /// completion is observed in-thread and `perry_poll` drains it on the next loop /// turn; there is no cross-thread wake to lose. `budget_ms` is the loop's -/// computed sleep budget (min of the next perry-timer deadline and the 1 s idle -/// cap); a zero budget is floored to 1 ms so native work still gets one poll -/// cycle under a hot timer. +/// computed sleep budget (min of the next Perry timer/native deadline and the +/// 1 s idle cap); a zero budget is floored to 1 ms so native work still gets +/// one poll cycle under a hot timer. extern "C" fn stdlib_wait_driver(budget_ms: u64) { run_one_tick(budget_ms); } @@ -490,6 +490,7 @@ pub fn ensure_pump_registered() { extern "C" { fn js_register_stdlib_pump(f: extern "C" fn() -> i32); fn js_register_stdlib_has_active(f: extern "C" fn() -> i32); + fn js_register_stdlib_next_wake(f: extern "C" fn() -> f64); fn js_stdlib_init_dispatch(); } ensure_gc_scanner_registered(); @@ -508,6 +509,7 @@ pub fn ensure_pump_registered() { unsafe { js_register_stdlib_pump(js_stdlib_process_pending); js_register_stdlib_has_active(js_stdlib_has_active_handles); + js_register_stdlib_next_wake(crate::readline::js_readline_next_wake_ms); // Wire up the runtime-level HANDLE_METHOD_DISPATCH so that // generic `jsObject.method(args)` calls on stdlib handle types // (net.Socket, Fastify, ioredis) fall back to the right FFI diff --git a/crates/perry-stdlib/src/readline/mod.rs b/crates/perry-stdlib/src/readline/mod.rs index 229f6fcbf0..22459d6329 100644 --- a/crates/perry-stdlib/src/readline/mod.rs +++ b/crates/perry-stdlib/src/readline/mod.rs @@ -32,6 +32,7 @@ use std::io::Read; use std::io::{self, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; +use std::time::{Duration, Instant}; use perry_runtime::closure::{ get_valid_func_ptr, js_closure_alloc, js_closure_call0, js_closure_call1, js_closure_call2, @@ -122,6 +123,12 @@ static PENDING_DATA: Mutex>> = Mutex::new(Vec::new()); /// reader queues one byte per chunk, so `\x1b[A` arrives as three chunks /// and `pump::coalesce_escape_sequences` parks an incomplete prefix here. static PENDING_ESCAPE: Mutex> = Mutex::new(Vec::new()); +/// One-shot deadline for the escape prefix above. A wake from another timer or +/// producer must not make a held ESC look old enough to flush; only this clock +/// can do that (#9593). +static PENDING_ESCAPE_DEADLINE: Mutex> = Mutex::new(None); +/// Node's default `readline.escapeCodeTimeout`. +const ESCAPE_CODE_TIMEOUT: Duration = Duration::from_millis(500); /// Whether the one-shot `'readable'` EOF notification has been delivered. static READABLE_EOF_NOTIFIED: AtomicBool = AtomicBool::new(false); /// `true` when raw mode is enabled — the reader thread checks this @@ -1851,6 +1858,9 @@ pub extern "C" fn js_readline_stdin_destroy() -> f64 { if let Ok(mut p) = PENDING_ESCAPE.lock() { p.clear(); } + if let Ok(mut deadline) = PENDING_ESCAPE_DEADLINE.lock() { + *deadline = None; + } if let Ok(mut q) = PENDING_LINES.lock() { q.clear(); } @@ -1878,6 +1888,7 @@ pub extern "C" fn js_readline_stdin_destroy() -> f64 { // --------------------------------------------------------------------------- mod pump; +pub(crate) use pump::js_readline_next_wake_ms; pub use pump::{js_readline_has_active, js_readline_process_pending}; // --------------------------------------------------------------------------- diff --git a/crates/perry-stdlib/src/readline/mod_tests.rs b/crates/perry-stdlib/src/readline/mod_tests.rs index 5d3e0d5b78..fd7788c2fc 100644 --- a/crates/perry-stdlib/src/readline/mod_tests.rs +++ b/crates/perry-stdlib/src/readline/mod_tests.rs @@ -251,7 +251,7 @@ fn stdin_unref_and_destroy_release_active_state() { } #[test] -fn split_escape_sequence_reassembles_to_single_keypress() { +fn complete_escape_sequence_reassembles_to_single_keypress() { // The raw-mode reader queues one byte per chunk, so an arrow key // arrives as `\x1b`, `[`, `A` in three chunks. The pump must // reassemble them into ONE 'up' keypress, not escape + [ + A. @@ -268,10 +268,28 @@ fn split_escape_sequence_reassembles_to_single_keypress() { } #[test] -fn bare_escape_flushes_on_next_tick() { - // A lone ESC can't be distinguished from the start of a sequence - // within one tick — it's held, then flushed as a bare 'escape' - // keypress on the next tick if nothing followed. +fn split_escape_sequence_reassembles_before_timeout() { + // ESC can arrive in one read and the rest of an arrow in a later read. + // The completing bytes cancel the one-shot instead of observing a tick as + // an implicit timeout and tearing the arrow apart. + let _g = reset(); + let event = event_name("keypress"); + let cb = keypress_recorder_callback(); + let _ = js_readline_stdin_on(event, cb); + test_inject_chunk(b"\x1b"); + assert_eq!(js_readline_process_pending(), 0); + assert!(js_readline_next_wake_ms() > 0.0); + test_inject_chunk(b"["); + test_inject_chunk(b"C"); + assert_eq!(js_readline_process_pending(), 1); + KEYPRESS_NAMES.with(|names| assert_eq!(*names.borrow(), vec!["right".to_string()])); + assert_eq!(js_readline_next_wake_ms(), -1.0); +} + +#[test] +fn bare_escape_flushes_only_after_escape_code_timeout() { + // A lone ESC can't be distinguished from the start of a sequence. Pump + // turns before the deadline must leave it held; expiry emits one keypress. let _g = reset(); let event = event_name("keypress"); let cb = keypress_recorder_callback(); @@ -280,8 +298,13 @@ fn bare_escape_flushes_on_next_tick() { assert_eq!(js_readline_process_pending(), 0); // The held prefix keeps the loop alive so the flush tick runs. assert_eq!(js_readline_has_active(), 1); + let remaining = js_readline_next_wake_ms(); + assert!((1.0..=500.0).contains(&remaining)); + assert_eq!(js_readline_process_pending(), 0); + *PENDING_ESCAPE_DEADLINE.lock().unwrap() = Some(Instant::now() - Duration::from_millis(1)); assert_eq!(js_readline_process_pending(), 1); KEYPRESS_NAMES.with(|names| assert_eq!(*names.borrow(), vec!["escape".to_string()])); + assert_eq!(js_readline_next_wake_ms(), -1.0); } #[test] diff --git a/crates/perry-stdlib/src/readline/pump.rs b/crates/perry-stdlib/src/readline/pump.rs index b73f3ee6ac..c1aa4e84e3 100644 --- a/crates/perry-stdlib/src/readline/pump.rs +++ b/crates/perry-stdlib/src/readline/pump.rs @@ -130,24 +130,83 @@ fn escape_state(acc: &[u8]) -> EscState { } } +fn arm_escape_timeout() { + if let Ok(mut deadline) = PENDING_ESCAPE_DEADLINE.lock() { + *deadline = Some(Instant::now() + ESCAPE_CODE_TIMEOUT); + } +} + +fn cancel_escape_timeout() { + if let Ok(mut deadline) = PENDING_ESCAPE_DEADLINE.lock() { + *deadline = None; + } +} + +fn escape_timeout_expired() -> bool { + let Ok(mut deadline) = PENDING_ESCAPE_DEADLINE.lock() else { + return false; + }; + if deadline.is_some_and(|at| at <= Instant::now()) { + *deadline = None; + true + } else { + false + } +} + +/// Deadline provider registered with perry-runtime's event pump. Returning the +/// ceiling avoids truncating a sub-millisecond remainder to zero and spinning +/// before the timeout is actually due. +pub(crate) extern "C" fn js_readline_next_wake_ms() -> f64 { + if STDIN_DESTROYED.load(Ordering::Acquire) || STDIN_PAUSED.load(Ordering::Acquire) { + return -1.0; + } + let Ok(deadline) = PENDING_ESCAPE_DEADLINE.lock() else { + return -1.0; + }; + let Some(deadline) = *deadline else { + return -1.0; + }; + let now = Instant::now(); + if deadline <= now { + 0.0 + } else { + deadline.duration_since(now).as_millis().saturating_add(1) as f64 + } +} + /// Reassemble ANSI escape sequences that the raw-mode reader queues as /// individual 1-byte chunks (`\x1b`, `[`, `A` → one `\x1b[A` chunk) so a /// single arrow key fires a single `'keypress'`/`'data'` event, matching a /// terminal's one-write delivery. A sequence still incomplete at the end of -/// a drain batch is carried in [`PENDING_ESCAPE`] and finished by the next -/// tick's bytes; if that next tick brings no new bytes the held bytes flush -/// as-is, so a bare Escape keypress is delivered one tick later (a -/// tick-granularity stand-in for Node's `escapeCodeTimeout`). +/// a drain batch is carried in [`PENDING_ESCAPE`] and arms a one-shot +/// `escapeCodeTimeout`. Bytes that complete the sequence cancel that deadline; +/// expiry flushes the held bytes as-is. Event-loop ticks from unrelated work do +/// not advance this state (#9593). fn coalesce_escape_sequences(raw: Vec>) -> Vec> { let mut acc: Vec = PENDING_ESCAPE .lock() .map(|mut p| std::mem::take(&mut *p)) .unwrap_or_default(); if raw.is_empty() { - // No new bytes this tick: a held ESC prefix is a bare Escape (or a - // torn sequence from a very slow terminal) — deliver it byte-wise - // instead of holding it forever. - return acc.into_iter().map(|b| vec![b]).collect(); + if acc.is_empty() { + cancel_escape_timeout(); + return Vec::new(); + } + if escape_timeout_expired() { + return acc.into_iter().map(|b| vec![b]).collect(); + } + // A timer, I/O source, or stale notify can produce arbitrarily many + // pump turns before the deadline. Put the prefix back unchanged. + if let Ok(mut p) = PENDING_ESCAPE.lock() { + *p = acc; + } + return Vec::new(); + } + // Fresh bytes either complete/invalidate the held prefix or replace it + // with a new incomplete one below. In every case the old one-shot is done. + if !acc.is_empty() { + cancel_escape_timeout(); } let mut out: Vec> = Vec::with_capacity(raw.len()); for chunk in raw { @@ -176,6 +235,7 @@ fn coalesce_escape_sequences(raw: Vec>) -> Vec> { if !acc.is_empty() { if let Ok(mut p) = PENDING_ESCAPE.lock() { *p = acc; + arm_escape_timeout(); } } out @@ -196,6 +256,7 @@ pub extern "C" fn js_readline_process_pending() -> i32 { if let Ok(mut p) = PENDING_ESCAPE.lock() { p.clear(); } + cancel_escape_timeout(); Vec::new() } else if STDIN_PAUSED.load(Ordering::Acquire) { // Paused: leave the queue AND any held escape prefix untouched so @@ -434,8 +495,8 @@ pub extern "C" fn js_readline_has_active() -> i32 { let paused = STDIN_PAUSED.load(Ordering::Acquire); let refed = STDIN_REFED.load(Ordering::Acquire); let has_lines = PENDING_LINES.lock().map(|q| !q.is_empty()).unwrap_or(false); - // A held escape prefix counts as pending data: the loop must tick once - // more so the accumulator can flush it as a bare Escape keypress. + // A held escape prefix counts as pending data: the loop must stay alive + // until its explicit escapeCodeTimeout deadline can flush it. let has_data = PENDING_DATA.lock().map(|q| !q.is_empty()).unwrap_or(false) || PENDING_ESCAPE .lock() diff --git a/crates/perry-stdlib/src/readline/test_support.rs b/crates/perry-stdlib/src/readline/test_support.rs index 29c0c2d700..79676d6cea 100644 --- a/crates/perry-stdlib/src/readline/test_support.rs +++ b/crates/perry-stdlib/src/readline/test_support.rs @@ -76,6 +76,7 @@ pub(super) fn reset() -> MutexGuard<'static, ()> { PENDING_LINES.lock().unwrap().clear(); PENDING_DATA.lock().unwrap().clear(); PENDING_ESCAPE.lock().unwrap().clear(); + *PENDING_ESCAPE_DEADLINE.lock().unwrap() = None; EOF_REACHED.store(false, Ordering::Release); READABLE_EOF_NOTIFIED.store(false, Ordering::Release); STDIN_PAUSED.store(false, Ordering::Release); diff --git a/crates/perry/tests/issue_9593_readline_escape_timeout.rs b/crates/perry/tests/issue_9593_readline_escape_timeout.rs new file mode 100644 index 0000000000..b0ba79ae13 --- /dev/null +++ b/crates/perry/tests/issue_9593_readline_escape_timeout.rs @@ -0,0 +1,240 @@ +//! Regression test for #9593: a torn raw-mode ESC prefix must use a real +//! `escapeCodeTimeout`, not "the next event-loop tick" as its clock. +//! +//! The child runs on a fresh PTY so `setRawMode(true)` exercises the production +//! stdin reader. We cover the two loop schedules that exposed the defect: +//! with no other timer the old code delivered bare ESC at the 1 s idle cap, +//! while an unrelated 50 ms interval made it arrive after about 50 ms. Node's +//! default is 500 ms in both cases. + +#![cfg(unix)] + +use std::fs::File; +use std::io::{BufRead, BufReader, Write}; +use std::os::fd::{FromRawFd, RawFd}; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +const SOURCE: &str = r#" +import { emitKeypressEvents } from "node:readline"; + +emitKeypressEvents(process.stdin); +process.stdin.setRawMode(true); + +if (process.env.PERRY_9593_INTERVAL === "1") { + setInterval(() => {}, 50); +} + +process.stdin.on("keypress", (_sequence, key) => { + console.log("KEY:" + key.name); +}); +console.log("READY"); +"#; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile(dir: &Path) -> PathBuf { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, SOURCE).expect("write PTY fixture"); + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + output +} + +fn open_pty() -> (File, File) { + let mut master: RawFd = -1; + let mut slave: RawFd = -1; + let rc = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }; + assert_eq!(rc, 0, "openpty failed: {}", std::io::Error::last_os_error()); + assert!(master >= 0 && slave >= 0); + // SAFETY: openpty returned two fresh, owned descriptors above. + unsafe { (File::from_raw_fd(master), File::from_raw_fd(slave)) } +} + +struct PtyChild { + child: Child, + input: File, + lines: Receiver<(String, Instant)>, +} + +impl PtyChild { + fn spawn(program: &Path, arm_interval: bool) -> Self { + let (master, slave) = open_pty(); + let child_stdin = slave.try_clone().expect("clone PTY slave for stdin"); + let child_stdout = slave.try_clone().expect("clone PTY slave for stdout"); + let mut command = Command::new(program); + command + .stdin(Stdio::from(child_stdin)) + .stdout(Stdio::from(child_stdout)) + .stderr(Stdio::null()) + .env("PERRY_9593_INTERVAL", if arm_interval { "1" } else { "0" }); + // Give the child its own session and make fd 0's PTY its controlling + // terminal. The stdio descriptors have already been installed when + // this hook runs. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = command.spawn().expect("spawn PTY child"); + drop(slave); + + let input = master.try_clone().expect("clone PTY master for writes"); + let (tx, lines) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(master).lines() { + match line { + Ok(line) => { + if tx + .send((line.trim_end_matches('\r').to_string(), Instant::now())) + .is_err() + { + break; + } + } + // Linux returns EIO from a PTY master after the slave closes. + Err(_) => break, + } + } + }); + + let mut session = Self { + child, + input, + lines, + }; + let (ready, _) = session + .recv_line(Duration::from_secs(30)) + .expect("PTY child never printed READY"); + assert_eq!(ready, "READY", "unexpected first PTY line"); + session + } + + fn send(&mut self, bytes: &[u8]) { + self.input.write_all(bytes).expect("write PTY input"); + self.input.flush().expect("flush PTY input"); + } + + fn recv_line(&mut self, timeout: Duration) -> Result<(String, Instant), RecvTimeoutError> { + self.lines.recv_timeout(timeout) + } + + fn recv_key(&mut self, timeout: Duration) -> (String, Instant) { + let started = Instant::now(); + loop { + let remaining = timeout.saturating_sub(started.elapsed()); + let (line, arrived) = self + .recv_line(remaining) + .unwrap_or_else(|_| panic!("no keypress line within {timeout:?}")); + if let Some(name) = line.strip_prefix("KEY:") { + return (name.to_string(), arrived); + } + } + } + + fn assert_no_key_waiting(&mut self) { + while let Ok((line, _)) = self.lines.try_recv() { + assert!( + !line.starts_with("KEY:"), + "escape prefix fired before its completing bytes arrived: {line}" + ); + } + } +} + +impl Drop for PtyChild { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn bare_escape_latency(program: &Path, arm_interval: bool) -> Duration { + let mut child = PtyChild::spawn(program, arm_interval); + // Let the generated loop settle into its wait path before injecting input. + std::thread::sleep(Duration::from_millis(100)); + let sent = Instant::now(); + child.send(b"\x1b"); + let (name, arrived) = child.recv_key(Duration::from_secs(2)); + assert_eq!(name, "escape"); + arrived.duration_since(sent) +} + +#[test] +fn escape_timeout_is_clocked_and_split_arrows_still_coalesce() { + let dir = tempfile::tempdir().expect("create fixture directory"); + let program = compile(dir.path()); + + let idle_latency = bare_escape_latency(&program, false); + let timer_latency = bare_escape_latency(&program, true); + let expected_min = Duration::from_millis(250); + let expected_max = Duration::from_millis(900); + for (schedule, latency) in [("idle", idle_latency), ("50 ms interval", timer_latency)] { + assert!( + (expected_min..=expected_max).contains(&latency), + "bare ESC under {schedule} was delivered after {latency:?}; expected the Node-compatible \ + 500 ms escapeCodeTimeout, independent of the event-loop wait budget" + ); + } + assert!( + idle_latency.abs_diff(timer_latency) < Duration::from_millis(250), + "bare ESC changed with an unrelated timer: idle={idle_latency:?}, interval={timer_latency:?}" + ); + + let mut child = PtyChild::spawn(&program, false); + child.send(b"\x1b"); + std::thread::sleep(Duration::from_millis(200)); + child.assert_no_key_waiting(); + let completed = Instant::now(); + child.send(b"[C"); + let (name, arrived) = child.recv_key(Duration::from_secs(1)); + assert_eq!( + name, "right", + "split arrow was torn into separate keypresses" + ); + assert!( + arrived.duration_since(completed) < Duration::from_millis(250), + "split arrow was not delivered promptly after completion" + ); + + let sent = Instant::now(); + child.send(b"\x1b[C"); + let (name, arrived) = child.recv_key(Duration::from_secs(1)); + assert_eq!(name, "right"); + assert!( + arrived.duration_since(sent) < Duration::from_millis(250), + "complete arrow sequence should be delivered immediately" + ); +}