Repository navigation
GC: for-in's deferred shadow set records prototype levels as unrooted raw pointers #9869
Description
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 ProxygetPrototypeOftrap 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
fix(gc): root the for-in shadow-set's recorded prototype levels (#9869)
fix(train): gates for the nursery RegExp, the page-class table, and t…
proggeramlug commentedon Sep 6, 2026
Fixed on
mainvia merge train #9883:VisitedLevelsnow storesRuntimeHandles, which the collector rewrites, andVisitedSlice::iterreads each level fresh from its handle.RuntimeHandleisCopy, 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::dropdoestruncate(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 asruntime handle used after its scope was droppedin #9864's ownfor_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-
getPrototypeOffixture underPERRY_GC_SCHEDULE_SEED+PERRY_GC_PROTECT_FROMSPACEthat faults on the unfixed build.proggeramlug commentedon Sep 6, 2026
Coverage landed: #9902 adds
test-files/test_issue_9869_for_in_visited_levels_gc.ts, onmainvia 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_setreads both retained levels after the key-array allocations have had a chance to move them. It runs underPERRY_GC_SCHEDULE_RATE=1withPERRY_GC_FORCE_EVACUATE=1,PERRY_GC_VERIFY_EVACUATION=1andPERRY_GC_PROTECT_FROMSPACE=1. It uses plainObject.createchains rather than the ProxygetPrototypeOftrap 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-suiteshards select tests by the substringtest_gap_, so atest_issue_*fixture runs in the nightlyfulltier only. That matches the convention of the other 380test_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 onmainand be attributed by sweep window rather than blocked at PR time. Renaming ittest_gap_issue_9869_…moves it into the per-PR gate; its output is a deterministickeys.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.