You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
#7341: the aarch64 stack-map fast walker reads the wrong stack for frames >= 4 KiB (lsl #12 prologue adjustments) #7394
Triaging the catches left after #7373–#7376, #7380, #7381, #7383, #7385, #7391, they are not independent bugs. Every one has the same signature.
Evidence
test
fault
what it dereferences
gc_call_argument_rooting
js_string_concat_chain + 552
byte_len at [x1, #4] — no allocating call precedes it in the callee
backoff_options
js_closure_call0 + 40
[x0, #0xc], cmp 0x4f53 — CLOSURE_MAGIC, first deref
os_3004plus
js_closure_call3 + 48
same shape
gc_process_env_cache_rooting
js_array_push_f64 + 68
early
gc_same_module_call_argument_rooting
js_string_concat_box + 212
early
In each case the fault is at or near the callee's first dereference of an incoming pointer, after the plausibility checks pass — a retired from-space address still looks like a heap pointer. Disassembling js_string_concat_chain up to the fault shows no allocating call at all before it, only bzero and a TLS registration.
The value is already dead when the callee is entered. Nothing inside these functions can fix that.
Why js_closure_call0 is the decisive case
It takes no arguments — only the closure. So this cannot be argument-list staleness. The callee itself is held in a bare SSA register across something that allocates, and then called. Whatever the shared defect is, it covers receivers as well as arguments.
Not root_reload's job
root_reload.rs re-reads shadow slots below collection points, which fixes values that are in a slot. These are bare SSA values straight out of lower_expr — there is no slot to re-read. It cannot see them by construction.
What this means for planning
test_gap_gc_call_argument_rooting's own header documents the shape and names #7154's perry_fn_<src>__<name> path — "an argument list is evaluated left to right and each finished value sits in a bare SSA register while the later ones are lowered". That module has since been reorganised, and expr/static_method.rs alone has ~10 for a in args loops lowering arguments with no root protection.
So this is one structural fix in argument/receiver lowering, not N per-callee patches — and it should close several catches at once. Attempting it per-site is what I'd advise against: two such attempts today (Object.assign source operand, and the replace search value earlier) landed textbook-perfect roots in the IR and closed zero catches, because the root was downstream of where the value died.
Suggested sequencing
Decide where the protection belongs — per-call-site temp roots for pointer-bearing operands when any later operand can allocate, vs. a pass over the emitted CFG like root_reload but for SSA operands rather than slot loads.
Land it behind the existing quarantine so the catch list is the acceptance test.
Re-run the sweep; expect several of the table above to close together, and treat any that don't as genuinely separate.
Related: #7341, #7154, #7294 (engine plan, layer 1 — docs/src/internals/rfc-rooting-by-construction.md argues exactly this shape is better prevented than reviewed).
Correcting this issue: the cause I named is already fixed. The symptom table stands; the diagnosis does not.
What I checked
crates/perry-codegen/src/lower_call/func_ref.rs — the file test_gap_gc_same_module_call_argument_rooting's header points at — lowers its argument list through rooted helpers in all four arms:
arm
helper
synthetic arguments + real rest
lower_rest_call_args_rooted
synthetic arguments
lower_rest_call_args_rooted
has_rest
lower_rest_call_args_rooted
plain else
lower_call_args_rooted
lower_exprs_rooted (expr/temp_root.rs:361) already does precisely what I proposed building: lowers left to right, roots each finished value across the evaluation of the ones that follow, gated on any_later_ref_may_trigger_gc so operands with nothing allocating after them pay nothing. The #7154/#7240 follow-up landed.
The tests pass under the env they document
Both purpose-built tests carry // parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1, and their headers state that line is the test. Under it:
They only fault under #7341's from-space quarantine.
And it is not a quarantine artifact
My first thought was that PERRY_CONSERVATIVE_STACK_SCAN=off made the config stricter than production — a value the conservative scan legitimately covers would look unrooted only because I disabled the scan. Measured, that is not it:
test
scan=off
scan=ON (default)
all five in the table
138
138
The faults survive with the conservative stack scan enabled. They are real and reachable in a production-like configuration; the quarantine only makes the dereference fatal instead of silent.
Where that leaves it
Real defect, unknown cause, and specifically not the call-argument-rooting one this issue was filed against. The title is wrong.
Anyone picking this up should start from the disassembly evidence in the table above rather than from the tests' headers — those describe a bug that no longer exists, and following them costs a couple of hours. That is the same trap object_assign_collection set earlier in #7341, where the file's last statement looked like the culprit and the program had actually died four statements earlier.
changed the title [-]#7341: the remaining quarantine catches share one root cause — callee/argument values stale on entry to runtime calls[/-][+]#7341: remaining quarantine catches — real, reachable with the conservative scan ON, cause unknown[/+]on Aug 4, 2026
changed the title [-]#7341: remaining quarantine catches — real, reachable with the conservative scan ON, cause unknown[/-][+]#7341: the aarch64 stack-map fast walker reads the wrong stack for frames >= 4 KiB (lsl #12 prologue adjustments)[/+]on Aug 4, 2026
Root cause found. It is not in lowering at all — it is the aarch64 stack-map
walker reading the wrong stack. Fix in #7398.
The measurement that split the space
The two purpose-built catches are backend-specific, which nothing in the
triage above had tested:
test
default build (RS4GC)
PERRY_RS4GC=0 (shadow stack)
gc_call_argument_rooting
fault 138
clean, bad 0
gc_same_module_call_argument_rooting
fault 138
clean, bad 0
gc_process_env_cache_rooting
fault 138
fault 138 (separate defect)
The shadow arm is not vacuously clean — it ran a real copying minor in the same
run (copied_objects=6344 copied_bytes=427648). Both arms collect; only the
statepoint arm faults.
That immediately rules out the shape everyone (including me) was chasing:
lowering is shared between the two backends, so a lowering defect cannot be
clean on one and fatal on the other.
The defect
fp_to_sp_offset (gc/roots/stack_maps.rs) decodes a function's prologue to
recover its body SP, which the fast x29-chain walker uses as the base for every
SP-relative root slot in the frame. Its ADD/SUB (immediate) patterns masked in bit 22 — the sh field selecting lsl #12 — so a shifted instruction did
not match the opcode comparison at all.
perry_fn_test_gap_gc_call_argument_rooting_ts__run at +0x20:
9101c3fd add x29, sp, #0x70 ; fp established
d14007ff sub sp, sp, #0x1, lsl #12 ; sh=1 -- no match, accumulation run ENDS
d12103ff sub sp, sp, #0x840 ; never reached
The failed match also terminated #7328's contiguous-sub accumulation run, so
the second sub went with it. The decoder reported 0x70 for a frame whose
body SP is 0x18B0 down, and the walker handed the collector addresses 6208
bytes off — which evacuation then writes through. LLVM uses the shifted form
for every frame ≥ 4 KiB, and generated functions cross that line routinely: 80
in this one binary.
It was never quarantine-only
PERRY_GC_HEAP_LIMIT=8 alone, conservative stack scan left ON, no
quarantine: the default build prints bad 1 — a wrong answer — where the PERRY_RS4GC=0 build prints bad 0.
So the "reachable with the conservative scan ON" observation in the title was
right, and understated: this was a live wrong-answer bug in the shipping
configuration, not a latent stale pointer.
Why the earlier hypotheses all measured zero
Retitling this issue, because the two things it asserts are both wrong and both
cost time:
it is not argument/receiver lowering (that landed and is correct — the func_ref.rs audit in the comment above stands);
the disassembly evidence in the opening table is real but not diagnostic.
"The value is already dead when the callee is entered" is true, and the reason
is that the caller's root slot was never rewritten, because the collector was
looking 6208 bytes away from it. Every callee-side and lowering-side hypothesis
was downstream of that.
The generalisable lesson for the rest of #7341: A/B the two root backends
before analysing a from-space catch. It is two compiles, it is decisive when it
splits, and it would have skipped straight past ten hypotheses here.
gc_process_env_cache_rooting still faults on the shadow-stack backend —
a genuinely separate defect, and the one catch in the table above that this
does not explain.
Activity
proggeramlug commentedon Aug 4, 2026
Correcting this issue: the cause I named is already fixed. The symptom table stands; the diagnosis does not.
What I checked
crates/perry-codegen/src/lower_call/func_ref.rs— the filetest_gap_gc_same_module_call_argument_rooting's header points at — lowers its argument list through rooted helpers in all four arms:arguments+ real restlower_rest_call_args_rootedargumentslower_rest_call_args_rootedhas_restlower_rest_call_args_rootedelselower_call_args_rootedlower_exprs_rooted(expr/temp_root.rs:361) already does precisely what I proposed building: lowers left to right, roots each finished value across the evaluation of the ones that follow, gated onany_later_ref_may_trigger_gcso operands with nothing allocating after them pay nothing. The #7154/#7240 follow-up landed.The tests pass under the env they document
Both purpose-built tests carry
// parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1, and their headers state that line is the test. Under it:They only fault under #7341's from-space quarantine.
And it is not a quarantine artifact
My first thought was that
PERRY_CONSERVATIVE_STACK_SCAN=offmade the config stricter than production — a value the conservative scan legitimately covers would look unrooted only because I disabled the scan. Measured, that is not it:The faults survive with the conservative stack scan enabled. They are real and reachable in a production-like configuration; the quarantine only makes the dereference fatal instead of silent.
Where that leaves it
Real defect, unknown cause, and specifically not the call-argument-rooting one this issue was filed against. The title is wrong.
Anyone picking this up should start from the disassembly evidence in the table above rather than from the tests' headers — those describe a bug that no longer exists, and following them costs a couple of hours. That is the same trap
object_assign_collectionset earlier in #7341, where the file's last statement looked like the culprit and the program had actually died four statements earlier.[-]#7341: the remaining quarantine catches share one root cause — callee/argument values stale on entry to runtime calls[/-][+]#7341: remaining quarantine catches — real, reachable with the conservative scan ON, cause unknown[/+][-]#7341: remaining quarantine catches — real, reachable with the conservative scan ON, cause unknown[/-][+]#7341: the aarch64 stack-map fast walker reads the wrong stack for frames >= 4 KiB (lsl #12 prologue adjustments)[/+]proggeramlug commentedon Aug 4, 2026
Root cause found. It is not in lowering at all — it is the aarch64 stack-map
walker reading the wrong stack. Fix in #7398.
The measurement that split the space
The two purpose-built catches are backend-specific, which nothing in the
triage above had tested:
PERRY_RS4GC=0(shadow stack)gc_call_argument_rootingbad 0gc_same_module_call_argument_rootingbad 0gc_process_env_cache_rootingThe shadow arm is not vacuously clean — it ran a real copying minor in the same
run (
copied_objects=6344 copied_bytes=427648). Both arms collect; only thestatepoint arm faults.
That immediately rules out the shape everyone (including me) was chasing:
lowering is shared between the two backends, so a lowering defect cannot be
clean on one and fatal on the other.
The defect
fp_to_sp_offset(gc/roots/stack_maps.rs) decodes a function's prologue torecover its body SP, which the fast x29-chain walker uses as the base for every
SP-relative root slot in the frame. Its ADD/SUB (immediate) patterns masked in
bit 22 — the
shfield selectinglsl #12— so a shifted instruction didnot match the opcode comparison at all.
perry_fn_test_gap_gc_call_argument_rooting_ts__runat+0x20:The failed match also terminated #7328's contiguous-
subaccumulation run, sothe second
subwent with it. The decoder reported0x70for a frame whosebody SP is
0x18B0down, and the walker handed the collector addresses 6208bytes off — which evacuation then writes through. LLVM uses the shifted form
for every frame ≥ 4 KiB, and generated functions cross that line routinely: 80
in this one binary.
It was never quarantine-only
PERRY_GC_HEAP_LIMIT=8alone, conservative stack scan left ON, noquarantine: the default build prints
bad 1— a wrong answer — where thePERRY_RS4GC=0build printsbad 0.So the "reachable with the conservative scan ON" observation in the title was
right, and understated: this was a live wrong-answer bug in the shipping
configuration, not a latent stale pointer.
Why the earlier hypotheses all measured zero
Retitling this issue, because the two things it asserts are both wrong and both
cost time:
func_ref.rsaudit in the comment above stands);"The value is already dead when the callee is entered" is true, and the reason
is that the caller's root slot was never rewritten, because the collector was
looking 6208 bytes away from it. Every callee-side and lowering-side hypothesis
was downstream of that.
The generalisable lesson for the rest of #7341: A/B the two root backends
before analysing a from-space catch. It is two compiles, it is decisive when it
splits, and it would have skipped straight past ten hypotheses here.
Still open after #7398
gc_process_env_cache_rootingstill faults on the shadow-stack backend —a genuinely separate defect, and the one catch in the table above that this
does not explain.
PERRY_STACKMAP_WALKER=unwindis independently wrong by one frame size —GC (aarch64): the platform-unwinder stack-map walker lands one frame size below the correct SP #7399. It is the fast walker's fallback, so it is not merely a bisection knob.
fix(gc): decode `lsl #12` frame adjustments in the aarch64 prologue w…