Skip to content

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

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.

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