Skip to content

GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154

Description

@jdalton

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.

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