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.
Symptom
The
perry-runtimelib test binary SIGSEGVs, taking the whole suite process down mid-run with no test reportingFAILED. Observed once in a 102-run soak ofcargo test -p perry-runtime --lib --no-fail-fast(arm64 macOS, debug profile, default parallelism).Faulting thread and stack, from the macOS crash report:
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 testexits non-zero withprocess didn't exit successfully: ... (signal: 11, SIGSEGV)and zeroFAILEDlines, so a log tail shows a clean-looking partial run. Any wrapper that greps forFAILED, or that classifies "a signal" as an external interruption, records this as benign. (Both mistakes were made while finding it: asignal:-based guard first lumped SIGSEGV in with an unrelated SIGTERM.)Of 69
perry_runtimecrash reports on this host, 68 are the deliberatepin_latch_sabotage_childSIGABRT — 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
0x000001fffffffff8is not a small offset off a live object; it is roughly2^41, the shape of an index-times-8 computed from a garbage id.object_shapeguards withis_valid_obj_ptrand then reads theGcHeaderatptr - 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'sinvalidate_representation_change. That is a hypothesis, not a diagnosis — the stack says where, not why.Reproduction
Rare. 1 occurrence in ~102 full-suite runs:
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=1in 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 thejs_object_clone_with_extra→set_object_keys_arraypath 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:
fix/7672-global-sink-isolationorigin/main@175c5fd5f, own target dir0 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.rsandgc/layout.rsare untouched by it; its only edit toobject/mod.rswraps nineAtomicU64/AtomicI64cache declarations in a macro and changes no function on this path. The PR is declaration-site only, andtyped_feedback::REGISTRYis not among the statics it converts — which is also why it remains a candidate for the same treatment, per the hypothesis above.