Repository navigation
temp_root_{argument,operand}_temporaries assert the pre-#7487 FFI spelling: 10 tests fail and the 8 that pass are vacuous — the #6951 contract has no working coverage #7503
Description
Split out of #7493. These failures are not the #7370 lowering default — PERRY_RS4GC=0 moves temp_root_argument_temporaries not at all (3/7 either way). They are #7487's, and the passing half of both suites is worse than the failing half.
Symptom
crates/perry-codegen/tests/temp_root_argument_temporaries.rs — 3 passed, 4 failed
crates/perry-codegen/tests/temp_root_operand_temporaries.rs — 13 passed, 6 failed
Failing (all with the same shape, ir.contains("call i32 @js_gc_temp_root_push") is false):
console_argument_accumulator_is_temp_rootedarray_literal_roots_elements_before_an_allocating_elementsymbol_keyed_typed_array_read_is_still_temp_rootedunproven_key_typed_array_read_is_still_temp_rootedmap_set_key_is_rooted_across_an_allocating_valueconcat_accumulator_is_rooted_and_written_backconstructor_arguments_are_rooted_across_the_instance_allocationwtf8_literal_operand_is_rooted_not_merely_reusedthe_new_instance_is_rooted_across_the_constructor_bodythe_object_assign_accumulator_is_rooted_across_each_source
Cause
#7487 re-lowered temp roots onto pooled frame allocas: push became a store plus the same bind a named local's store emits, get a load, truncate a slot zero. js_gc_temp_root_push / _get / _set / _truncate survive only on the FFI fallback arm, which is taken when reserve_shadow_slot() returns None — and it returns Some under both lowerings. So the spelling every assertion in these two files searches for is now emitted essentially never.
The contract itself is intact. console.log("label", churn()) today:
%r2 = alloca ptr addrspace(1) ; the pooled temp slot
%r1 = call i64 @js_array_alloc(i32 2)
%rs4gc.s1 = inttoptr i64 %r1 to ptr addrspace(1)
store ptr addrspace(1) %rs4gc.s1, ptr %r2 ; push
%r7.rs4p = load ptr addrspace(1), ptr %r2 ; re-read before use
%r8 = call i64 @js_array_push_f64(i64 %r7, double %r6)
%rs4gc.s2 = inttoptr i64 %r8 to ptr addrspace(1)
store ptr addrspace(1) %rs4gc.s2, ptr %r2 ; write the reallocated pointer back
...
store ptr addrspace(1) null, ptr %r2 ; truncateEvery property #6951 was written to pin is there. Only the spelling changed.
The worse half
The tests that still pass in these files are the negative direction — !ir.contains("call i32 @js_gc_temp_root_push") — and they now hold for every program, rooted or not:
non_allocating_element_list_emits_no_rooting_callsbuffer_element_operand_is_not_temp_rootedbuffer_index_get_operand_is_not_temp_rootedmap_set_with_a_non_allocating_value_emits_no_rooting_callsstring_method_with_non_allocating_args_emits_no_rooting_callsconstructor_arguments_on_plain_locals_emit_no_rooting_callsregistered_root_operands_are_reloaded_rather_than_rootedstring_literal_concat_operand_is_not_re_derived_when_nothing_collects
That is CLAUDE.md hazard 4 — the gate runs, its subject does not. #6996/#6997's whole point was that rooting a value which can never be collected is pure cost on the path that exists because it was optimised, and nothing measures that any more.
So the net position: the #6951 / #6969 / #6970 / #6971 / #7114 / #7154 / #7200 temp-root contract has no working coverage in either direction. Ten tests fail, eight pass without asserting anything.
What to build
Re-point both files at the pooled form. The assertion has to name the value — "some temp root exists" is too weak, since a named local's slot looks identical:
- find the register the fixture's producer defines (
js_array_alloc,js_string_concat,js_object_assign_one, …); - assert it is stored into an entry alloca before the next allocating call;
- assert the consuming call reads its operand with a
loadfrom that same alloca, not from the producer's register; - for
_set-shaped producers (concat,Object.assign), assert the post-call value is stored back.
That is strictly stronger than ir.contains("call i32 @js_gc_temp_root_push"), which only proved a call was emitted somewhere in the module.
Both lowerings must be expressible: shadow spells the slot alloca i64 + js_shadow_slot_bind, native spells it alloca ptr addrspace(1) with the bind implicit in the RS4GC retype. A helper that normalises the two, in tests/native_proof_support/ or a sibling, would serve both files — and would let these tests run unpinned, which is the right outcome: the contract is lowering-independent and should be asserted that way.
Acceptance
- All 10 failing tests green, asserting the pooled form.
- All 8 vacuous negatives re-pointed and shown to be non-vacuous — sabotage each by deleting the rooting at its emission site and confirm the corresponding positive test fails.
- At least one test still covers the FFI fallback arm (
js_gc_temp_root_pushis not dead code — it is what runs whenreserve_shadow_slotreturnsNone), or that arm should be deleted per CLAUDE.md's kill-policy.
Activity
docs(test): point the lowering notes at the filed follow-ups (#7502, #…
fix(test): make the root-lowering pin reachable from integration suit…
proggeramlug commentedon Aug 9, 2026
Fixed in #7675. Verdict: the positives were obsolete (asserting a spelling that no longer exists on any shipped path) and the negatives were vacuous (true of every program in the language).
What replaced it. New
perry_codegen::testing::temp_slotsreads rooted-slot traffic per entry alloca — stores, loads, clears, in program order — recognising both spellings:store i64 %v, ptr %s%t = inttoptr i64 %v to ptr addrspace(1)+store ptr addrspace(1) %t, ptr %s%d = load i64, ptr %s%d.rs4p = load ptr addrspace(1), ptr %s(+ptrtoint/bitcastback to%d)store i64 0, ptr %sstore ptr addrspace(1) null, ptr %sThe RS4GC retype preserves register names — the value the shadow lowering would call
%r7is still%r7, reached through%r7.rs4p— which is what lets one reader serve both, and therefore what lets the contract be asserted unpinned. It is lowering-independent and now says so.The assertion names the value, as the issue asked:
assert_rooted_across(ir, producer_result, consumer)finds the slot the producer's result (or its NaN-boxed form) was stored into, requires the consuming call's operand to be re-read from that same slot between the store and the call, and requires the consumer not to take the producer's own register.console_argument_accumulator_is_rooted_re_read_and_written_backadditionally requires the reallocated push result to be written back into the slot, and the clear to land after the consumer.A separate
temp_root_slotsdistinguishes #7487's pooled slots from named-local and scalar-replacement storage — by alloca type and by seed, because RS4GC retypes every pointer-capable alloca toptr addrspace(1)and a named local's slot is seeded withundefinedwhile a pooled temp slot is seeded with zero/null. That is what makes the eight negatives non-vacuous: they now say "no TEMPORARY was rooted" in a module that still roots its locals.Status.
temp_root_operand_temporaries.rsis 19/19 under both lowerings, unpinned.temp_root_argument_temporaries.rsis deleted; its seven tests moved tocrates/perry-codegen/src/temp_root_coverage/(see #6988), each running once per lowering.Sabotage, run. Removing the
ctx.block().store(…)inrooting::temp_root::temp_slot_store— the pooled spelling of "never actually root it" — turns all four positives in the moved suite red (console_argument_accumulator_is_rooted_re_read_and_written_back,an_array_literal_roots_elements_before_an_allocating_element,a_symbol_keyed_typed_array_read_is_still_temp_rooted,an_unproven_key_typed_array_read_is_still_temp_rooted) and correctly leaves the three negatives green.Unit-level sabotages, committed (in
testing::temp_slots::tests, so they keep running):an_unrooted_accumulator_is_caught— the pre-gc: console.log argument temporaries are not precise roots — a precise-roots-only collection drops string-literal args (minimal repro, no evacuation needed) #6951 shape, the accumulator threaded through an SSA register.rooted_but_not_re_read_is_caught— String literal operand is not GC-rooted across an allocating call in the same expression (stale handle after evacuation) #7114 exactly: rooted, location rewritten, and the consuming call still reads the pushed register.contains("call i32 @js_gc_temp_root_push")passes this happily;assert_rooted_acrossdoes not.the_negative_direction_sees_real_rooting—assert_no_temp_rootingmust REJECT a program that roots, in both lowerings, or every negative gate is a claim about nothing.both_lowerings_read_as_the_same_contract— the same program in both spellings reads as the same slot, the same events, the same verdict.On the FFI fallback arm. It is not covered and, per the issue's third acceptance bullet, that is now a live question rather than an assumption:
reserve_shadow_slot()returnsSomeunder both shipped lowerings, sojs_gc_temp_root_push/_get/_set/_truncateare emitted essentially never. Under CLAUDE.md's kill-policy that arm is a mode nobody has verified and should be deleted; I have not done it here because it is a compiler change and this PR is test-only. Worth its own issue.test(codegen): make the stale codegen-test contracts able to fail aga…
proggeramlug commentedon Aug 9, 2026
Merged as 6cdcd79 (#7675).
temp_root_operand_temporaries.rsis 19/19 under both lowerings, unpinned;temp_root_argument_temporaries.rsmoved tosrc/temp_root_coverage/(#6988). Evidence, the emission sabotage and the four committed unit sabotages are in the comment above.Left open by this work, per the third acceptance bullet: the FFI fallback arm (
js_gc_temp_root_push/_get/_set/_truncate) is still unreachable under both shipped lowerings, so under CLAUDE.md's kill-policy it is a mode nobody has verified and should be deleted. That is a compiler change rather than a test change; worth its own issue.