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" + ); +}