Repository navigation
gc: perry-runtime lib tests SIGSEGV in typed_feedback::object_shape via js_object_clone_with_extra (1 in ~102 full-suite runs, no test reports FAILED) #7683
Description
Symptom
The perry-runtime lib test binary SIGSEGVs, taking the whole suite process down mid-run with no test reporting FAILED. Observed once in a 102-run soak of cargo test -p perry-runtime --lib --no-fail-fast (arm64 macOS, debug profile, default parallelism).
Faulting thread and stack, from the macOS crash report:
exception: EXC_BAD_ACCESS / SIGSEGV, KERN_INVALID_ADDRESS at 0x000001fffffffff8
thread: gc::tests::copying::large_object_clone_direct_copy_keeps_young_child_alive_and_excludes_parent
perry_runtime::typed_feedback::object_shape
perry_runtime::typed_feedback::invalidate_representation_change
perry_runtime::gc::layout::layout_mark_unknown
perry_runtime::object::mark_object_dynamic_shape_unknown
perry_runtime::object::set_object_keys_array
js_object_clone_with_extra
perry_runtime::gc::tests::copying::large_object_clone_direct_copy_keeps_young_child_alive_and_excludes_parent
Why this is worth an issue rather than a retry
A crash is not a test failure, and the harness reports it as neither. cargo test exits non-zero with process didn't exit successfully: ... (signal: 11, SIGSEGV) and zero FAILED lines, so a log tail shows a clean-looking partial run. Any wrapper that greps for FAILED, or that classifies "a signal" as an external interruption, records this as benign. (Both mistakes were made while finding it: a signal:-based guard first lumped SIGSEGV in with an unrelated SIGTERM.)
Of 69 perry_runtime crash reports on this host, 68 are the deliberate pin_latch_sabotage_child SIGABRT — an instrument doing its job — and this is the only genuine one, so it is easy to lose in the noise of an expected-abort test.
The address is the tell
0x000001fffffffff8 is not a small offset off a live object; it is roughly 2^41, the shape of an index-times-8 computed from a garbage id. object_shape guards with is_valid_obj_ptr and then reads the GcHeader at ptr - GC_HEADER_SIZE, so the input address either passed that predicate while being junk, or the fault is inside the predicate's own probe.
typed_feedback::REGISTRY (crates/perry-runtime/src/typed_feedback.rs:23, LazyLock<Mutex<TypedFeedbackRegistry>>) is process-global and keyed by heap address, which is the #7672 shape: an entry installed by one test, whose object then dies and whose address is recycled, read back by another test's invalidate_representation_change. That is a hypothesis, not a diagnosis — the stack says where, not why.
Reproduction
Rare. 1 occurrence in ~102 full-suite runs:
for i in $(seq 1 30); do
cargo test -p perry-runtime --lib --no-fail-fast > run_$i.log 2>&1
grep -qE "signal: [0-9]+, SIG" run_$i.log && echo "run $i CRASHED"
done
Do not classify a signal-terminated run as an external interruption unless the signal is SIGTERM/SIGKILL/SIGINT — SIGSEGV/SIGABRT/SIGBUS are the process crashing on its own.
Suggested first step
Run the named test under --test-threads=1 in a loop to establish whether it needs a parallel neighbour (which would confirm the shared-registry hypothesis) or crashes alone (which would make it a plain bug on the js_object_clone_with_extra → set_object_keys_array path for a large old-born clone). The test asks for 4096 extra bytes, which is what routes the clone into old-gen and makes this path unusual.
Attribution: measured, and it does not distinguish — but the stack does
Found while soaking #7674 (per-test global sinks), so the first question was whether that PR caused it. A/B on the same host, same profile, same harness:
| arm | full-suite runs | SIGSEGV |
|---|---|---|
fix/7672-global-sink-isolation |
~102 | 1 |
origin/main @ 175c5fd5f, own target dir |
25 | 0 |
0 in 25 is not evidence of absence at a ~1% rate (0.25 events expected), so the counts do not separate the arms and are not claimed to.
What does separate them is the stack: every frame is in code #7674 does not modify. typed_feedback.rs and gc/layout.rs are untouched by it; its only edit to object/mod.rs wraps nine AtomicU64/AtomicI64 cache declarations in a macro and changes no function on this path. The PR is declaration-site only, and typed_feedback::REGISTRY is not among the statics it converts — which is also why it remains a candidate for the same treatment, per the hypothesis above.
Activity
fix(gc): initialize keys_array on both js_object_clone_with_extra bra…
test(gc): assert clone_with_extra's keys_array custody at the source (#…
fix(gc): initialize keys_array before clone_with_extra's collecting a…
proggeramlug commentedon Aug 9, 2026
Closed by #7727 (v0.5.1423) — root cause found and fixed by construction.
js_object_clone_with_extraleftkeys_arrayholding recycled heap bytes between its allocation andset_object_keys_array.js_array_allocruns in that window, and it allocates — so it can collect, and the collector reads that slot as a child edge (object::gc_keys_array_slot).arena_alloc_gc_old's fast path deliberately reuses a swept, non-zeroed hole (#7437), so the garbage is real prior heap content; whether it decodes as a plausible-but-unmapped address depends on allocation history, which is exactly the shape of a ~1-in-102 flake. Every sibling allocator in the file already nulled the slot.On the soak: 150 runs found 0 crashes, and that was correctly reported as inconclusive rather than clean — at a 1-in-102 rate, zero crashes in 150 runs happens ~23% of the time by luck, so N≈300–450 would be needed to conclude anything. Chasing the mechanism was the right call over soaking harder.
On the test: the runtime version I wrote first passed with the fix deleted. The window is inside the function, and a fresh arena block is zeroed, so nothing observable survives to assert on. The guard is therefore a source-level custody check in the style of
scripts/gc_pin_sites.py— it fails if either initialisation is removed.Worth noting for the next soak of this kind: the same investigation found 52/150 runs with ordinary flaky FAILUREs (not crashes), concentrated in
settling_many_keys_is_not_quadraticandsabotaged_parent_gate_*— host-load artifacts on a box at load 40-70, not defects.