Skip to content

GC: for-in's deferred shadow set records prototype levels as unrooted raw pointers #9869

Description

@proggeramlug

Summary

for-in's deferred shadow set records each walked prototype level as a plain
NaN-boxed f64 in VisitedLevels, then dereferences it later. Between the
record and the read the walk crosses an allocating call and a call that can run
arbitrary user JS, so a collection in that window leaves the recorded word
pointing at a moved object.

The window

In crates/perry-runtime/src/object/field_get_set/enumeration.rs,
for_in_keys_with:

if shadow_live {
    mark_own_names(current, &mut seen, &mut scratch, diag);
} else {
    visited.push(current);          // records a NaN-boxed heap pointer as f64
}
current = super::super::object_ops::js_object_get_prototype_of(current);

and at the first level >= 1 that has an enumerable key to filter:

build_shadow_set(visited.as_slice(), &mut seen, &mut scratch, diag);

which is

for recv in visited.iter() {
    mark_own_names(*recv, seen, scratch, diag);   // -> js_object_get_own_property_names(recv)
}

Between visited.push(current) at level N and that read, the loop executes:

  • js_object_get_prototype_of(current) — a Proxy getPrototypeOf trap is
    arbitrary user JS, which can allocate and collect;
  • js_object_keys_value(current) at level N+1 — allocates the key array.

Either can move the object recorded at level N. VisitedLevels is a plain
Rust struct (inline: [f64; 8] plus a Vec<f64> spill), so nothing rewrites
it, and it is not reachable from any registered root scanner. The stale word is
then decoded as a heap pointer by js_object_get_own_property_names.

This is the "unrooted cache of a raw heap pointer" shape from
docs/src/internals/gc-rooting-invariant.md — invisible to
scripts/gc_root_dominance_check.py, which reads emitted LLVM IR and cannot
see a Rust-side side table.

Reachability

visited accumulates only while !shadow_live, and build_shadow_set runs at
the first level >= 1 with en > 0. So the minimal shape is an object with an
enumerable own key whose prototype also has one — level 0 is recorded, level 1
triggers the rebuild, and the level-0 pointer has crossed one
js_object_keys_value allocation by then.

Provenance

Introduced with the deferred shadow set in 468a57d64
("perf(enum): for-in builds its shadow set only when a prototype level has a
key to filter"); present on main. It is the one place #9864's rooting pass
did not reach, because the rework landed after that patch was written.

Fix

Store RuntimeHandles instead of raw f64, so the collector rewrites the
recorded levels, and read each level fresh from its handle in
VisitedSlice::iter. RuntimeHandle is Copy, so the inline arm still costs
no allocation and the "no malloc per for-in" property the rework exists for is
preserved.

A fix in that shape is included in the merge train carrying #9864
(fix(gc): root the for-in shadow-set's recorded prototype levels), so this
issue is filed for the record and for the test coverage it still wants: a
Proxy-getPrototypeOf fixture under PERRY_GC_SCHEDULE_SEED +
PERRY_GC_PROTECT_FROMSPACE that faults on the unfixed build.

Activity

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