fix(gc): readline/stdin listener closures are moving-GC roots - #6858
Conversation
The stdin listener lists (DATA/KEYPRESS/READABLE_CALLBACKS) and readline interface callbacks (line/close/question) held JS closure pointers as raw i64 in native storage that no GC scanner ever visited. Sound under the non-moving collector (closures stay live via the JS listener graph, never relocated), but the copying minor RELOCATES a young closure and leaves these native slots pointing at the stale from-space address -> js_readline_process_pending dispatches through the stale pointer -> 'value is not a function'. Register a mutable root scanner (stdlib:readline) so the moving collector rewrites+marks these slots, mirroring stdlib:events (EventEmitter) and the net.Socket listener fix (PerryTS#35). Found via the idle-reclaim campaign: the primary fault backtrace was js_readline_process_pending -> a bare-address callee pointing at a moved NurseryEden closure (obj_type=4).
📝 WalkthroughWalkthroughReadline now registers a moving-GC root scanner for stdin listeners, readline callbacks, per-interface callback state, and pending iteration state. Scanner initialization is guarded by ChangesReadline GC root scanning
Estimated code review effort: 3 (Moderate) | ~20 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-stdlib/src/readline.rs`:
- Around line 197-206: Move the one-time GC scanner registration from the
`.question()`/`.on()` usage paths into `ReadlineInterfaceState::new()`, which is
used by `create_interface_from_options()` and `js_readline_create_interface()`.
Ensure `ensure_gc_scanner_registered()` runs during interface construction
before storing or exposing the NaN-boxed input/output values, while preserving
the existing `Once`-guarded registration behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 4f82ceda-7c8f-4dc6-8943-e6a591dc1708
📒 Files selected for processing (1)
crates/perry-stdlib/src/readline.rs
| static READLINE_GC_REGISTERED: std::sync::Once = std::sync::Once::new(); | ||
|
|
||
| fn ensure_gc_scanner_registered() { | ||
| READLINE_GC_REGISTERED.call_once(|| { | ||
| perry_runtime::gc::gc_register_mutable_root_scanner_named( | ||
| "stdlib:readline", | ||
| scan_readline_roots_mut, | ||
| ); | ||
| }); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Locate where ReadlineInterfaceState is constructed and readline interfaces
# are created, to confirm whether input/output are stored before any of the
# three ensure_gc_scanner_registered() call sites can run.
rg -n 'ReadlineInterfaceState\s*\{' crates/perry-stdlib/src/readline.rs -B15 -A5
rg -n 'fn js_readline_create' crates/perry-stdlib/src/readline.rs -A25
rg -n 'ensure_gc_scanner_registered' crates/perry-stdlib/src/readline.rsRepository: PerryTS/perry
Length of output: 3171
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== readline.rs relevant sections =="
sed -n '70,135p' crates/perry-stdlib/src/readline.rs
sed -n '1120,1185p' crates/perry-stdlib/src/readline.rs
echo "== all ensure_gc_scanner_registered sites in repo =="
rg -n 'ensure_gc_scanner_registered\(\)' .
echo "== read/write/store field patterns for readline state =="
rg -n 'ReadlineInterfaceState|readline_create_interface|create_interface_from_options|input:|output:|\.input|\.output|question_callback|line_callback|listener_callbacks|with_interface|with_interface_mut' crates/perry-stdlib/src/readline.rs
echo "== deterministic call-order probe from source text =="
python3 - <<'PY'
from pathlib import Path
p = Path('crates/perry-stdlib/src/readline.rs')
s = p.read_text()
for name in ['js_readline_create_interface', 'js_readline_question', 'js_readline_on']:
idx = s.find('pub extern "C" fn ' + name)
print(f'{name} first occurrence: line={s[:idx].count(chr(10))+1 if idx!=-1 else None}')
print('ensure_gc_scanner_registered call sites:')
for m in [(s[i+1:i+s[i:].find('\n')+1],i) for i in range(len(s)) if s[i:].startswith('ensure_gc_scanner_registered()')]:
print(f'line={s[:m[1]].count(chr(10))+1}')
PYRepository: PerryTS/perry
Length of output: 19067
Register the readline GC scanner at interface construction.
ensure_gc_scanner_registered() is only called from the public .question() and .on() paths, but ReadlineInterfaceState::new() stores NaN-boxed input/output values during create_interface_from_options()/js_readline_create_interface(). If a readline interface is created without calling .question() or .on() before a moving GC pass, the scanner is still unregistered and those pointers are unscanned. Move the call_once registration into the interface construction path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry-stdlib/src/readline.rs` around lines 197 - 206, Move the
one-time GC scanner registration from the `.question()`/`.on()` usage paths into
`ReadlineInterfaceState::new()`, which is used by
`create_interface_from_options()` and `js_readline_create_interface()`. Ensure
`ensure_gc_scanner_registered()` runs during interface construction before
storing or exposing the NaN-boxed input/output values, while preserving the
existing `Once`-guarded registration behavior.
Problem
The stdin listener lists (
DATA_CALLBACKS/KEYPRESS_CALLBACKS/READABLE_CALLBACKS— theprocess.stdin.on('data'/'keypress'/'readable')lists) and the readline interface callbacks (line/close/question) hold JS closure pointers as rawi64in native storage that no GC scanner ever visited (the source even documented this: "No GC scanner ever visited these").This is sound under the non-moving collector — the closures stay live via the JS listener graph and are never relocated. But under a moving collector (copying minor), the young closure is relocated and these native slots keep the stale from-space pointer, so the next
js_readline_process_pendingdispatches through the stale pointer → reads forwarding-pointer bytes →TypeError: value is not a function.Fix
Register a
stdlib:readlinemutable root scanner (scan_readline_roots_mut) that visits + rewrites all those closure slots when the moving collector runs — mirroring the existingstdlib:events(EventEmitter) scanner and the net.Socket listener fix (#35). Registered at the three store sites (stdin_on_op,js_readline_question,js_readline_on).Same class of latent bug as the EventEmitter/net.Socket cases; readline was simply missed. Found while validating a moving idle-reclaim; the primary-fault backtrace was
js_readline_process_pending → dispatch → [stale bare-address callee pointing at a moved NurseryEden closure].Testing
Validated end-to-end: with the moving idle-reclaim enabled, the reclaim went from crashing 3/3 runs to surviving 3/3. Dispatch clones each callback list and drops the lock before invoking, so no lock/borrow is held across an allocation that could re-enter the scanner. No behavior change under the default non-moving collector.
https://claude.ai/code/session_01GNQBccgAPBmz3txWnAFxpG
Summary by CodeRabbit