Repository navigation
GC: for-in's deferred shadow set records prototype levels as unrooted raw pointers #9869
Description
Activity
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.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.
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.