Skip to content
Closed
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
11 changes: 11 additions & 0 deletions changelog.d/9593-readline-escape-timeout.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 26 additions & 21 deletions crates/perry-runtime/src/event_pump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
// ============================================================================
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions crates/perry-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
8 changes: 5 additions & 3 deletions crates/perry-stdlib/src/common/async_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions crates/perry-stdlib/src/readline/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -122,6 +123,12 @@ static PENDING_DATA: Mutex<Vec<Vec<u8>>> = 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<Vec<u8>> = 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<Option<Instant>> = 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
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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};

// ---------------------------------------------------------------------------
Expand Down
33 changes: 28 additions & 5 deletions crates/perry-stdlib/src/readline/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand All @@ -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]
Expand Down
81 changes: 71 additions & 10 deletions crates/perry-stdlib/src/readline/pump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>>) -> Vec<Vec<u8>> {
let mut acc: Vec<u8> = 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<u8>> = Vec::with_capacity(raw.len());
for chunk in raw {
Expand Down Expand Up @@ -176,6 +235,7 @@ fn coalesce_escape_sequences(raw: Vec<Vec<u8>>) -> Vec<Vec<u8>> {
if !acc.is_empty() {
if let Ok(mut p) = PENDING_ESCAPE.lock() {
*p = acc;
arm_escape_timeout();
}
}
out
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions crates/perry-stdlib/src/readline/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading