From 8288938681faeca3520349b6bbc22f0a0d9c8bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 04:53:47 +0200 Subject: [PATCH] feat(codegen/runtime): stamp class ShapeIds at birth --- .../7916-eager-class-shape-stamping.md | 27 +++++++ crates/perry-codegen/src/codegen/closure.rs | 1 + crates/perry-codegen/src/codegen/entry.rs | 2 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 2 + crates/perry-codegen/src/codegen/mod.rs | 12 ++- .../perry-codegen/src/codegen/string_pool.rs | 16 ++++ crates/perry-codegen/src/expr/mod.rs | 5 ++ crates/perry-codegen/src/gc_call_effects.rs | 1 + .../src/lower_call/alloc_hot_tests.rs | 18 +++++ .../perry-codegen/src/lower_call/new_alloc.rs | 52 +++++++++++-- .../src/lower_call/scalar_method.rs | 75 ++++++++++--------- .../src/runtime_decls/strings.rs | 6 ++ crates/perry-codegen/src/typed_shape.rs | 14 ++++ crates/perry-runtime/src/object/alloc.rs | 43 ++++++++++- crates/perry-runtime/src/object/shapes.rs | 43 +++++++++++ 16 files changed, 272 insertions(+), 46 deletions(-) create mode 100644 changelog.d/7916-eager-class-shape-stamping.md diff --git a/changelog.d/7916-eager-class-shape-stamping.md b/changelog.d/7916-eager-class-shape-stamping.md new file mode 100644 index 0000000000..802d55b3a3 --- /dev/null +++ b/changelog.d/7916-eager-class-shape-stamping.md @@ -0,0 +1,27 @@ +### feat(codegen/runtime): stamp canonical class ShapeIds at birth + +Compiled class instances now receive the stable ShapeId of their canonical +keys array during allocation instead of waiting for the first by-name property +lookup to stamp them. Module initialization mints one id beside each rooted +`@perry_class_keys_*` global; both the inline bump allocator and the outlined +allocator load that scalar and install it in the header's shape word before the +object is published. Scalar-replaced receivers use the same path when they +materialize. + +This is #6759 C3 rung 2 and the next prerequisite for #7916's remaining header +shrink: rung 1 made the shape word valid for class instances, while this rung +makes it present from birth so a guard can depend on it without a lazy-stamp +window. The allocation-time parent id remains only as the ShapeId-exhaustion +fallback; inheritance already comes from the class registry after #7981. + +The object representation is deliberately unchanged in this rung: a two-field +literal remains **56 bytes** and `retain` remains **168 MB written for 48 MB of +numeric payload** (3.5x amplification). The next guard-migration/header-layout +rungs consume the invariant established here; this PR does not claim a memory +or wall-clock improvement by itself. Per-object storage added: **0 bytes**. + +Regression coverage proves both allocation forms consume the id minted at +module init, and a runtime test reads a fresh class instance's shape word before +any by-name access so lazy self-healing cannot make the test pass vacuously. + +Refs #6759 and #7916. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 9cd69552dc..21eb204c00 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -988,6 +988,7 @@ pub(super) fn compile_closure( shadow_slot_clears_after_stmt, arena_state_slot: None, class_keys_slots: HashMap::new(), + class_shape_slots: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index b416ea3219..3d639fb373 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -820,6 +820,7 @@ pub(super) fn compile_module_entry( shadow_slot_clears_after_stmt: main_shadow_slot_clears_after_stmt, arena_state_slot: None, class_keys_slots: HashMap::new(), + class_shape_slots: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), @@ -1490,6 +1491,7 @@ pub(super) fn compile_module_entry( shadow_slot_clears_after_stmt: init_shadow_slot_clears_after_stmt, arena_state_slot: None, class_keys_slots: HashMap::new(), + class_shape_slots: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 97d4e09921..ea9a50477a 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -800,6 +800,7 @@ pub(super) fn compile_function( temp_roots: crate::rooting::TempRootPool::default(), arena_state_slot: None, class_keys_slots: HashMap::new(), + class_shape_slots: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index eadaff919c..b323824185 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -513,6 +513,7 @@ pub(super) fn compile_method( shadow_slot_clears_after_stmt, arena_state_slot: None, class_keys_slots: HashMap::new(), + class_shape_slots: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), @@ -1577,6 +1578,7 @@ pub(super) fn compile_static_method( shadow_slot_clears_after_stmt, arena_state_slot: None, class_keys_slots: HashMap::new(), + class_shape_slots: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index a7acd5a1da..b9995a86e0 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -37,7 +37,7 @@ use perry_hir::Module as HirModule; use crate::module::LlModule; use crate::runtime_decls; use crate::strings::StringPool; -use crate::types::{LlvmType, DOUBLE, I64}; +use crate::types::{LlvmType, DOUBLE, I32, I64}; pub(crate) mod arguments; mod artifacts; @@ -841,6 +841,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> &mut used_class_keys_globals, ); llmod.add_internal_global(&global_name, I64, "0"); + llmod.add_internal_global( + &crate::typed_shape::shape_id_global_name_from_keys_global(&global_name), + I32, + "0", + ); // Build the packed-keys string. Format: each field name // followed by `\0`. Parent classes contribute their fields @@ -1019,6 +1024,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> &mut used_class_keys_globals, ); llmod.add_internal_global(&global_name, I64, "0"); + llmod.add_internal_global( + &crate::typed_shape::shape_id_global_name_from_keys_global(&global_name), + I32, + "0", + ); class_keys_globals_map.insert(c.name.clone(), global_name.clone()); let mut packed_keys = String::new(); let mut total_field_count = c.fields.len() as u32; diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 43821cc46f..3a3b0f61a4 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -475,6 +475,22 @@ pub(super) fn emit_string_pool( // the evacuation `try_rewrite_value` raw fallback already handle). let addr_i64 = blk.ptrtoint(&global_ref, I64); blk.call_void("js_gc_register_global_root", &[(I64, &addr_i64)]); + + // #6759 C3 rung 2: mint the canonical ShapeId beside the canonical + // keys array. Every compiled `new C()` path loads this immutable u32 + // and writes it into the receiver's shape word at birth. The keys + // global is registered first, so the shape record and every future + // instance refer to the rooted/rewriteable canonical array. + let shape_id = blk.call( + I32, + "js_object_shape_id_for_keys", + &[(I64, &arr), (I32, &fc_str)], + ); + let shape_global = format!( + "@{}", + crate::typed_shape::shape_id_global_name_from_keys_global(global_name) + ); + blk.store(I32, &shape_id, &shape_global); } // Register the parent-class chain for every class with a parent. diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 073b107640..d870e47ba1 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -794,6 +794,11 @@ pub(crate) struct FnCtx<'a> { /// subsequent sites for the same class load from the slot. pub class_keys_slots: std::collections::HashMap, + /// Per-class cached ShapeId global slots, paired one-for-one with + /// [`Self::class_keys_slots`]. Shape ids are scalar metadata rather than GC + /// pointers, so these entry-hoisted copies need no shadow-slot binding. + pub class_shape_slots: std::collections::HashMap, + /// Per-arr-local cached `arr.length` slots — populated by /// `lower_for` when it spots the well-known shape /// `for (...; i < arr.length; ...) { body }` and proves via diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 8c2491ec42..4c7dfea41b 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -123,6 +123,7 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { // probe gates backstop the audit. "js_closure_alloc_singleton" | "js_object_alloc_class_inline_keys" + | "js_object_alloc_class_inline_keys_stamped" | "js_array_push_f64" | "js_array_length" | "js_array_slice_values" diff --git a/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs b/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs index 7468e382ae..eec0d32b91 100644 --- a/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs +++ b/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs @@ -35,6 +35,12 @@ const INLINE_SLOW_CALL: &str = "call ptr @js_inline_arena_slow_alloc("; const INLINE_FAST_BLOCK: &str = "\nalloc.fast"; /// Emitted only by the outlined allocator. const OUTLINED_CALL: &str = "call i64 @js_object_alloc_class_inline_keys"; +/// Rung 2's outlined entry has an explicit ShapeId argument. +const STAMPED_OUTLINED_CALL: &str = "call i64 @js_object_alloc_class_inline_keys_stamped("; +/// One mint per class at module init, never per allocation. +const SHAPE_MINT_CALL: &str = "call i32 @js_object_shape_id_for_keys("; +/// The immutable id is hoisted to the function-entry setup like keys_array. +const SHAPE_GLOBAL_LOAD: &str = "load i32, ptr @perry_class_shape_id_"; const N_ID: u32 = 11; const WALK_ID: u32 = 700; @@ -296,6 +302,11 @@ fn a_self_recursive_function_inlines_its_bump_allocator() { "the outlined allocator is still emitted for the recursive function's \ only `new` site:\n{ir}" ); + assert!( + ir.contains(SHAPE_MINT_CALL) && ir.contains(SHAPE_GLOBAL_LOAD), + "the inline allocator did not consume the class ShapeId minted at module init; \ + newborn instances would keep the allocation-time parent word until a lazy lookup:\n{ir}" + ); } /// The anti-bloat half. Identical module minus the self-call: one call site, no @@ -315,6 +326,13 @@ fn a_cold_straight_line_function_keeps_the_outlined_allocator() { !ir.contains(INLINE_SLOW_CALL), "the inline bump allocator reached a cold site:\n{ir}" ); + assert!( + ir.contains(STAMPED_OUTLINED_CALL) + && ir.contains(SHAPE_MINT_CALL) + && ir.contains(SHAPE_GLOBAL_LOAD), + "the cold allocation did not pass its module-init ShapeId to the stamped \ + outlined allocator:\n{ir}" + ); } #[test] diff --git a/crates/perry-codegen/src/lower_call/new_alloc.rs b/crates/perry-codegen/src/lower_call/new_alloc.rs index 7073f7354f..c06407176d 100644 --- a/crates/perry-codegen/src/lower_call/new_alloc.rs +++ b/crates/perry-codegen/src/lower_call/new_alloc.rs @@ -19,7 +19,31 @@ use perry_hir::Class; use crate::expr::FnCtx; -use crate::types::{I32, I64, I8, PTR}; +use crate::types::{I1, I32, I64, I8, PTR}; + +/// Load the immutable ShapeId paired with a class's canonical keys global. +/// +/// As with `class_keys_slots`, cache it in a function-entry alloca: the inline +/// allocation slow path is an opaque runtime call, so LLVM will not reliably +/// hoist the module-global load out of a hot loop by itself. Unlike the keys +/// pointer this scalar is not a GC root and needs no shadow-slot binding. +pub(super) fn load_class_shape_id( + ctx: &mut FnCtx<'_>, + class_name: &str, + keys_global_name: &str, +) -> String { + let shape_slot = if let Some(slot) = ctx.class_shape_slots.get(class_name).cloned() { + slot + } else { + let shape_global = + crate::typed_shape::shape_id_global_name_from_keys_global(keys_global_name); + let slot = ctx.func.entry_init_load_global(&shape_global, I32); + ctx.class_shape_slots + .insert(class_name.to_string(), slot.clone()); + slot + }; + ctx.block().load(I32, &shape_slot) +} /// #7469: is the `new` site being lowered inside a **loop body**? /// @@ -336,19 +360,21 @@ fn emit_instance_alloc_inner( s }; let keys_ptr = ctx.block().load(I64, &keys_slot); + let shape_id = load_class_shape_id(ctx, class_name, &keys_global_name); ctx.pending_declares.push(( - "js_object_alloc_class_inline_keys".to_string(), + "js_object_alloc_class_inline_keys_stamped".to_string(), I64, - vec![I32, I32, I32, I64], + vec![I32, I32, I32, I64, I32], )); ctx.block().call( I64, - "js_object_alloc_class_inline_keys", + "js_object_alloc_class_inline_keys_stamped", &[ (I32, &cid_str), (I32, &parent_cid_str), (I32, &field_count.to_string()), (I64, &keys_ptr), + (I32, &shape_id), ], ) } else { @@ -455,6 +481,7 @@ fn emit_instance_alloc_inner( s }; let keys_ptr = ctx.block().load(I64, &keys_slot); + let shape_id = load_class_shape_id(ctx, class_name, &keys_global_name); // Inline bump-allocator IR. let blk = ctx.block(); @@ -540,10 +567,21 @@ fn emit_instance_alloc_inner( let oh_word_1: u64 = OBJECT_TYPE_REGULAR | ((cid as u64) << 32); blk.store(I64, &oh_word_1.to_string(), &oh_addr_1); - // Second 8 bytes: parent_class_id (u32, low) | field_count (u32, high) + // Second 8 bytes: ShapeId (u32, low) | field_count (u32, high). + // Rung 0 removed the last inheritance consumer of this word; the + // parent edge was registered during module init. Keep the old + // parent value only if the process-global ShapeId range was + // exhausted and init returned 0, preserving the lazy fallback. let oh_addr_2 = blk.gep(I8, &raw, &[(I64, "16")]); - let oh_word_2: u64 = (parent_cid as u64) | ((field_count as u64) << 32); - blk.store(I64, &oh_word_2.to_string(), &oh_addr_2); + let has_shape_id = blk.icmp_ne(I32, &shape_id, "0"); + let shape_word = blk.select(I1, &has_shape_id, I32, &shape_id, &parent_cid.to_string()); + let shape_word64 = blk.zext(I32, &shape_word, I64); + let oh_word_2 = blk.or( + I64, + &shape_word64, + &((field_count as u64) << 32).to_string(), + ); + blk.store(I64, &oh_word_2, &oh_addr_2); // Third 8 bytes: keys_array pointer. The keys_ptr we loaded // above is an i64 (carries the ArrayHeader address); store as diff --git a/crates/perry-codegen/src/lower_call/scalar_method.rs b/crates/perry-codegen/src/lower_call/scalar_method.rs index b6ecabc0ad..d1140def22 100644 --- a/crates/perry-codegen/src/lower_call/scalar_method.rs +++ b/crates/perry-codegen/src/lower_call/scalar_method.rs @@ -545,44 +545,47 @@ fn materialize_scalar_receiver( .and_then(|parent| ctx.class_ids.get(parent).copied()) .unwrap_or(0); let parent_class_id_str = parent_class_id.to_string(); - let (obj_handle, has_stable_keys) = - if let Some(keys_global_name) = ctx.class_keys_globals.get(class_name).cloned() { - let keys_slot = if let Some(slot) = ctx.class_keys_slots.get(class_name).cloned() { - slot - } else { - let slot = crate::expr::entry_init_load_rooted_global(ctx, &keys_global_name, I64); - ctx.class_keys_slots - .insert(class_name.to_string(), slot.clone()); - slot - }; - let keys_ptr = ctx.block().load(I64, &keys_slot); - ctx.pending_declares.push(( - "js_object_alloc_class_inline_keys".to_string(), - I64, - vec![I32, I32, I32, I64], - )); - let obj_handle = ctx.block().call( - I64, - "js_object_alloc_class_inline_keys", - &[ - (I32, &class_id_str), - (I32, &parent_class_id_str), - (I32, &field_count_str), - (I64, &keys_ptr), - ], - ); - emit_materialized_scalar_receiver_typed_shape_init(ctx, class_name, &obj_handle); - (obj_handle, true) + let (obj_handle, has_stable_keys) = if let Some(keys_global_name) = + ctx.class_keys_globals.get(class_name).cloned() + { + let keys_slot = if let Some(slot) = ctx.class_keys_slots.get(class_name).cloned() { + slot } else { - ( - ctx.block().call( - I64, - "js_object_alloc", - &[(I32, &class_id_str), (I32, &field_count_str)], - ), - false, - ) + let slot = crate::expr::entry_init_load_rooted_global(ctx, &keys_global_name, I64); + ctx.class_keys_slots + .insert(class_name.to_string(), slot.clone()); + slot }; + let keys_ptr = ctx.block().load(I64, &keys_slot); + let shape_id = super::new_alloc::load_class_shape_id(ctx, class_name, &keys_global_name); + ctx.pending_declares.push(( + "js_object_alloc_class_inline_keys_stamped".to_string(), + I64, + vec![I32, I32, I32, I64, I32], + )); + let obj_handle = ctx.block().call( + I64, + "js_object_alloc_class_inline_keys_stamped", + &[ + (I32, &class_id_str), + (I32, &parent_class_id_str), + (I32, &field_count_str), + (I64, &keys_ptr), + (I32, &shape_id), + ], + ); + emit_materialized_scalar_receiver_typed_shape_init(ctx, class_name, &obj_handle); + (obj_handle, true) + } else { + ( + ctx.block().call( + I64, + "js_object_alloc", + &[(I32, &class_id_str), (I32, &field_count_str)], + ), + false, + ) + }; for (field, slot) in field_slots { let value = ctx.block().load(DOUBLE, &slot); diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index d7e9289f6e..b7623ca79a 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1027,7 +1027,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { I64, &[I32, I32, I32, I64], ); + module.declare_function( + "js_object_alloc_class_inline_keys_stamped", + I64, + &[I32, I32, I32, I64, I32], + ); module.declare_function("js_build_class_keys_array", I64, &[I32, I32, PTR, I32]); + module.declare_function("js_object_shape_id_for_keys", I32, &[I64, I32]); // Inline bump-allocator state accessor + slow path. The codegen // calls `js_inline_arena_state` once per JS function entry, caches // the returned pointer in a stack slot, and reads/writes the diff --git a/crates/perry-codegen/src/typed_shape.rs b/crates/perry-codegen/src/typed_shape.rs index 34e9ba6807..bddd3b8f1d 100644 --- a/crates/perry-codegen/src/typed_shape.rs +++ b/crates/perry-codegen/src/typed_shape.rs @@ -369,3 +369,17 @@ pub(crate) fn raw_f64_mask_global_name_from_keys_global(keys_global_name: &str) .map(|suffix| format!("perry_typed_shape_raw_f64_mask_{}", suffix)) .unwrap_or_else(|| format!("perry_typed_shape_raw_f64_mask_{}", keys_global_name)) } + +/// The module-global ShapeId paired with one canonical class keys array. +/// +/// Keeping the name derived from the already-unique keys global means aliases +/// and sanitized-name collisions necessarily share the same pair. The id is +/// minted once, immediately after `js_build_class_keys_array`, and loaded by +/// every compiled construction path so class instances arrive birth-stamped +/// instead of waiting for their first by-name lookup (#6759 C3 rung 2). +pub(crate) fn shape_id_global_name_from_keys_global(keys_global_name: &str) -> String { + keys_global_name + .strip_prefix("perry_class_keys_") + .map(|suffix| format!("perry_class_shape_id_{}", suffix)) + .unwrap_or_else(|| format!("perry_class_shape_id_{}", keys_global_name)) +} diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index deb1a624f8..b153d20cb5 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -240,8 +240,8 @@ pub extern "C" fn js_object_alloc_fast_with_parent( /// (`PERRY_LLVM_BITCODE_LINK=1`) inline the entire body — including /// the `arena_alloc_gc` call — into the user's `new ClassName()` /// site, eliminating function-call overhead from the hot loop. -#[no_mangle] -pub extern "C" fn js_object_alloc_class_inline_keys( +#[inline] +fn object_alloc_class_inline_keys_impl( class_id: u32, parent_class_id: u32, field_count: u32, @@ -295,6 +295,45 @@ pub extern "C" fn js_object_alloc_class_inline_keys( ptr } +/// Compatibility entry point for runtime callers that do not have a +/// module-init ShapeId. Their first by-name resolve retains rung 1's lazy +/// self-heal; compiled allocations use the stamped entry point below. +#[no_mangle] +pub extern "C" fn js_object_alloc_class_inline_keys( + class_id: u32, + parent_class_id: u32, + field_count: u32, + keys_array: *mut ArrayHeader, +) -> *mut ObjectHeader { + object_alloc_class_inline_keys_impl(class_id, parent_class_id, field_count, keys_array) +} + +/// The compiled-class allocation entry point after #6759 C3 rung 2. +/// +/// `shape_id` is minted once from the same canonical `keys_array` at module +/// initialization. Installing it after the existing allocator returns keeps +/// every allocation/rooting/layout invariant above in one implementation, +/// while making a fresh class instance immediately usable by ShapeId guards. +/// A zero/exhausted id preserves the allocation-time parent word and therefore +/// falls back to the pre-rung-2 lazy-stamping behavior. +#[no_mangle] +pub extern "C" fn js_object_alloc_class_inline_keys_stamped( + class_id: u32, + parent_class_id: u32, + field_count: u32, + keys_array: *mut ArrayHeader, + shape_id: u32, +) -> *mut ObjectHeader { + let ptr = + object_alloc_class_inline_keys_impl(class_id, parent_class_id, field_count, keys_array); + if crate::object::shapes::is_shape_id(shape_id) { + unsafe { + (*ptr).parent_class_id = shape_id; + } + } + ptr +} + /// Build (or fetch from SHAPE_CACHE) the keys_array for a class. /// Called ONCE per class at module init time; the resulting pointer /// is cached in a per-class global by the codegen and then passed diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index b964cfd118..ff20e1895e 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -126,6 +126,17 @@ pub(crate) fn shape_id_for_keys_ensure(keys: *const ArrayHeader, key_count: u32) .shape_id } +/// Mint (or retrieve) the stable ShapeId paired with a canonical keys array. +/// +/// Codegen calls this once per class during module initialization and stores +/// the result beside `@perry_class_keys_*`. It deliberately takes a raw u64 +/// rather than `*const ArrayHeader`: Perry's textual LLVM ABI represents the +/// rooted keys global as an integer heap word on every target. +#[no_mangle] +pub extern "C" fn js_object_shape_id_for_keys(keys: u64, key_count: u32) -> u32 { + shape_id_for_keys_ensure(keys as usize as *const ArrayHeader, key_count) +} + // --------------------------------------------------------------------------- // #6759 C3 rung 1 — THE SHAPE WORD IS UNIFORM. // @@ -454,6 +465,38 @@ mod c3c_tests { shape_drop(b as *const ArrayHeader); } + /// #6759 C3 rung 2: the codegen-facing allocator receives the id minted + /// beside its canonical keys global and installs it before the newborn + /// instance is published to user code. No by-name lookup is allowed in + /// this fixture: observing a stamp therefore proves it was present at + /// birth rather than lazily self-healed by rung 1. + #[test] + fn compiled_class_allocator_stamps_the_canonical_shape_at_birth() { + let _lock = crate::gc::global_side_table_test_lock(); + const CID: u32 = 0x0C3C_7902; + let packed = b"birth_a\0birth_b"; + let keys = + crate::object::js_build_class_keys_array(CID, 2, packed.as_ptr(), packed.len() as u32); + let shape_id = js_object_shape_id_for_keys(keys as usize as u64, 2); + assert!( + is_shape_id(shape_id), + "module init must mint a real ShapeId" + ); + + let obj = + crate::object::js_object_alloc_class_inline_keys_stamped(CID, 0, 2, keys, shape_id); + let birth_word = unsafe { (*obj).parent_class_id }; + assert_eq!( + birth_word, shape_id, + "a fresh compiled class instance waited for a by-name lookup to stamp" + ); + assert_eq!( + unsafe { (*obj).keys_array }, + keys, + "the stamp and canonical keys global must describe the same shape" + ); + } + /// #6759 C3c stamp invariant on a REAL object through the real /// write/read paths: a read resolution stamps a shape id into the /// plain object's `parent_class_id`; after further appends the stamp