Skip to content

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

@proggeramlug

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

  1. It is the plain docs/src/internals/gc-rooting-invariant.md rule — 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. Any f() === g(), f() + g(), f().concat(g()) … where the left operand is heap-allocated and the right operand can collect has the same shape.
  2. 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 the defineProperty window; PR fix(gc): root Object.defineProperty's receiver, key and descriptor fields across its own allocating calls #7978 fixes a genuine, independently-proven defineProperty rooting 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.

Metadata

Metadata

Assignees

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