Skip to content

codegen: ClassExprFresh leaves the class object unrooted across its own js_object_set_field_by_name stores #7211

Closed

Description

@jdalton

Summary

Expr::ClassExprFresh (crates/perry-codegen/src/expr/static_field_meta.rs:432) leaves the freshly-allocated class object in a raw SSA register across the very js_object_set_field_by_name calls that install its named statics. When the static initializers are all inert, the existing protect_handle predicate decides no root is needed — but the field-store helper is itself a collection point, so the register can go stale regardless of what the initializers do.

This is the same class as #7154 / #7184 / #7192 / #7206, and it is currently reported red by the gate that already exists on main (.github/workflows/gc-root-dominance.yml, added in #7198). It has not blocked anything because that job is not in branch protection's required contexts — hazard 2 in CLAUDE.md's "four ways a gate can be unable to fail".

The predicate gap

let protect_handle = !captured_args.is_empty()
    || !symbol_statics.is_empty()
    || !block_fns.is_empty()
    || super::temp_root::any_may_trigger_gc(ctx, named_statics.iter().map(|(_, v)| v));

Every disjunct asks whether something the author supplies can collect: a captured argument, a symbol static, a static { … } block, or an initializer expression. None asks whether the lowering's own emitted calls can collect. The loop below unconditionally emits one js_object_set_field_by_name per named static, and that helper performs the keys-array transition and can allocate.

So class C { static tag = tag } — one inert LocalGet initializer — takes protect_handle == false and emits:

%r10 = call i64 @js_object_alloc(i32 1, i32 1)
call void @js_object_mark_class(i64 %r10)
call void @js_class_object_pin_parent(i64 %r10, i32 1)
call void @js_object_set_field_by_name(i64 %r10, i64 %r14, double %r11)  ; can collect
%r15 = or i64 %r10, 9222527611924643840                                  ; %r10 may be from-space
%r16 = bitcast i64 %r15 to double
store double %r16, ptr %r9
call void @js_shadow_slot_bind(i32 0, ptr %r9)                           ; roots the stale address

Why js_object_mark_class does not save it

js_object_mark_class stores the pointer into CLASS_OBJECT_VALUES, which is a registered root and is forwarded (class_registry/gc_roots.rs:138, visit_nanbox_u64_slot). That keeps the object alive and keeps the side table's copy correct. It does nothing for %r10, which is a separate copy the collector cannot see. After an evacuating minor the object is fine and the register is stale — so the statics land on the abandoned copy and the shadow slot is bound to from-space.

This is worth stating explicitly because "it's already in a root table" is the natural reason to wave this off, and it is the wrong reason: reachability is not the invariant; the invariant is that the register you are still going to use was rewritten.

Reproduction

cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
./scripts/gc_root_dominance_corpus.sh ir-corpus
python3 scripts/gc_root_dominance_check.py ir-corpus --moving-only \
  --min-files 100 --min-binds 500 --min-funcs 1200 -v

Five hits across four modules, all js_object_alloc -> js_object_set_field_by_name in main:

  • test_gap_class_expr_identity (x2)
  • test_gap_class_expr_instance_fields
  • test_gap_class_expr_new_instanceof
  • test_gap_class_expr_static_this

Suggested fix

Make protect_handle unconditional when named_statics is non-empty, since the loop emits a collecting call per entry regardless of initializer shape. The narrow version is to || in !named_statics.is_empty(); the honest version is to stop predicating on "can the user's expressions collect" at all and root whenever the object outlives a single emitted call.

Not fixed here because crates/perry-codegen/src/expr/ is being actively edited under #7206 and the js_closure_callN work; opening this so the gate can cite a tracked issue in its allowlist rather than absorbing the hits into a numeric threshold.

Activity

  1. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    Contributor

    Re-verified on main = d4ac65f21. Fixed — closing.

    Fixed by #7226 (7f29b804c), which took the "honest version" this issue asked for rather than the narrow one:

    let protect_handle = !named_statics.is_empty()
        || !captured_args.is_empty()
        || !symbol_statics.is_empty()
        || !block_fns.is_empty();

    The old any_may_trigger_gc(named_statics) disjunct was removed rather than kept alongside — it is strictly subsumed by the new clause, and leaving it would have read as a second, narrower opinion about the same operand. static_field_meta.rs:478-502 now carries your js_object_mark_class argument verbatim, including the line that matters most: reachability is not the invariant; the invariant is that the register you are still going to use was rewritten.

    Evidence — this issue's own stated reproduction

    ./scripts/gc_root_dominance_corpus.sh ir-corpus
    python3 scripts/gc_root_dominance_check.py ir-corpus --moving-only \
      --allowlist scripts/gc_root_dominance_allowlist.json \
      --min-files 90 --min-binds 1500 --min-funcs 1200
    
    === checked 2282 functions / 138 modules (138 .ll files, 2950 root stores)
    === violations: 0   (moving-minor reachable: 0)
    

    Was 5 hits across 4 modules (test_gap_class_expr_identity ×2, _instance_fields, _new_instanceof, _static_this). Now 0, over a corpus comfortably above every CI floor.

    And the checker was proven able to fail on this very corpus, so the 0 is a verdict rather than a parse failure:

    === seeded violations: 40 planted, 40 caught, 0 MISSED
    

    The sibling --unrooted-allocas --moving-only mode is also at 0 over the same 138 modules / 5 779 gc-capable allocas.

    Part of the sweep in #7262.

  2. added 2 commits that reference this issue on Aug 3, 2026
    57ec369
    e38bf74
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