Skip to content

SIGSEGV: garbage key reaches set_field_by_name_object_tail when a strict arm holds a frozen class instance with a field named caller #9542

Description

@proggeramlug

A .cts whose strict arm contains a frozen class instance with a field named
caller, written with +=, segfaults — and the fault lands on an earlier
statement in the same function, so it is a module-shape effect rather than a bad
statement.

* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x23c88c00000)
  frame #0: perry_runtime::object::field_set_by_name::tail::set_field_by_name_object_tail + 812
->  ldrb   w11, [x9], #0x1        ; key_bytes_hash reading the key
    eor    x11, x1, x11
    mul    x1, x11, x10

set_field_by_name_object_tail is hashing a garbage key pointer: the
receiver/key reached the by-name setter with the key naming unmapped memory.

Repro

Attached repro (also reproducible from
test-files/test_gap_9459_property_set_strictness.cts with a frozen
class CallerCell { caller: number } instance added to its strict arm). Output
stops after

strict frozen for-of head computed: TypeError 1

so the crashing statement is the NEXT one — computedPlus[computedKey] += 1, a
strict compound assignment through the Expr::IndexSet runtime-string-key arm —
even though the trigger is the caller-named class field later in the
function. Removing only the class-field case makes the whole file pass; removing
any other case does not.

What is known

Where to start

The key operand for the strict o[k] += v lane is rooted by
with_operands_rooted_across(ctx, &[object, index], &[value], …) in
expr/index_set.rs, so this is more likely a stale interned-key handle or a
dispatch-id/string-pool index collision than a missing root — the #7201/#7640
family, but the guard is present, so the pointer is probably wrong rather than
merely moved. Dumping --trace llvm for the crashing function and comparing the
key global against the string pool is the first step.

Found while adding caller/arguments receiver-path coverage to
test_gap_9459_property_set_strictness.cts (PR #9519). That fixture omits the
frozen-class-field-named-caller case with a comment pointing here; it belongs
back in the file once this is fixed.

Activity

  1. proggeramlug commented on Sep 2, 2026

    @proggeramlug
    ContributorAuthor

    Root-caused: this is #9499, and it is already fixed on main by c405ce4981 (#9522, the root-reload launder), which landed at 15:29 today — after the bisect in this issue. PR #9558 restores the held-out fixture case as the second regression witness; no code change is needed.

    All three leads in the issue turned out to be wrong, and each is worth recording:

    • caller is not load-bearing. Renaming the field to zzz, writing it with = instead of +=, or never writing it at all reproduces byte-identically. Any perturbation of the module's object-literal shape set does it. The poison-pill special cases in field_set_by_name.rs / write_helpers.rs are not on the path.
    • Not a rooting gap in expr/index_set.rs. The with_operands_rooted_across(ctx, &[object, index], &[value], …) group is present and correct, and the emitted IR for the crashing statement is structurally identical with and without the added class — only SSA numbering and two AnonShape hashes differ.
    • Not a stale interned key or a string-pool collision. The key was neither stale nor mis-interned: it was the receiver. Instrumenting js_get_string_pointer_unified caught it taking the POINTER_TAG branch on 0x7ffd_046b_fceb_06f0; set_field_by_name_object_tail then read the receiver's parent_class_id word as a byte_len of 0x800011bc and walked off the heap — which is what the garbage-key hash loop in the backtrace is.

    The mechanism is #9499's exactly: a load %root_slot folded back into an earlier safepoint's spill slot that was recycled in the hole, so the reload handed the consuming call a different live object. #9499 measured it as "the key stored was the closure allocated three lines earlier"; here it is "the key stored was the receiver". A GDB watchpoint on the machine slot backing the key shows the last write before the faulting read storing the receiver:

    WRITE val=0x7fff031d8c620058 pc=0x5555556c69fc   <- the key string "x"
    WRITE val=0x7ffd031d8c6b06f0 pc=0x5555556c6b55   <- the RECEIVER, same slot
    Program received signal SIGSEGV
    #0  set_field_by_name_object_tail
    #1  js_typed_feedback_object_set_field_by_name
    #2  perry_fn_..._strictArm
    

    A/B with one variable, on the exact fixture PR #9558 adds:

    build result
    c405ce4981^ (main just before #9522) SIGSEGV 3/3, output truncated at strict frozen for-of head computed
    c405ce4981 (#9522 launder) exit 0, 82 lines, byte-identical to node v26.5.1, 3/3

    Independently confirmed on the #9459 branch (9f40f65f5): it SIGSEGVs on this shape, and the same tree with only c405ce4981 cherry-picked runs clean. Reproduced on macOS arm64 and Linux x86-64; the fix verification is Linux x86-64.

    Suggest closing as a duplicate of #9499 when #9558 merges.

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