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.
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 veryjs_object_set_field_by_namecalls that install its named statics. When the static initializers are all inert, the existingprotect_handlepredicate 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
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 onejs_object_set_field_by_nameper named static, and that helper performs the keys-array transition and can allocate.So
class C { static tag = tag }— one inertLocalGetinitializer — takesprotect_handle == falseand emits:Why
js_object_mark_classdoes not save itjs_object_mark_classstores the pointer intoCLASS_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
Five hits across four modules, all
js_object_alloc -> js_object_set_field_by_nameinmain:test_gap_class_expr_identity(x2)test_gap_class_expr_instance_fieldstest_gap_class_expr_new_instanceoftest_gap_class_expr_static_thisSuggested fix
Make
protect_handleunconditional whennamed_staticsis 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 thejs_closure_callNwork; opening this so the gate can cite a tracked issue in its allowlist rather than absorbing the hits into a numeric threshold.