Repository navigation
Description
Summary
gc::tests::runtime_roots::prototype_addr_cache::prototype_addr_reads_through_a_forwarding_stub fails roughly 1 run in 100 of cargo test --release -p perry-runtime at default parallelism. It is the one member of the #7946 flaky set that #7954 did not fix, and it is left named here rather than silently.
Message:
array_prototype_addr must resolve the GC forwarding chain: a stale from-space
address is a DIFFERENT address for the SAME object than every
clean_arr_ptr-resolved receiver ...
left: 4005900094920
right: 4007898578952
Cause
ARRAY_PROTO_ADDR / OBJECT_PROTO_ADDR (crates/perry-runtime/src/array/indexing.rs) are process-global AtomicUsize caches holding raw addresses into a thread-local arena. The test plants a synthetic forwarded stub in one of them and reads it back; concurrently, any other libtest thread can
- call
array_prototype_addr()/object_prototype_addr(), which heals the cache in place, or - run a collection, whose registered
scan_prototype_addr_cache_roots_mutrewrites the slot,
and either overwrites what this test planted.
That the cache is process-wide at all is the deeper point: with perry/thread agents the address it holds is only meaningful on the thread that wrote it (this is the "runtime-side cache of a raw heap pointer" shape CLAUDE.md's known-weak-areas section describes).
Why #7954 did not fix it
The obvious fix — per_test_global! the two caches, as that PR does for the element-shape counters — deterministically broke two unrelated tests, 200 runs out of 200:
gc::tests::dead_owner_side_tables::test_dead_owner_descriptor_entries_pruned_on_full_gcgc::tests::dead_owner_side_tables::test_dead_arguments_object_entry_pruned_on_full_gc
and introduced four intermittent promise::native_async::tests::* failures (~15 in 200 runs) that are not present on main.
The likely mechanism: PerThread materialisation is lazy, so the first read of the cache on a given thread misses and runs the full resolution path — js_get_global_this_builtin_value(b"Object"), i.e. a globalThis bootstrap, allocating objects. object_prototype_addr() is reachable from inside a GC root scan (scan_prototype_addr_cache_roots_mut) and from note_object_prototype_index_write on the array index-write path, so that bootstrap lands in places the shared cache never let it reach. This was reverted rather than shipped half-understood.
Suggested direction
Either
- eagerly materialise the per-thread instance outside any collection (e.g. at thread/arena init) so no read can trigger a bootstrap, or
- move the healing logic onto an explicit
&AtomicUsize(it already takes one —heal_prototype_addr(cache, cached)) and let the gc: a relocating minor with PRECISE roots breaks 14 of 20 representation-corpus files (5 crashes, 9 mismatches) — the conservative stack scan is load-bearing for correctness #6981 defence-1 test exercise it on its own cell, keeping a separate race-free assertion that the shipped accessors read the shipped statics. That is the shape test(runtime): stop five process-globals leaking across libtest threads (#7946) #7954 used forgc::poll_arm.
Whichever way, the acceptance criterion is the same as #7946's: ≥200 consecutive full-suite runs green at default parallelism, with the dead_owner_side_tables and promise::native_async families explicitly checked, not just the target test.
Activity
test(runtime): stop five process-globals leaking across libtest threa…
test(runtime): stop five process-globals leaking across libtest threa…
test(runtime): fix the last two suite flakes — shared prototype-addre…