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.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.

Activity

  1. added
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 18, 2026
  2. proggeramlug commented on Sep 18, 2026

    @proggeramlug
    ContributorAuthor

    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 with PERRY_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 a class X extends EventEmitter subclass actually dispatches through (the native-base
    subclass-install machinery), not perry-stdlib/src/events.rs's handle-table EventEmitterHandle. That
    handle path serves un-subclassed new EventEmitter(), and only when the perry-ext-events well-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=4 is GC_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 default PERRY_GC_PROTECT_FROMSPACE_DEPTH of 4 sufficed — unlike #7154, this
    value goes stale and is re-used within the same emit loop.

    The asymmetry in events.rs is 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 a RuntimeHandleScope, roots args via
      root_nanbox_f64_slice, roots the freshly built array via root_raw_mut_ptr, calls through rooted handles.
    • sync branch (fallthrough): computes receiver/callback_value from 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 EventEmitter whose 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.

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