Summary
A young-generation minor collection can reclaim or relocate a shared function object while other objects still hold a reference to it. When something later calls that reference, the program dies with TypeError: value is not a function (lodash reports the same thing as "Expected a function").
The reference that gets lost always lives in the second field of an object, and the collector never sees it because the old-to-young edge pointing at it is missing from the cycle's remembered set. This reproduces 100% of the time with PERRY_GC_FORCE_EVACUATE=1, and setting PERRY_GC_MOVING_LOOP_POLLS=0 makes it go away entirely, which points at the evacuating young-generation scavenge that became the default in #7019.
This is the same family of failure as #6831, but it happens at the object-field and remembered-set layer rather than the array-layout layer.
How to reproduce
Run any affected Node CLI under a forced evacuation:
PERRY_FORCE_WELL_KNOWN=iovalkey PERRY_GC_FORCE_EVACUATE=1 <binary> --help
It exits 1 with TypeError: value is not a function every time. Setting PERRY_GC_MOVING_LOOP_POLLS=0 produces a clean run, which is the kill switch that isolates this to the moving collector.
Adding PERRY_GC_FROMSPACE_SCAN=1 prints the dangling references described below.
What the diagnostic scan shows
After the array fixes in #7138 removed their false positives, roughly 36 genuine offenders remain per cycle. The rest are type=3 string entries, which are false positives from the conservative scan. The real ones are type=2 objects and type=4 closures:
owner=… type=2 space=Old +40 nanbox -> <young> DANGLING dirty_now=true ever_dirty=true marked=false
owner=… type=2 space=Survivor0 +40 nanbox -> <same young> DANGLING dirty_now=false ever_dirty=false marked=true
The +40 offset is field[1]. An ObjectHeader occupies 32 bytes — object_type, class_id, parent_class_id and field_count take 16, the keys array takes 8, and meta takes 8 — so field[1] lands at offset 40.
All of those offenders point at a single shared young object, referenced from field[1] of about 30 different objects. That shared object is a constructor function, which is why the crash surfaces as "value is not a function".
An allocation-site backtrace shows the holders are created in js_object_set_field_by_name, called from proxy::create_or_update_receiver_property, called from zod's $constructor in node_modules/zod/src/v4/core/core.ts.
Two details rule out the obvious explanations. layout_covers_slot is true with the layout state at SIDE_MASK, so the traced layout does visit field[1] and this is not a layout-coverage gap. And the snapshot counters read in_snapshot = 0, not_in_snapshot = 43, meaning none of these old-to-young field[1] edges are in the cycle's remembered-set snapshot, so the minor never scans them at all.
Where the edge is being lost
The old-to-young field[1] edge is dropped across a cycle boundary and never recovered. Four places are worth checking, in rough order of suspicion.
remembered_set_clear in crates/perry-runtime/src/gc/copying.rs around line 1207 clears DIRTY_OLD_PAGES on every cycle.
restore_surviving_dirty_coverage in crates/perry-runtime/src/gc/verify.rs around line 167 only rescans pages that were in the pre-cycle snapshot. Once a page is dropped it is never re-remembered, so the loss cascades. This is the area #5029 covers.
The promotion path is also a candidate: rebuild_evacuated_old_to_young_remembered_set and remember_evacuated_old_copy_young_slots, both in verify.rs, gate on pointer_in_old_gen and only process moved_headers.
Finally, part of the population has ever_dirty=false, meaning the write barrier never fired for that store at all. That suggests a barrier skip on old-object field stores in js_object_set_field_by_name or runtime_store_jsvalue_slot, which is a different bug from the snapshot drop affecting the rest.
Suggested next diagnostic step
The ~36 offenders appear to be two distinct bugs sharing one symptom, so the next step is to split them. Re-run with a global allocation-site tag plus a per-slot barrier trap on js_object_set_field_by_name and runtime_store_jsvalue_slot, restricted to old-object field stores whose child is young.
Offenders with ever_dirty=false are barrier skips, and the fix is to route that store through the write barrier. Offenders with ever_dirty=true are snapshot drops, and the fix belongs in restore_surviving_dirty_coverage or remembered_set_clear so that an old-to-young edge to a still-live shared young object survives the cycle boundary.
One approach that does not work
Widening the collector to trace beyond length or into uninitialized capacity is not a fix. It has been tried and it segfaults on the uninitialized slack in js_array_alloc. The correct layer for this fix is the remembered set and the write barrier.
Relationship to #7138
#7138 is a separate change and does not fix this. It landed two array soundness fixes: layout_note_slot restoring SIDE_MASK, which took within-length array-to-young losses from 202 to 0, and HOLE-initialisation of the slack in js_array_alloc and js_array_grow, which removed roughly 4000 uninitialized-capacity false positives per cycle from PERRY_GC_FROMSPACE_SCAN.
Those fixes matter here only because clearing the false positives is what made the ~36 genuine offenders above visible.
Summary
A young-generation minor collection can reclaim or relocate a shared function object while other objects still hold a reference to it. When something later calls that reference, the program dies with
TypeError: value is not a function(lodash reports the same thing as "Expected a function").The reference that gets lost always lives in the second field of an object, and the collector never sees it because the old-to-young edge pointing at it is missing from the cycle's remembered set. This reproduces 100% of the time with
PERRY_GC_FORCE_EVACUATE=1, and settingPERRY_GC_MOVING_LOOP_POLLS=0makes it go away entirely, which points at the evacuating young-generation scavenge that became the default in #7019.This is the same family of failure as #6831, but it happens at the object-field and remembered-set layer rather than the array-layout layer.
How to reproduce
Run any affected Node CLI under a forced evacuation:
It exits 1 with
TypeError: value is not a functionevery time. SettingPERRY_GC_MOVING_LOOP_POLLS=0produces a clean run, which is the kill switch that isolates this to the moving collector.Adding
PERRY_GC_FROMSPACE_SCAN=1prints the dangling references described below.What the diagnostic scan shows
After the array fixes in #7138 removed their false positives, roughly 36 genuine offenders remain per cycle. The rest are
type=3string entries, which are false positives from the conservative scan. The real ones aretype=2objects andtype=4closures:The
+40offset is field[1]. AnObjectHeaderoccupies 32 bytes — object_type, class_id, parent_class_id and field_count take 16, the keys array takes 8, and meta takes 8 — so field[1] lands at offset 40.All of those offenders point at a single shared young object, referenced from field[1] of about 30 different objects. That shared object is a constructor function, which is why the crash surfaces as "value is not a function".
An allocation-site backtrace shows the holders are created in
js_object_set_field_by_name, called fromproxy::create_or_update_receiver_property, called from zod's$constructorinnode_modules/zod/src/v4/core/core.ts.Two details rule out the obvious explanations.
layout_covers_slotistruewith the layout state atSIDE_MASK, so the traced layout does visit field[1] and this is not a layout-coverage gap. And the snapshot counters readin_snapshot = 0, not_in_snapshot = 43, meaning none of these old-to-young field[1] edges are in the cycle's remembered-set snapshot, so the minor never scans them at all.Where the edge is being lost
The old-to-young field[1] edge is dropped across a cycle boundary and never recovered. Four places are worth checking, in rough order of suspicion.
remembered_set_clearincrates/perry-runtime/src/gc/copying.rsaround line 1207 clearsDIRTY_OLD_PAGESon every cycle.restore_surviving_dirty_coverageincrates/perry-runtime/src/gc/verify.rsaround line 167 only rescans pages that were in the pre-cycle snapshot. Once a page is dropped it is never re-remembered, so the loss cascades. This is the area #5029 covers.The promotion path is also a candidate:
rebuild_evacuated_old_to_young_remembered_setandremember_evacuated_old_copy_young_slots, both inverify.rs, gate onpointer_in_old_genand only processmoved_headers.Finally, part of the population has
ever_dirty=false, meaning the write barrier never fired for that store at all. That suggests a barrier skip on old-object field stores injs_object_set_field_by_nameorruntime_store_jsvalue_slot, which is a different bug from the snapshot drop affecting the rest.Suggested next diagnostic step
The ~36 offenders appear to be two distinct bugs sharing one symptom, so the next step is to split them. Re-run with a global allocation-site tag plus a per-slot barrier trap on
js_object_set_field_by_nameandruntime_store_jsvalue_slot, restricted to old-object field stores whose child is young.Offenders with
ever_dirty=falseare barrier skips, and the fix is to route that store through the write barrier. Offenders withever_dirty=trueare snapshot drops, and the fix belongs inrestore_surviving_dirty_coverageorremembered_set_clearso that an old-to-young edge to a still-live shared young object survives the cycle boundary.One approach that does not work
Widening the collector to trace beyond
lengthor into uninitialized capacity is not a fix. It has been tried and it segfaults on the uninitialized slack injs_array_alloc. The correct layer for this fix is the remembered set and the write barrier.Relationship to #7138
#7138 is a separate change and does not fix this. It landed two array soundness fixes:
layout_note_slotrestoringSIDE_MASK, which took within-length array-to-young losses from 202 to 0, and HOLE-initialisation of the slack injs_array_allocandjs_array_grow, which removed roughly 4000 uninitialized-capacity false positives per cycle fromPERRY_GC_FROMSPACE_SCAN.Those fixes matter here only because clearing the false positives is what made the ~36 genuine offenders above visible.