Summary
lower_new_impl has one exit that returns a freshly allocated class instance without emitting js_gc_init_typed_shape_layout. Every other new path emits it. An instance produced through that exit is left at GC_LAYOUT_POINTER_FREE with no TypedLayoutDescriptor, which is the one layout state where layout_note_slot is load-bearing for GC correctness rather than merely a precision hint.
Found while proving the class-field store elision in #6919 (representation-selection Phase 4b.1). It did not cause a bug there — the elision was narrowed instead of being shipped on the unproven premise — but it blocks the larger win, so it is worth closing on its own.
The exit
crates/perry-codegen/src/lower_call/new.rs, the standalone-constructor-symbol branch:
// new.rs:811
if ctx.class_stack.iter().any(|active| active == class_name)
|| ctor_alias_collision
|| force_ctor_call
{
...
if let Some(ctor_ret) = call_local_constructor_symbol(ctx, class, &obj_box, &lowered_args, caps_absent_from_args) {
...
emit_typed_shape_layout_init(ctx, class_name, &obj_handle); // new.rs:870 ✅
...
return Ok(final_box); // new.rs:899
}
if let Some(prev) = &saved_new_target {
ctx.block().call(DOUBLE, "js_new_target_set", &[(DOUBLE, prev)]);
}
return Ok(obj_box); // new.rs:905 ❌ no init
}
...
emit_typed_shape_layout_init(ctx, class_name, &obj_handle); // new.rs:1661 ✅ inline path tail
The None arm of call_local_constructor_symbol falls through to return Ok(obj_box) and never reaches either emit_typed_shape_layout_init call.
Reachability
call_local_constructor_symbol (new_ctor_args.rs:321) returns None exactly when ctx.methods has no (class.name, "<Class>_constructor") entry:
let ctor_name = ctx.methods.get(&(class.name.clone(), ctor_method_name)).cloned()?;
Two of the three branch conditions pre-check that the symbol exists, so they cannot reach the None arm:
force_ctor_call — requires local_constructor_symbol_exists(ctx, class) (new.rs:810)
ctor_alias_collision — requires local_constructor_symbol_exists(ctx, class) (new.rs:791)
The third does not:
ctx.class_stack.iter().any(|active| active == class_name) — the recursion guard for new Foo() issued while already lowering Foo. No symbol-existence check.
So the exit is reachable for a recursive new Foo() inside Foo's own constructor or a method of Foo, when Foo has no <Foo>_constructor entry in this module's ctx.methods (no own constructor, or a class whose ctor symbol wasn't emitted in this compilation unit).
I did not construct a runnable reproducer — this was identified statically while discharging a soundness obligation, and the correct response there was to stop depending on the premise rather than to rely on the path being rare. A reproducer would be worth writing as part of the fix.
Why it matters
js_object_alloc_class_inline_keys leaves a fresh instance at GC_LAYOUT_POINTER_FREE. Under that state, layout_note_slot (gc/layout.rs) is what promotes the object to GC_LAYOUT_SIDE_MASK and sets the per-slot pointer bit on the first pointer-valued field store:
LAYOUT_SLOT_MASKS.with(|m| {
let mut masks = m.borrow_mut();
if pointer {
if let Some(mask) = masks.get_mut(&parent_user) { mask.set_slot(slot_index); }
else if state == GC_LAYOUT_POINTER_FREE {
let mut mask = LayoutSlotMask::Inline(0);
mask.set_slot(slot_index);
masks.insert(parent_user, mask);
set_layout_state(header, GC_LAYOUT_SIDE_MASK);
} else { set_layout_state(header, GC_LAYOUT_UNKNOWN); }
}
...
});
With a descriptor installed, that bit is already published by init_typed_shape_layout (it inserts pointer_mask straight into LAYOUT_SLOT_MASKS and sets SIDE_MASK), so the note is a pure no-op for pointer-masked slots. Without one, the note is the only writer, and the collector would otherwise scan zero slots on an object that is POINTER_FREE by header but holds live children.
Today nothing is broken: the note is still emitted unconditionally at both class-field store sites, so the mask gets built the slow way. The gap is that the invariant "a user-class instance reaching a class-field store has a typed descriptor, or is UNKNOWN" does not hold, and any optimization that wants to lean on it is blocked.
Proposed fix
Emit emit_typed_shape_layout_init(ctx, class_name, &obj_handle) on the return Ok(obj_box) path too, so every new <user class> under a class_keys_globals entry either installs a descriptor or explicitly downgrades.
This should be safe by construction: init_typed_shape_layout already validates the live field words before promoting (each raw-f64 slot must hold raw-f64 bits, no pointer outside pointer_mask) and calls layout_set_typed_unknown — i.e. GC_LAYOUT_UNKNOWN, the conservative state — when they don't. On this path the constructor did not run, so the fields are TAG_UNDEFINED; for a class with any number field that fails layout_raw_f64_bits and the object lands in UNKNOWN rather than a wrong mask. That is a correct-but-conservative outcome and worth measuring (it trades a POINTER_FREE fast scan for a conservative one on this rare path).
Worth pairing with a codegen assertion or a debug-mode check that no new of a class with a keys global returns without one of the two emitters having run.
Why this is worth closing
It is the prerequisite for the full pointer-masked layout-note elision — the larger half of Phase 4b.1 in #6919, which had to be dropped to the value-only predicate because of this exit. With the invariant total, a class-field store on a Ptr<Shape>-proven receiver could retire js_gc_note_slot_layout for every value, not just provably-non-pointer ones, since layout_note_slot is a no-op for a pointer-masked slot under an intact descriptor and a no-op in UNKNOWN. That covers the Foo-typed / string-typed / number[]-typed field stores that currently still pay the call.
Refs: #6919 (docs/representation-selection-rfc.md §5.7 records the narrowed scope and names this exit as the blocker).
Summary
lower_new_implhas one exit that returns a freshly allocated class instance without emittingjs_gc_init_typed_shape_layout. Every othernewpath emits it. An instance produced through that exit is left atGC_LAYOUT_POINTER_FREEwith noTypedLayoutDescriptor, which is the one layout state wherelayout_note_slotis load-bearing for GC correctness rather than merely a precision hint.Found while proving the class-field store elision in #6919 (representation-selection Phase 4b.1). It did not cause a bug there — the elision was narrowed instead of being shipped on the unproven premise — but it blocks the larger win, so it is worth closing on its own.
The exit
crates/perry-codegen/src/lower_call/new.rs, the standalone-constructor-symbol branch:The
Nonearm ofcall_local_constructor_symbolfalls through toreturn Ok(obj_box)and never reaches eitheremit_typed_shape_layout_initcall.Reachability
call_local_constructor_symbol(new_ctor_args.rs:321) returnsNoneexactly whenctx.methodshas no(class.name, "<Class>_constructor")entry:Two of the three branch conditions pre-check that the symbol exists, so they cannot reach the
Nonearm:force_ctor_call— requireslocal_constructor_symbol_exists(ctx, class)(new.rs:810)ctor_alias_collision— requireslocal_constructor_symbol_exists(ctx, class)(new.rs:791)The third does not:
ctx.class_stack.iter().any(|active| active == class_name)— the recursion guard fornew Foo()issued while already loweringFoo. No symbol-existence check.So the exit is reachable for a recursive
new Foo()insideFoo's own constructor or a method ofFoo, whenFoohas no<Foo>_constructorentry in this module'sctx.methods(no own constructor, or a class whose ctor symbol wasn't emitted in this compilation unit).I did not construct a runnable reproducer — this was identified statically while discharging a soundness obligation, and the correct response there was to stop depending on the premise rather than to rely on the path being rare. A reproducer would be worth writing as part of the fix.
Why it matters
js_object_alloc_class_inline_keysleaves a fresh instance atGC_LAYOUT_POINTER_FREE. Under that state,layout_note_slot(gc/layout.rs) is what promotes the object toGC_LAYOUT_SIDE_MASKand sets the per-slot pointer bit on the first pointer-valued field store:With a descriptor installed, that bit is already published by
init_typed_shape_layout(it insertspointer_maskstraight intoLAYOUT_SLOT_MASKSand setsSIDE_MASK), so the note is a pure no-op for pointer-masked slots. Without one, the note is the only writer, and the collector would otherwise scan zero slots on an object that isPOINTER_FREEby header but holds live children.Today nothing is broken: the note is still emitted unconditionally at both class-field store sites, so the mask gets built the slow way. The gap is that the invariant "a user-class instance reaching a class-field store has a typed descriptor, or is
UNKNOWN" does not hold, and any optimization that wants to lean on it is blocked.Proposed fix
Emit
emit_typed_shape_layout_init(ctx, class_name, &obj_handle)on thereturn Ok(obj_box)path too, so everynew <user class>under aclass_keys_globalsentry either installs a descriptor or explicitly downgrades.This should be safe by construction:
init_typed_shape_layoutalready validates the live field words before promoting (each raw-f64 slot must hold raw-f64 bits, no pointer outsidepointer_mask) and callslayout_set_typed_unknown— i.e.GC_LAYOUT_UNKNOWN, the conservative state — when they don't. On this path the constructor did not run, so the fields areTAG_UNDEFINED; for a class with anynumberfield that failslayout_raw_f64_bitsand the object lands inUNKNOWNrather than a wrong mask. That is a correct-but-conservative outcome and worth measuring (it trades aPOINTER_FREEfast scan for a conservative one on this rare path).Worth pairing with a codegen assertion or a debug-mode check that no
newof a class with a keys global returns without one of the two emitters having run.Why this is worth closing
It is the prerequisite for the full pointer-masked layout-note elision — the larger half of Phase 4b.1 in #6919, which had to be dropped to the value-only predicate because of this exit. With the invariant total, a class-field store on a
Ptr<Shape>-proven receiver could retirejs_gc_note_slot_layoutfor every value, not just provably-non-pointer ones, sincelayout_note_slotis a no-op for a pointer-masked slot under an intact descriptor and a no-op inUNKNOWN. That covers theFoo-typed /string-typed /number[]-typed field stores that currently still pay the call.Refs: #6919 (
docs/representation-selection-rfc.md§5.7 records the narrowed scope and names this exit as the blocker).