Repository navigation
Description
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 addressWhy 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 -vFive 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_fieldstest_gap_class_expr_new_instanceoftest_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
docs(gc): correct the dominance direction, the shape summary, and the…
docs(gc): correct the dominance direction, the shape summary, and the…
docs(gc): correct the dominance direction, the shape summary, and the…
docs(gc): correct the dominance direction, the shape summary, and the…
docs(gc): correct the dominance direction, the shape summary, and the…
fix(gc): root the regexp receiver across ToString, and audit ALLOC_RE
fix(gc): root the regexp receiver across ToString, and audit ALLOC_RE
fix(gc): root the interned typeof strings, the rawJSON key, and every…
fix(gc): root the regexp receiver across ToString, and audit ALLOC_RE…
proggeramlug commentedon Aug 2, 2026
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: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-502now carries yourjs_object_mark_classargument 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
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:
The sibling
--unrooted-allocas --moving-onlymode is also at 0 over the same 138 modules / 5 779 gc-capable allocas.Part of the sweep in #7262.
fix(gc): root the rest-argument and same-module direct-call paths
fix(gc): root rest-args and same-module call arguments (#7270, by @jd…
test(gc): cover every interned string-cache root cell, and trim CLAUD…
test(gc): cover every interned string-cache root cell, and trim CLAUD…
docs(gc-rooting): update for fixed #7211 and add runtime-cache class (#…
test(gc): cover every interned string-cache root cell, and trim CLAUD…
fix(gc): root the displaced receiver in direct IMPLICIT_THIS save/res…
fix(codegen): an own property beats a proven-kind builtin (#10943 lay…
fix(codegen): an own property beats a proven-kind builtin (#10943 lay…
fix(codegen): an own property beats a proven-kind builtin (#10943 lay…