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

  1. proggeramlug commented on Sep 6, 2026

    @proggeramlug
    ContributorAuthor

    Fixed on main via merge train #9883: VisitedLevels now stores RuntimeHandles, which the collector rewrites, and VisitedSlice::iter reads each level fresh from its handle. RuntimeHandle is Copy, so the inline arm still costs no allocation.

    Worth recording for anyone who touches this: the first version of the fix was wrong in a way the type system could not catch. The runtime handle stack is strictly LIFO — RuntimeHandleScope::drop does truncate(self.base) — so rooting into an OUTER scope while an inner scope is alive has the inner scope's drop discard the handle. RuntimeHandle<'scope> ties to the scope's borrow, not to its stack position, so it compiles. It surfaced as runtime handle used after its scope was dropped in #9864's own for_in_grown_result_and_receiver_survive_prototype_collection. The per-level scope now closes before the push.

    Leaving this open for the coverage it still wants: a Proxy-getPrototypeOf fixture under PERRY_GC_SCHEDULE_SEED + PERRY_GC_PROTECT_FROMSPACE that faults on the unfixed build.

  2. proggeramlug commented on Sep 6, 2026

    @proggeramlug
    ContributorAuthor

    Coverage landed: #9902 adds test-files/test_issue_9869_for_in_visited_levels_gc.ts, on main via merge train #9903 — verified on main.

    It is the shape this issue asked for: a three-level prototype chain whose middle level contributes no enumerable keys, so the shadow set stays deferred and build_shadow_set reads both retained levels after the key-array allocations have had a chance to move them. It runs under PERRY_GC_SCHEDULE_RATE=1 with PERRY_GC_FORCE_EVACUATE=1, PERRY_GC_VERIFY_EVACUATION=1 and PERRY_GC_PROTECT_FROMSPACE=1. It uses plain Object.create chains rather than the Proxy getPrototypeOf trap I suggested, which is if anything better — it needs no user JS to reach the same window.

    One caveat worth a maintainer's decision before this is considered fully closed. The per-PR gap-suite shards select tests by the substring test_gap_, so a test_issue_* fixture runs in the nightly full tier only. That matches the convention of the other 380 test_issue_* files and is not a defect in #9902. But this bug's entire failure mode was staying invisible until much later, which is exactly the case where per-PR coverage is worth more than nightly — a regression here would land on main and be attributed by sweep window rather than blocked at PR time. Renaming it test_gap_issue_9869_… moves it into the per-PR gate; its output is a deterministic keys.join(","), so it is suitable as a gap test.

    Closing, since the coverage exists and the fix is verified. Reopen if the tier should change.

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