Skip to content

GC: the catch (e) parameter slot is reserved but never bound — the exception has no root across the catch body #7209

Closed

Description

@proggeramlug

Summary

The catch (e) parameter has a shadow slot reserved for it and never bound, so the exception object — almost always a heap Error — has no root at all for the whole catch body.

This is the sharpest single finding of the #7202 bare-alloca enumeration (PR #7207), because the frame is already sized for it: the fix is a bind, not a reservation.

Evidence

  • collectors/pointer_locals.rs:983-990 does assign_slot for the catch parameter (typed Any), so js_shadow_frame_enter's count includes it.
  • stmt/try_stmt.rs:114 allocates the parameter slot with alloca_entry and never emits js_shadow_slot_bind, so active[idx] stays false forever and the collector never dereferences it.
  • stmt/try_stmt.rs:107 calls js_clear_exception() — dropping the runtime's own reference — before lower_stmts(&clause.body) runs arbitrary allocating user code.

So between the clear and the first re-read, the only thing referring to the exception is an alloca nothing scans. Under a non-moving collector the conservative native-stack scan finds it; with PERRY_CONSERVATIVE_STACK_SCAN=off (the cons_scan_off matrix arm, and the direction the collector is moving) it is a live use-after-free, and under an evacuating minor it is a stale address.

Fix shape

Bind the existing reserved index — js_shadow_slot_bind(idx, catch_slot) right after the store — or route it through expr::scalar_slot_root::root_entry_alloca (generalized in #7207), whose contract also covers the undefined seed.

A reproducer: try { throw new Error(...) } catch (e) { churn(); use(e.message) } in a loop, under PERRY_GC_MOVING_LOOP_POLLS=1 with PERRY_CONSERVATIVE_STACK_SCAN=off.

Refs #7202, #7154, #7161, #7207, #6968.

Activity

  1. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Reproduced and fixed in #7216.

    Red at origin/main 1679e22b4, compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1: bad churn 0 message 8 field 8 of 400 iterations, deterministic 3/3 — both e.message and a field stored on the error read wrong after an allocating call in the catch body. Green 5/5 with the fix, 3/3 on the evacuating arm, 3/3 under the shipped default, byte-exact against Node 26.5.1.

    The fix is emit_shadow_slot_bind_for_local, which binds the index collect_pointer_typed_locals had already reserved — so the frame does not grow; it simply stops being sized for a root that did not exist. Emitted after the store rather than hoisted to entry setup, because on the non-throwing path the alloca is never written and an entry-hoisted bind would make the slot active over uninitialized stack bytes.

  2. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Re-verified on main = d4ac65f21. Fixed — closing.

    Fixed by #7216 (dd83c7e92) with emit_shadow_slot_bind_for_local, which reuses the already-reserved index rather than growing the frame, emitted after the store rather than hoisted to entry setup (on the non-throwing path the alloca is never written, so an entry-hoisted bind would make the slot active over uninitialized stack bytes).

    Your framing was right and is worth preserving: this was sharper than an ordinary missing root because js_clear_exception() drops the runtime's own reference two lines earlier, so the exception was reachable only through unrooted stack memory while arbitrary user code ran — a precise-roots collection could sweep it, not merely relocate it.

    Evidence

    test-files/test_gap_gc_catch_param_rooting.ts (shipped with #7216), compiled with no compile-time GC env, run on the allocation-point arm:

    PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
      ./test_gap_gc_catch_param_rooting
    
    result
    at 1679e22b4 message 8 field 8 of 400, 3/3 deterministic
    d4ac65f21 bad churn 0 message 0 field 0, 10/10, byte-exact vs node 26.5.1

    Movement confirmed: 6 copying minors, 1 614 objects copied. Note this is the one witness that can also fail under a non-moving precise-roots collection (the sweep case), which is why it belongs on cons_scan_off and not only on the requires=move arms.

    Part of the sweep in #7262.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions