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
Activity
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:event shadow stack native roots store store i64 %v, ptr %s%t = inttoptr i64 %v to ptr addrspace(1)+store ptr addrspace(1) %t, ptr %sload %d = load i64, ptr %s%d.rs4p = load ptr addrspace(1), ptr %s(+ptrtoint/bitcastback to%d)clear 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.- added a commit that references this issue
on 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.
Split out of #7493. These failures are not the #7370 lowering default —
PERRY_RS4GC=0movestemp_root_argument_temporariesnot 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 failedcrates/perry-codegen/tests/temp_root_operand_temporaries.rs— 13 passed, 6 failedFailing (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_sourceCause
#7487 re-lowered temp roots onto pooled frame allocas:
pushbecame a store plus the same bind a named local's store emits,geta load,truncatea slot zero.js_gc_temp_root_push/_get/_set/_truncatesurvive only on the FFI fallback arm, which is taken whenreserve_shadow_slot()returnsNone— and it returnsSomeunder 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:Every 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_collectsThat 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:
js_array_alloc,js_string_concat,js_object_assign_one, …);loadfrom that same alloca, not from the producer's register;_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 italloca ptr addrspace(1)with the bind implicit in the RS4GC retype. A helper that normalises the two, intests/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
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.