Skip to content

#7341: the aarch64 stack-map fast walker reads the wrong stack for frames >= 4 KiB (lsl #12 prologue adjustments) #7394

Closed

Description

@proggeramlug

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

  1. 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.
  2. Land it behind the existing quarantine so the catch list is the acceptance test.
  3. 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).

Activity

  1. proggeramlug commented on Aug 4, 2026

    @proggeramlug
    ContributorAuthor

    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:

    test_gap_gc_same_module_call_argument_rooting   own-env=[bad 0]   node=[bad 0]   ✅
    test_gap_gc_call_argument_rooting               own-env=[bad 0]   node=[bad 0]   ✅
    

    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.

  2. 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
  3. 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
  4. proggeramlug commented on Aug 4, 2026

    @proggeramlug
    ContributorAuthor

    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.

    Still open after #7398

  5. added a commit that references this issue on Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    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