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
1 change: 1 addition & 0 deletions changelog.d/9589-idle-reclaim-productivity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fix(gc): the idle-time reclaim now prices a full by what its sweep freed instead of by the old-gen occupancy delta (#9589). `arena::old_gen_in_use_bytes` sums the live old blocks' bump offsets, and a non-moving sweep cannot lower it — dead objects go back to the old-gen free list and the block keeps its offset — so the occupancy delta read ~0 for cycles that freed megabytes and the reducer backed off on every real workload. Measured on the compiled claude-code TUI: two reducer fulls freed 2.37 MB and 0.51 MB with occupancy flat at 93.6 MB and 50 MB already on the free list, and the activity requirement had doubled twice within a minute of idle. The `[gc-idle-reclaim] done` line now also reports the bar it was priced against and `reusable=`, the old-gen free-list residue that only compaction can return to the OS.
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.
3 changes: 3 additions & 0 deletions changelog.d/9640-child-timeout-threads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed timed `child_process.spawn`, `exec`, and `execFile` calls retaining
one sleeping OS thread until the full timeout after a child had already exited.
Timeout workers now wake on child completion while preserving deadline kills.
12 changes: 12 additions & 0 deletions changelog.d/9641-readline-close-pauses-stdin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
**fix(stdlib): pause process.stdin when closing a readline interface (#9594)**

Closing a stdin-backed readline interface now pauses the shared
`process.stdin` stream, matching Node. Previously `rl.close()` fired the
interface's close callback but left Perry's background stdin reader flowing,
so bytes written afterwards still reached `process.stdin` `data` listeners and
could keep a CLI alive unexpectedly.

Interface close is now tracked separately from physical stdin EOF. An explicit
`process.stdin.resume()` can therefore restore delivery after close, a later
real EOF still reaches stdin's `end` / `close` listeners, and constructing a
new readline interface resumes stdin as Node's constructor does.
39 changes: 12 additions & 27 deletions crates/perry-runtime/src/child_process/reactor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,25 +466,6 @@ fn cp_spawn_reader<R: Read + Send + 'static>(handle: u64, mut pipe: R, fd: usize
});
}

/// Spawn the waiter thread that reaps `child` and reports its exit status.
fn cp_spawn_waiter(handle: u64, waiter: CpWaiter) {
std::thread::spawn(move || {
let (code, signal) = waiter();
cp_push_event(CpEvent::Exited {
handle,
code,
signal,
});
});
}

fn cp_spawn_timeout(handle: u64, timeout: Duration, signal: i32) {
std::thread::spawn(move || {
std::thread::sleep(timeout);
cp_push_event(CpEvent::Timeout { handle, signal });
});
}

/// IPC reader (#1933): read newline-delimited JSON from the parent socket and
/// push each line for main-thread parse + `'message'` delivery. For
/// `serialization: 'advanced'` (#2130) the framing is instead a 4-byte
Expand Down Expand Up @@ -773,10 +754,11 @@ fn cp_register_live_child_parts(
for (fd, _, pipe) in extra_pipes {
cp_spawn_reader(handle, pipe, fd);
}
cp_spawn_waiter(handle, waiter);
if let Some(timeout) = timeout {
cp_spawn_timeout(handle, timeout, kill_signal);
}
cp_spawn_waiter(
handle,
waiter,
timeout.map(|timeout| (timeout, kill_signal)),
);
#[cfg(any(unix, windows))]
{
if let Some(sock) = ipc {
Expand Down Expand Up @@ -1390,10 +1372,11 @@ pub(super) fn cp_exec_async(
}
Err(_) => (Some(-1), None),
});
cp_spawn_waiter(handle, waiter);
if let Some(timeout) = timeout {
cp_spawn_timeout(handle, timeout, kill_signal);
}
cp_spawn_waiter(
handle,
waiter,
timeout.map(|timeout| (timeout, kill_signal)),
);
crate::event_pump::js_notify_main_thread();
cp
}
Expand Down Expand Up @@ -1961,9 +1944,11 @@ pub(super) fn cp_live_stdin_queue_callback(handle: u64, callback_bits: u64) -> b
mod integration;
mod kill;
mod stdin_drain;
mod timeout;
#[cfg(all(test, windows))]
#[path = "reactor/windows_kill_tests.rs"]
mod windows_kill_tests;
pub(crate) use integration::*;
pub(crate) use kill::*;
use stdin_drain::*;
use timeout::*;
89 changes: 89 additions & 0 deletions crates/perry-runtime/src/child_process/reactor/timeout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Cancellable child-process timeout waiters.

use super::*;
use std::sync::mpsc::{self, RecvTimeoutError};

/// Spawn the process waiter and, when configured, a timeout waiter. The
/// process waiter owns the sole cancellation sender, so a completed child
/// wakes its timeout waiter instead of leaving an OS thread asleep until the
/// original deadline.
pub(super) fn cp_spawn_waiter(handle: u64, waiter: CpWaiter, timeout: Option<(Duration, i32)>) {
let timeout_cancel = timeout.map(|(timeout, signal)| {
let (cancel, cancelled) = mpsc::channel();
std::thread::spawn(move || {
if matches!(
cancelled.recv_timeout(timeout),
Err(RecvTimeoutError::Timeout)
) {
cp_push_event(CpEvent::Timeout { handle, signal });
}
});
cancel
});

std::thread::spawn(move || {
let (code, signal) = waiter();
if let Some(cancel) = timeout_cancel {
let _ = cancel.send(());
}
cp_push_event(CpEvent::Exited {
handle,
code,
signal,
});
});
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Barrier};
use std::time::Instant;

#[test]
fn completed_children_wake_fifty_long_timeout_waiters() {
const CHILDREN: usize = 50;
let ready = Arc::new(Barrier::new(CHILDREN + 1));
let mut cancels = Vec::with_capacity(CHILDREN);
let mut completions = Vec::with_capacity(CHILDREN);

for _ in 0..CHILDREN {
let (cancel, cancelled) = mpsc::channel();
let (completed, completion) = mpsc::channel();
let ready = Arc::clone(&ready);
std::thread::spawn(move || {
ready.wait();
let timed_out = matches!(
cancelled.recv_timeout(Duration::from_secs(60)),
Err(RecvTimeoutError::Timeout)
);
let _ = completed.send(timed_out);
});
cancels.push(cancel);
completions.push(completion);
}

ready.wait();
let started = Instant::now();
for cancel in cancels {
cancel.send(()).expect("timeout waiter should be alive");
}
for completion in completions {
assert_eq!(
completion.recv_timeout(Duration::from_secs(1)),
Ok(false),
"timeout waiter did not stop after child completion"
);
}
assert!(started.elapsed() < Duration::from_secs(1));
}

#[test]
fn elapsed_deadline_still_selects_the_timeout_arm() {
let (_cancel, cancelled) = mpsc::channel::<()>();
assert!(matches!(
cancelled.recv_timeout(Duration::from_millis(10)),
Err(RecvTimeoutError::Timeout)
));
}
}
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() {
// `PERRY_GC_CENSUS`: one relaxed atomic load; services a pending SIGUSR2
Expand Down Expand Up @@ -574,11 +583,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
45 changes: 37 additions & 8 deletions crates/perry-runtime/src/gc/idle_reclaim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,30 @@
//! 3. **Rate.** At least [`IDLE_RECLAIM_MIN_INTERVAL_MS`] since the reducer's
//! own last full started.
//!
//! **Productivity backoff.** A full that lowered old-gen occupancy by less than
//! [`IDLE_RECLAIM_PRODUCTIVE_PCT`] percent of what it started with, or by less
//! than [`IDLE_RECLAIM_PRODUCTIVE_MIN_BYTES`], doubles the activity requirement
//! **Productivity backoff.** A full whose SWEEP freed less than
//! [`IDLE_RECLAIM_PRODUCTIVE_PCT`] percent of the old-gen occupancy it started
//! with, or less than [`IDLE_RECLAIM_PRODUCTIVE_MIN_BYTES`], doubles the
//! activity requirement
//! up to `2^`[`IDLE_RECLAIM_MAX_BACKOFF_SHIFT`] collections; a productive full
//! resets it to one. This is what keeps a RETAINING idle heap (the TUI's
//! six-second young-collection sawtooth) from paying a whole-heap mark per
//! minor: after a few unproductive attempts the reducer runs once per ~32
//! collections, and any burst — which produces many collections in a row —
//! re-arms it promptly.
//!
//! The price is the cycle's own freed-bytes count and NOT the change in
//! `arena::old_gen_in_use_bytes`, which is the sum of the live old blocks'
//! bump offsets: a non-moving sweep hands dead objects to the old-gen free
//! list and the block keeps its offset, so only a whole-block release moves
//! that number. Pricing on it scored every full on a real workload
//! unproductive — measured on the compiled claude-code TUI, two reducer fulls
//! freed 2.37 MB and 0.51 MB while occupancy stood still at 93.6 MB with
//! 50 MB already on the old-gen free list, and the reducer backed off to one
//! attempt per four collections inside a minute of idle. Returning that free
//! list to the OS needs compaction, which a budgeted (non-moving) cycle
//! cannot do; the `reusable=` field on the `[gc-idle-reclaim] done` line
//! reports how much is waiting for it.
//!
//! **Work cap.** While a cycle is open the hook never spends more than
//! [`IDLE_RECLAIM_MAX_WORK_MS_PER_SECOND`] of any wall-clock second stepping
//! it; past that it lets the loop park for the rest of its budget. A healthy
Expand Down Expand Up @@ -97,9 +111,9 @@ pub const IDLE_RECLAIM_QUIET_MS: u64 = 3_000;
/// Minimum spacing between two reducer-started fulls.
pub const IDLE_RECLAIM_MIN_INTERVAL_MS: u64 = 10_000;

/// A reducer full counts as productive when it lowered old-gen occupancy by at
/// least this many bytes AND by at least [`IDLE_RECLAIM_PRODUCTIVE_PCT`] percent
/// of the occupancy it started with.
/// A reducer full counts as productive when its sweep freed at least this many
/// bytes AND at least [`IDLE_RECLAIM_PRODUCTIVE_PCT`] percent of the old-gen
/// occupancy it started with.
pub const IDLE_RECLAIM_PRODUCTIVE_MIN_BYTES: usize = 4 * 1024 * 1024;

/// See [`IDLE_RECLAIM_PRODUCTIVE_MIN_BYTES`].
Expand Down Expand Up @@ -365,7 +379,14 @@ pub(super) fn note_cycle_completed(freed_bytes: u64) {
let reclaimed = before.saturating_sub(after);
OLD_RECLAIMED_BYTES.fetch_add(reclaimed as u64, Ordering::Relaxed);
let bar = IDLE_RECLAIM_PRODUCTIVE_MIN_BYTES.max(before / 100 * IDLE_RECLAIM_PRODUCTIVE_PCT);
let productive = reclaimed >= bar;
// Price the full by what its sweep freed. `old_gen_occupancy` sums the
// live blocks' bump offsets, which a non-moving sweep cannot lower —
// it returns objects to the old-gen free list and the block keeps its
// offset — so the occupancy delta reads ~0 for a cycle that freed
// megabytes, and every real full scored unproductive (module docs).
// The delta stays in the counters and the trace as the whole-block
// release signal it actually is.
let productive = freed_bytes >= bar as u64;
if productive {
PRODUCTIVE.fetch_add(1, Ordering::Relaxed);
st.backoff_shift = 0;
Expand All @@ -379,7 +400,8 @@ pub(super) fn note_cycle_completed(freed_bytes: u64) {
st.last_seen_external = external;
if gc_diag_enabled() {
eprintln!(
"[gc-idle-reclaim] done old_in_use={before}->{after} reclaimed_old={reclaimed} freed={freed_bytes} productive={productive} backoff_shift={}",
"[gc-idle-reclaim] done old_in_use={before}->{after} reclaimed_old={reclaimed} freed={freed_bytes} bar={bar} reusable={} productive={productive} backoff_shift={}",
old_free_bytes(),
st.backoff_shift
);
}
Expand Down Expand Up @@ -526,6 +548,13 @@ pub(super) mod test_support {
STATE.with(|s| *s.borrow_mut() = IdleReclaimState::default());
}

/// Pin the old-gen occupancy the in-flight full started with, so a
/// completion can be priced against a known `before` without reproducing
/// the allocation pattern that would produce one.
pub(crate) fn set_old_in_use_at_start(bytes: usize) {
STATE.with(|s| s.borrow_mut().old_in_use_at_start = bytes);
}

pub(crate) fn thread_attempts() -> u64 {
STATE.with(|s| s.borrow().attempts)
}
Expand Down
Loading
Loading