Skip to content

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

@proggeramlug

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_extraset_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions