Repository navigation
Event/listener dispatch paths reuse unrooted JSValue copies across user callbacks that can move the heap #10600
Description
Activity
- addedpackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 18, 2026 CONFIRMED — and the primary site is a fifth one this issue did not name.
This was filed as a code-reading finding. It is now reproduced as a segfault on a plain default build with no
GC environment knobs set at all.Repro
class Bus extends EventEmitter {}with 3 listeners, the second allocating ~6000 small objects, dispatched 300
times: segfaults 3/3 runs. Differential control on the identical binary withPERRY_GEN_GC=0(full
mark-sweep, non-moving): succeeds 3/3, output byte-identical to Node. That isolates it to the moving
collector.The real site
crates/perry-runtime/src/node_stream_event_emitter.rs—emit_stream_event/call_listener_args. This is
the path aclass X extends EventEmittersubclass actually dispatches through (the native-base
subclass-install machinery), notperry-stdlib/src/events.rs's handle-tableEventEmitterHandle. That
handle path serves un-subclassednew EventEmitter(), and only when theperry-ext-eventswell-known binding
isn't linked — which it always is in a standard build.Instrumented evidence
[gc-fromspace-protect] FAULT: signal 11 at 0x529372bcc3c This address is RETIRED FROM-SPACE. The evacuating minor moved or freed the object here and the holder kept the pre-collection address. block=0x52937240000 +511036 retired_bytes=1048576 retired_by_minor=#0 last-known object: user_ptr=0x529372bcc30 obj_type=4 size=24 The faulting instruction IS the stale use.obj_type=4isGC_TYPE_CLOSURE(gc/types.rs:21). A symbolized backtrace puts the fault at
js_native_call_value←call_listener_args←emit_stream_event: the stale value is exactly the listener
closure pointer the dispatch loop reuses from its unrooted snapshot. It faulted on the first run at
retired_by_minor=#0, so the defaultPERRY_GC_PROTECT_FROMSPACE_DEPTHof 4 sufficed — unlike #7154, this
value goes stale and is re-used within the same emit loop.The asymmetry in
events.rsis real (confirmed by direct read, no build needed)call_emitter_listener,perry-stdlib/src/events.rs:948-977:- async branch (
async_resource_handle != 0): opens aRuntimeHandleScope, rootsargsvia
root_nanbox_f64_slice, roots the freshly built array viaroot_raw_mut_ptr, calls through rooted handles. - sync branch (fallthrough): computes
receiver/callback_valuefrom raw params and calls
js_native_call_value(callback_value, args.as_ptr(), args.len())directly — no scope, no rooting.
Same function, same requirement, adjacent branches disagreeing.
Severity
Any
class X extends EventEmitterwhose listener allocates can crash on a default build. That is the ordinary
shape of Node event-driven code, so this is not an exotic corner.Fix in progress across all five sites.
- async branch (
- added a commit that references this issue
on Sep 18, 2026 - added a commit that references this issue
on Sep 19, 2026
Raised by automated review on PR #10564 and triaged as valid by the agent working that PR, which left it
unfixed because it is unrelated to that PR's subject (exception-transport savepoints). Filing it so it does
not disappear when #10564 merges. Not independently reproduced — it is a code-reading finding, and the
first step is to confirm it with a moving-collection stress run.
Claim
Several event/listener dispatch paths copy JSValues into local variables or plain
Vec<f64>, then call usercode that can allocate and trigger a moving collection, then reuse those copies for the next listener.
The copies are not GC roots, so the collector never rewrites them, and a later dispatch can receive a retired
address. This is the shape CLAUDE.md describes: a value that is live across a collection point but not rooted,
surfacing cycles later as
TypeError: value is not a functionrather than at the collection itself.Sites named
crates/perry-stdlib/src/worker_threads/worker_surface.rs—stream_emit_eventreuses rawthis,arrandargacross listener iterations, and passes an unrooted callback value tojs_native_call_value.domain.rs—emit_domain_eventcopies listeners into an unscannedVec<f64>and passes an unrootedargument slice to each listener.
events.rs— the synchronous branch passes unrootedcallback_valueandargstojs_native_call_value, which can allocate before callback entry. (The asynchronous branch already roots itscallback, receiver, argument array and arguments via
js_async_resource_run_in_async_scope— so the twobranches disagree, which is itself the tell.)
events/warnings.rs—emit_warningandwarningare not rooted before generic dispatch. Theprocessreceiver stays live via
IMPLICIT_THIS, but the copied callback and argument values are not rewritten whenGC moves them.
Fix shape
Root each value through
RuntimeHandleScopeand re-read the current value before every dispatch, ratherthan reusing the pre-call copy. The async branch of
events.rsis the worked example of the correct pattern.How to confirm before fixing
Per CLAUDE.md's rooting-bug instruments, a workload that emits to several listeners where an early listener
allocates heavily, run under
PERRY_GC_SCHEDULE_SEED=<u64> PERRY_GC_PROTECT_FROMSPACE=1, should fault at thestale deref with the address and the retiring minor named. Note the caveat: a run with zero copying minors
protects nothing — check that
PERRY_GC_DIAG=1prints a[gc-fromspace-protect] retired_set=#Nline, andraise
PERRY_GC_PROTECT_FROMSPACE_DEPTHif it does not fault. Also note the static checker(
scripts/gc_root_dominance_check.py) cannot see this class at all — it reads emitted LLVM IR, and these areruntime-side Rust locals.