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.
Summary
dispatch_uint8_buffer_methodholds itsreduceaccumulator and itsmapoutput array in unrooted Rust locals acrossjs_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 functionor an incoherent-header abort.Two aggravating factors specific to this site:
reduceaccumulator 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.mapoutput array is written on every iteration, so a move partway through leaves earlier writes in the old copy.Instruments
scripts/gc_root_dominance_check.pyreads 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, withPERRY_GC_ZEAL_ALLOC_KB=0for a small fixture, andPERRY_GC_PROTECT_FROMSPACE_DEPTHraised if the value survives many collections between its last valid read and its stale use.A fixture wants: a Buffer-backed
Uint8Array, areducewhose accumulator is a string or object, and a callback that allocates enough to force a copying minor mid-loop.Suggested fix
RuntimeHandleScopewithacross_mutaround eachjs_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); seescripts/raw_handle_debt.pyand #7341.Found while fixing #8137 (#8173); filed separately because it is a different defect with a different instrument.