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.
Summary
for-in's deferred shadow set records each walked prototype level as a plainNaN-boxed
f64inVisitedLevels, then dereferences it later. Between therecord 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:and at the first level >= 1 that has an enumerable key to filter:
which is
Between
visited.push(current)at level N and that read, the loop executes:js_object_get_prototype_of(current)— a ProxygetPrototypeOftrap isarbitrary 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.
VisitedLevelsis a plainRust struct (
inline: [f64; 8]plus aVec<f64>spill), so nothing rewritesit, 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 toscripts/gc_root_dominance_check.py, which reads emitted LLVM IR and cannotsee a Rust-side side table.
Reachability
visitedaccumulates only while!shadow_live, andbuild_shadow_setruns atthe first level >= 1 with
en > 0. So the minimal shape is an object with anenumerable 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_valueallocation 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 passdid not reach, because the rework landed after that patch was written.
Fix
Store
RuntimeHandles instead of rawf64, so the collector rewrites therecorded levels, and read each level fresh from its handle in
VisitedSlice::iter.RuntimeHandleisCopy, so the inline arm still costsno allocation and the "no malloc per
for-in" property the rework exists for ispreserved.
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 thisissue is filed for the record and for the test coverage it still wants: a
Proxy-
getPrototypeOffixture underPERRY_GC_SCHEDULE_SEED+PERRY_GC_PROTECT_FROMSPACEthat faults on the unfixed build.