Skip to content

gc: dispatch_uint8_buffer_method holds the reduce accumulator and map output in unrooted locals across js_closure_call #8179

Description

@proggeramlug

Summary

dispatch_uint8_buffer_method holds its reduce accumulator and its map output array in unrooted Rust locals across js_closure_call*. A user callback can allocate, and therefore collect, so both are pre-move addresses by the time the next iteration writes through them.

This is pre-existing and reachable from the statically-typed path today — it is not introduced by #8173, which routes the dynamically-typed receiver into this same dispatcher. #8173 roots the receiver and the callback at the boundary but deliberately did not restructure the loop bodies, which is the right scope call but leaves this open.

Why it matters

This is the #7154 family: a GC value live across a collection point with no root. Its defining property is that it is invisible at collection time — there is nothing for the collector to find — and it surfaces cycles later, in a different function, typically as TypeError: value is not a function or an incoherent-header abort.

Two aggravating factors specific to this site:

  • A reduce accumulator that is a heap value (string, object, array) is exactly the shape that breaks. A numeric accumulator will never reproduce it, so a naive test passes.
  • The map output array is written on every iteration, so a move partway through leaves earlier writes in the old copy.

Instruments

scripts/gc_root_dominance_check.py reads emitted LLVM IR and is structurally blind to this — the values live in Rust runtime locals, not in codegen output. Per CLAUDE.md, the runtime instruments are the only detector: PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1, with PERRY_GC_ZEAL_ALLOC_KB=0 for a small fixture, and PERRY_GC_PROTECT_FROMSPACE_DEPTH raised if the value survives many collections between its last valid read and its stale use.

A fixture wants: a Buffer-backed Uint8Array, a reduce whose accumulator is a string or object, and a callback that allocates enough to force a copying minor mid-loop.

Suggested fix

RuntimeHandleScope with across_mut around each js_closure_call*, re-reading the accumulator and the output pointer after every call — the same pattern #8131 and #8084 applied elsewhere. Note the raw-handle ratchet distinguishes reload shape (across_*) from argument position (with_*_ptr); see scripts/raw_handle_debt.py and #7341.

Found while fixing #8137 (#8173); filed separately because it is a different defect with a different instrument.

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