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_event reuses raw this, arr and
arg across listener iterations, and passes an unrooted callback value to js_native_call_value.
domain.rs — emit_domain_event copies listeners into an unscanned Vec<f64> and passes an unrooted
argument slice to each listener.
events.rs — the synchronous branch passes unrooted callback_value and args to
js_native_call_value, which can allocate before callback entry. (The asynchronous branch already roots its
callback, receiver, argument array and arguments via js_async_resource_run_in_async_scope — so the two
branches disagree, which is itself the tell.)
events/warnings.rs — emit_warning and warning are not rooted before generic dispatch. The process
receiver stays live via IMPLICIT_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.
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.