Repository navigation
gc: a call-result operand of === is not rooted across the other operand's call (js_eq / js_jsvalue_equals faults on from-space) #7979
Description
Found while validating #7963 (PR #7978). This is a latent defect on main, not a defect of that PR: it reproduces identically on a pristine origin/main build and on the branch.
Symptom
A comparison whose operands are two calls — the shape every gap probe in test-files/ uses for its verdict line —
console.log("x", observed() === expected() ? "ok" : "BAD");leaves observed()'s result as an SSA temporary that is live across expected(). When expected() allocates through a loop back-edge it collects, the temporary is never rewritten, and the run faults on retired from-space memory:
[gc-fromspace-protect] FAULT: signal 10 at 0x38af4b20e5c
This address is RETIRED FROM-SPACE. ...
block=0x38af4b20000 +3676 retired_bytes=4104 retired_by_minor=#134
last-known object: user_ptr=0x38af4b20e58 obj_type=3 size=216
The faulting instruction IS the stale use. Backtrace:
0 … perry_runtime::arena::quarantine::fromspace_fault_handler
1 … perry_runtime::gc::schedule::schedule_fault_handler
2 libsystem_platform.dylib _sigtramp
3 … js_jsvalue_equals + 328
4 … js_eq + 20
5 … main + 688
obj_type=3 is GC_TYPE_STRING, and the faulting address is user_ptr + 4, which is exactly StringHeader::byte_len (crates/perry-runtime/src/string/mod.rs:308 — offset 0 is utf16_len). So the faulting instruction is js_jsvalue_equals reading the length of a string operand that has moved.
The backtrace was obtained by compiling against an unstripped runtime:
cargo build --profile perry-dev -p perry-runtime-static -p perry-stdlib-static
PERRY_DEBUG_SYMBOLS=1 PERRY_NO_AUTO_OPTIMIZE=1 \
PERRY_RUNTIME_DIR=$TARGET/perry-dev target/release/perry prog.ts -o /tmp/prog
(PERRY_DEBUG_SYMBOLS=1 is what suppresses strip_final_binary; without it the release binary keeps 159 symbols and nothing resolves.)
Reproduce
Take test-files/test_gap_gc_define_property_descriptor_rooting.ts from PR #7978 and put its three verdict lines back into the inline form:
console.log("objectGroupBy", objectGroupBy() === expectedObjectGroupBy() ? "ok" : "BAD");
console.log(
"definePropertyOneAtATime",
definePropertyOneAtATime() === expectedIndexed("prop-", "value-", 12) ? "ok" : "BAD",
);then
PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 \
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 ./prog
exits 138. Binding both sides first —
const observed = definePropertyOneAtATime();
const expected = expectedIndexed("prop-", "value-", 12);
console.log("definePropertyOneAtATime", observed === expected ? "ok" : "BAD");— exits 0 with copying_minors=301 moved_objects=110912, i.e. the collector was extremely busy and the rooted form survives it. That difference is the whole bug: the value is identical, only its binding changed.
Why it matters
- It is the plain
docs/src/internals/gc-rooting-invariant.mdrule — a GC-managed value's root store must dominate every subsequent site that can collect — applied to a call-result temporary feeding a binary operator. Anyf() === g(),f() + g(),f().concat(g())… where the left operand is heap-allocated and the right operand can collect has the same shape. - It is silently shaping the gap corpus. Probes written in the natural inline style will fault under the scheduled-collection witness configurations for a reason that has nothing to do with what they are testing, and the obvious reading of the census line (
obj_type=3, a string) invites attributing it to whatever runtime helper the probe exercises. gc: from-space fault in the Object.defineProperty / descriptor-getter family (the window #6949's scope note defers) #7963's own report may be this defect rather than thedefinePropertywindow; PR fix(gc): root Object.defineProperty's receiver, key and descriptor fields across its own allocating calls #7978 fixes a genuine, independently-provendefinePropertyrooting hole, but its first-draft probe was faulting here, not there.
Where to look
scripts/gc_root_dominance_check.py is the right instrument for this class, but it reported 0 root store(s) in the corpus on this program's --trace llvm output, both with and without PERRY_INLINE_SHADOW_SLOT=0 — so either the module genuinely emits no root stores (which would itself be the bug) or the checker's corpus-liveness guard is telling us the IR is not the IR it expects. Worth resolving first: a checker that cannot see this program cannot gate it.
docs/src/internals/gc-rooting-invariant.md names three shapes of this defect already (#7184 out-of-frame slot index, #7192 store after an allocating call, #7207 plain alloca_entry). This looks like a fourth: no store at all, because the value never became a named local.
Activity
fix(codegen): root comparison operands across calls (#8011)