Skip to content

Event/listener dispatch paths reuse unrooted JSValue copies across user callbacks that can move the heap #10600

Description

@proggeramlug

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.rsstream_emit_event reuses raw this, arr and
    arg across listener iterations, and passes an unrooted callback value to js_native_call_value.
  • domain.rsemit_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.rsemit_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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressionpackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions