Repository navigation
Event/listener dispatch paths reuse unrooted JSValue copies across user callbacks that can move the heap #10600
Description
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 user
code 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 function rather than at the collection itself.
Sites named
crates/perry-stdlib/src/worker_threads/worker_surface.rs—stream_emit_eventreuses rawthis,arrand
argacross 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 unrooted
argument slice to each listener.events.rs— the synchronous branch passes unrootedcallback_valueandargsto
js_native_call_value, which can allocate before callback entry. (The asynchronous branch already roots its
callback, receiver, argument array and arguments viajs_async_resource_run_in_async_scope— so the two
branches disagree, which is itself the tell.)events/warnings.rs—emit_warningandwarningare not rooted before generic dispatch. Theprocess
receiver stays live viaIMPLICIT_THIS, but the copied callback and argument values are not rewritten when
GC moves them.
Fix shape
Root each value through RuntimeHandleScope and re-read the current value before every dispatch, rather
than reusing the pre-call copy. The async branch of events.rs is 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 the
stale 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=1 prints a [gc-fromspace-protect] retired_set=#N line, and
raise PERRY_GC_PROTECT_FROMSPACE_DEPTH if 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 are
runtime-side Rust locals.
Activity
proggeramlug commentedon 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 300times: segfaults 3/3 runs. Differential control on the identical binary with
PERRY_GEN_GC=0(fullmark-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 isthe path a
class X extends EventEmittersubclass actually dispatches through (the native-basesubclass-install machinery), not
perry-stdlib/src/events.rs's handle-tableEventEmitterHandle. Thathandle path serves un-subclassed
new EventEmitter(), and only when theperry-ext-eventswell-known bindingisn't linked — which it always is in a standard build.
Instrumented evidence
obj_type=4isGC_TYPE_CLOSURE(gc/types.rs:21). A symbolized backtrace puts the fault atjs_native_call_value←call_listener_args←emit_stream_event: the stale value is exactly the listenerclosure 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, thisvalue 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_resource_handle != 0): opens aRuntimeHandleScope, rootsargsviaroot_nanbox_f64_slice, roots the freshly built array viaroot_raw_mut_ptr, calls through rooted handles.receiver/callback_valuefrom raw params and callsjs_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 ordinaryshape of Node event-driven code, so this is not an exotic corner.
Fix in progress across all five sites.
test: add gap test for event/listener dispatch GC rooting (#10600)
test: add gap test for event/listener dispatch GC rooting (#10600)