From 1f3efe914d0cc4039b7b9f3be1f56982009c2861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 17 Jun 2026 06:47:58 +0200 Subject: [PATCH 1/4] perf(codegen): outline per-new-site inline allocator (smaller IR + faster) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `new C()` site inlined ~50 lines of bump-allocator IR (load arena state, bump offset, fast/slow/merge, write GC+object headers, zero-fill slots) — all per-class compile-time constants, identical across sites. On a 13MB minified bundle this is a dominant source of codegen bloat (millions of IR lines). Replace the per-site inline bump with a single call to the existing runtime `js_object_alloc_class_inline_keys`, which performs the identical alloc + header init + slot zero-fill. Default on; opt back into the inline path with PERRY_INLINE_NEW=1. Measured (8M-allocation loop, -O2): inline 7030ms -> outline 5832ms (~17% FASTER), and -45 IR lines per new-site. The inline bump was a pessimization at scale — it bloated the hot loop, hurting icache/regalloc/LLVM-opt more than the saved call. perry-codegen suite green on the default (outline) path; output matches Node for fields, inheritance (super), and arrays of instances. --- crates/perry-codegen/src/lower_call/new.rs | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index ca7c287f8..96992e01d 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -565,6 +565,43 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> ], ) } else if let Some(keys_global_name) = ctx.class_keys_globals.get(class_name).cloned() { + if std::env::var_os("PERRY_INLINE_NEW").is_none() { + // [#bloat] Default: outline the per-`new`-site allocator. Opt back + // into the old inline bump-allocator with PERRY_INLINE_NEW=1. + // Measured win-win vs inline: −45 IR lines/site AND ~17% faster on an + // 8M-allocation loop (the inline bump bloated the hot loop, hurting + // icache/regalloc more than the saved call). Outline the per-`new`-site + // inline bump-allocator (~145 lines of per-class-constant IR) into a + // single call to the runtime `js_object_alloc_class_inline_keys`, + // which performs the identical bump alloc + header init + slot + // zero-fill and returns the same user pointer (as i64). Cuts ~145 IR + // lines per `new` site to ~3. Only the per-class keys-array global is + // loaded (cached per function, same as the inline path). + let keys_slot = if let Some(s) = ctx.class_keys_slots.get(class_name).cloned() { + s + } else { + let s = ctx.func.entry_init_load_global(&keys_global_name, I64); + ctx.class_keys_slots + .insert(class_name.to_string(), s.clone()); + s + }; + 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], + )); + ctx.block().call( + I64, + "js_object_alloc_class_inline_keys", + &[ + (I32, &cid_str), + (I32, &parent_cid_str), + (I32, &field_count.to_string()), + (I64, &keys_ptr), + ], + ) + } else { // Compile-time layout constants. const GC_HEADER_SIZE: u64 = 8; const OBJECT_HEADER_SIZE: u64 = 24; @@ -730,6 +767,7 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> // the existing nanbox_pointer_inline expects. let user_ptr = blk.gep(I8, &raw, &[(I64, "8")]); blk.ptrtoint(&user_ptr, I64) + } } else { // Fallback: build the packed-keys string at this site and // call the slower SHAPE_CACHE-aware allocator. Used when the From e903f38e0e58104d436fe602be59bbd85f007072 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 07:59:22 +0000 Subject: [PATCH 2/4] fix(runtime): zero-fill slots in js_object_alloc_class_inline_keys; fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outlined `new C()` allocator path (default since this PR) calls js_object_alloc_class_inline_keys, which initialized only the object header and left the field slots holding recycled arena bytes. The inline bump path (PERRY_INLINE_NEW=1) and json/parser.rs both zero-fill by hand precisely because the helper didn't — so the new default path regressed: a field read-before-write, or a GC scan of a still-constructing instance, could observe stale bytes from a previously-freed object (the #4717 `marked` "Cannot read properties of undefined" failure mode). Fold the slot zero-fill into the helper itself (mirroring js_object_alloc_with_parent) so every caller — inline path, JSON parser, class_registry, and the outlined codegen path — is correct by construction. Verified the outlined path now matches Node and the PERRY_INLINE_NEW=1 path for plain fields, inheritance, and read-before-write. Also reindent the inline branch so `cargo fmt --check` (the failing lint job) passes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011CDqAsmTvG7kwRTE1YPTZL --- crates/perry-codegen/src/lower_call/new.rs | 324 ++++++++++----------- crates/perry-runtime/src/object/alloc.rs | 15 + 2 files changed, 177 insertions(+), 162 deletions(-) diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 96992e01d..53e00c50e 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -602,171 +602,171 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> ], ) } else { - // Compile-time layout constants. - const GC_HEADER_SIZE: u64 = 8; - const OBJECT_HEADER_SIZE: u64 = 24; - const FIELD_SLOT_SIZE: u64 = 8; - const MIN_FIELD_SLOTS: u64 = 8; - const GC_TYPE_OBJECT: u64 = 2; - const GC_FLAG_ARENA: u64 = 0x02; - // PR #1146: pointer-free hint for inline-allocated regular - // objects. The field-store sites issue per-slot - // `js_gc_note_slot_layout` so the GC sees real pointer-bearing - // slots regardless of this initial tag. - const GC_LAYOUT_POINTER_FREE: u64 = 0x4000; - const OBJECT_TYPE_REGULAR: u64 = 1; - - let alloc_field_count = std::cmp::max(field_count as u64, MIN_FIELD_SLOTS); - let payload_size = OBJECT_HEADER_SIZE + alloc_field_count * FIELD_SLOT_SIZE; - let total_size = GC_HEADER_SIZE + payload_size; // e.g. 96 for any class with ≤8 fields - let total_size_str = total_size.to_string(); - - // Lazy: allocate the per-function arena-state slot on the - // first `new` we see. The slot init (`call @js_inline_arena_state` - // + store) lives in the entry block via `entry_init_call_ptr`, - // so it dominates every reachable use. - let arena_state_slot = if let Some(slot) = ctx.arena_state_slot.clone() { - slot - } else { - let slot = ctx.func.entry_init_call_ptr("js_inline_arena_state"); - ctx.arena_state_slot = Some(slot.clone()); - slot - }; + // Compile-time layout constants. + const GC_HEADER_SIZE: u64 = 8; + const OBJECT_HEADER_SIZE: u64 = 24; + const FIELD_SLOT_SIZE: u64 = 8; + const MIN_FIELD_SLOTS: u64 = 8; + const GC_TYPE_OBJECT: u64 = 2; + const GC_FLAG_ARENA: u64 = 0x02; + // PR #1146: pointer-free hint for inline-allocated regular + // objects. The field-store sites issue per-slot + // `js_gc_note_slot_layout` so the GC sees real pointer-bearing + // slots regardless of this initial tag. + const GC_LAYOUT_POINTER_FREE: u64 = 0x4000; + const OBJECT_TYPE_REGULAR: u64 = 1; + + let alloc_field_count = std::cmp::max(field_count as u64, MIN_FIELD_SLOTS); + let payload_size = OBJECT_HEADER_SIZE + alloc_field_count * FIELD_SLOT_SIZE; + let total_size = GC_HEADER_SIZE + payload_size; // e.g. 96 for any class with ≤8 fields + let total_size_str = total_size.to_string(); + + // Lazy: allocate the per-function arena-state slot on the + // first `new` we see. The slot init (`call @js_inline_arena_state` + // + store) lives in the entry block via `entry_init_call_ptr`, + // so it dominates every reachable use. + let arena_state_slot = if let Some(slot) = ctx.arena_state_slot.clone() { + slot + } else { + let slot = ctx.func.entry_init_call_ptr("js_inline_arena_state"); + ctx.arena_state_slot = Some(slot.clone()); + slot + }; - // Hoist the per-class `keys_array` global load to the function - // entry block (cached in a stack slot per class). Without this - // hoisting, LLVM would reload `@perry_class_keys_` on - // every loop iteration, because the loop body's `call - // @js_inline_arena_slow_alloc` blocks LICM — LLVM can't prove - // the call doesn't modify the global. - let keys_slot = if let Some(s) = ctx.class_keys_slots.get(class_name).cloned() { - s - } else { - let s = ctx.func.entry_init_load_global(&keys_global_name, I64); - ctx.class_keys_slots - .insert(class_name.to_string(), s.clone()); - s - }; - let keys_ptr = ctx.block().load(I64, &keys_slot); - - // Inline bump-allocator IR. - let blk = ctx.block(); - let state_ptr = blk.load(PTR, &arena_state_slot); - - // offset = state.offset (at byte offset 8 in InlineArenaState). - // The offset is invariant 8-aligned: arena blocks start at offset 0 - // (8-aligned), every allocation is a multiple of 8 (`total_size` - // includes the 8-byte GcHeader and `MIN_FIELD_SLOTS=8` slots × - // 8 bytes), and `js_inline_arena_slow_alloc` only ever swings the - // state to `block.offset` which is also always 8-aligned. So we - // skip the `(offset + 7) & -8` align-up step entirely — saves - // 2 instructions per iter on the hot path. - let offset_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "8")]); - let offset_val = blk.load(I64, &offset_field_ptr); - let aligned_off = offset_val.clone(); - - // new_offset = aligned + total_size - let new_offset = blk.add(I64, &aligned_off, &total_size_str); - - // size = state.size (at byte offset 16) - let size_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "16")]); - let size_val = blk.load(I64, &size_field_ptr); - - // fits = new_offset <= size - let fits = blk.icmp_ule(I64, &new_offset, &size_val); - - // Set up fast/slow/merge basic blocks. - let fast_idx = ctx.new_block("alloc.fast"); - let slow_idx = ctx.new_block("alloc.slow"); - let merge_idx = ctx.new_block("alloc.merge"); - let fast_label = ctx.block_label(fast_idx); - let slow_label = ctx.block_label(slow_idx); - let merge_label = ctx.block_label(merge_idx); - - ctx.block().cond_br(&fits, &fast_label, &slow_label); - - // ---- Fast path: bump and return data + aligned ---- - ctx.current_block = fast_idx; - let blk = ctx.block(); - // GC_STORE_AUDIT(INIT): inline arena bump offset is allocator metadata, not a JS heap edge. - blk.store(I64, &new_offset, &offset_field_ptr); - // data ptr is at byte offset 0 in InlineArenaState - let data_ptr = blk.load(PTR, &state_ptr); - let raw_fast = blk.gep(I8, &data_ptr, &[(I64, &aligned_off)]); - let fast_pred_label = blk.label.clone(); - blk.br(&merge_label); - - // ---- Slow path: call into the runtime ---- - ctx.current_block = slow_idx; - let raw_slow = ctx.block().call( - PTR, - "js_inline_arena_slow_alloc", - &[(PTR, &state_ptr), (I64, &total_size_str), (I64, "8")], - ); - let slow_pred_label = ctx.block().label.clone(); - ctx.block().br(&merge_label); - - // ---- Merge: phi the raw pointer, write headers, NaN-box ---- - ctx.current_block = merge_idx; - let blk = ctx.block(); - let raw = blk.phi( - PTR, - &[(&raw_fast, &fast_pred_label), (&raw_slow, &slow_pred_label)], - ); + // Hoist the per-class `keys_array` global load to the function + // entry block (cached in a stack slot per class). Without this + // hoisting, LLVM would reload `@perry_class_keys_` on + // every loop iteration, because the loop body's `call + // @js_inline_arena_slow_alloc` blocks LICM — LLVM can't prove + // the call doesn't modify the global. + let keys_slot = if let Some(s) = ctx.class_keys_slots.get(class_name).cloned() { + s + } else { + let s = ctx.func.entry_init_load_global(&keys_global_name, I64); + ctx.class_keys_slots + .insert(class_name.to_string(), s.clone()); + s + }; + let keys_ptr = ctx.block().load(I64, &keys_slot); - // Write GcHeader (8 bytes) as a single i64 store. Field - // packing (little-endian): - // bits 0..7 = obj_type (u8) - // bits 8..15 = gc_flags (u8) - // bits 16..31 = _reserved (u16) - // bits 32..63 = size (u32) - let gc_packed: u64 = GC_TYPE_OBJECT - | (GC_FLAG_ARENA << 8) - | (GC_LAYOUT_POINTER_FREE << 16) - | ((total_size as u64) << 32); - // GC_STORE_AUDIT(INIT): inline headers initialize freshly allocated unpublished object storage. - blk.store(I64, &gc_packed.to_string(), &raw); - - // Write ObjectHeader at raw + 8. - // First 8 bytes: object_type (u32, low) | class_id (u32, high) - let oh_addr_1 = blk.gep(I8, &raw, &[(I64, "8")]); - 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) - 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); - - // Third 8 bytes: keys_array pointer. The keys_ptr we loaded - // above is an i64 (carries the ArrayHeader address); store as - // i64 since the underlying memory is 8 bytes either way. - let oh_addr_3 = blk.gep(I8, &raw, &[(I64, "24")]); - // GC_STORE_AUDIT(INIT): keys_array edge is installed before publishing the new object. - blk.store(I64, &keys_ptr, &oh_addr_3); - - // PerryTS/perry#4717: zero-fill the field slots with `undefined`, mirroring - // `js_object_alloc_with_parent` (runtime object/alloc.rs), which deliberately - // initializes ALL `max(field_count, 8)` slots "to prevent stale data from - // previously freed GC objects from bleeding through." This inline bump path - // wrote only the headers and left the slots uninitialized, so a field - // read-before-write — or a GC that scans the still-constructing instance — - // observed stale arena bytes. When those bytes were a previously-freed - // `undefined`/pointer (e.g. `marked`'s `this.defaults`), the constructor - // crashed with "Cannot read properties of undefined". Slots start at - // raw + GcHeader(8) + ObjectHeader(24) = raw + 32. - for i in 0..alloc_field_count { - let slot_off = GC_HEADER_SIZE + OBJECT_HEADER_SIZE + i * FIELD_SLOT_SIZE; - let slot_ptr = blk.gep(I8, &raw, &[(I64, &slot_off.to_string())]); - // GC_STORE_AUDIT(INIT): freshly allocated inline object slot initialized to undefined. - blk.store(I64, crate::nanbox::TAG_UNDEFINED_I64, &slot_ptr); - } + // Inline bump-allocator IR. + let blk = ctx.block(); + let state_ptr = blk.load(PTR, &arena_state_slot); + + // offset = state.offset (at byte offset 8 in InlineArenaState). + // The offset is invariant 8-aligned: arena blocks start at offset 0 + // (8-aligned), every allocation is a multiple of 8 (`total_size` + // includes the 8-byte GcHeader and `MIN_FIELD_SLOTS=8` slots × + // 8 bytes), and `js_inline_arena_slow_alloc` only ever swings the + // state to `block.offset` which is also always 8-aligned. So we + // skip the `(offset + 7) & -8` align-up step entirely — saves + // 2 instructions per iter on the hot path. + let offset_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "8")]); + let offset_val = blk.load(I64, &offset_field_ptr); + let aligned_off = offset_val.clone(); + + // new_offset = aligned + total_size + let new_offset = blk.add(I64, &aligned_off, &total_size_str); + + // size = state.size (at byte offset 16) + let size_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "16")]); + let size_val = blk.load(I64, &size_field_ptr); + + // fits = new_offset <= size + let fits = blk.icmp_ule(I64, &new_offset, &size_val); + + // Set up fast/slow/merge basic blocks. + let fast_idx = ctx.new_block("alloc.fast"); + let slow_idx = ctx.new_block("alloc.slow"); + let merge_idx = ctx.new_block("alloc.merge"); + let fast_label = ctx.block_label(fast_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + + ctx.block().cond_br(&fits, &fast_label, &slow_label); + + // ---- Fast path: bump and return data + aligned ---- + ctx.current_block = fast_idx; + let blk = ctx.block(); + // GC_STORE_AUDIT(INIT): inline arena bump offset is allocator metadata, not a JS heap edge. + blk.store(I64, &new_offset, &offset_field_ptr); + // data ptr is at byte offset 0 in InlineArenaState + let data_ptr = blk.load(PTR, &state_ptr); + let raw_fast = blk.gep(I8, &data_ptr, &[(I64, &aligned_off)]); + let fast_pred_label = blk.label.clone(); + blk.br(&merge_label); + + // ---- Slow path: call into the runtime ---- + ctx.current_block = slow_idx; + let raw_slow = ctx.block().call( + PTR, + "js_inline_arena_slow_alloc", + &[(PTR, &state_ptr), (I64, &total_size_str), (I64, "8")], + ); + let slow_pred_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + // ---- Merge: phi the raw pointer, write headers, NaN-box ---- + ctx.current_block = merge_idx; + let blk = ctx.block(); + let raw = blk.phi( + PTR, + &[(&raw_fast, &fast_pred_label), (&raw_slow, &slow_pred_label)], + ); + + // Write GcHeader (8 bytes) as a single i64 store. Field + // packing (little-endian): + // bits 0..7 = obj_type (u8) + // bits 8..15 = gc_flags (u8) + // bits 16..31 = _reserved (u16) + // bits 32..63 = size (u32) + let gc_packed: u64 = GC_TYPE_OBJECT + | (GC_FLAG_ARENA << 8) + | (GC_LAYOUT_POINTER_FREE << 16) + | ((total_size as u64) << 32); + // GC_STORE_AUDIT(INIT): inline headers initialize freshly allocated unpublished object storage. + blk.store(I64, &gc_packed.to_string(), &raw); + + // Write ObjectHeader at raw + 8. + // First 8 bytes: object_type (u32, low) | class_id (u32, high) + let oh_addr_1 = blk.gep(I8, &raw, &[(I64, "8")]); + 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) + 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); + + // Third 8 bytes: keys_array pointer. The keys_ptr we loaded + // above is an i64 (carries the ArrayHeader address); store as + // i64 since the underlying memory is 8 bytes either way. + let oh_addr_3 = blk.gep(I8, &raw, &[(I64, "24")]); + // GC_STORE_AUDIT(INIT): keys_array edge is installed before publishing the new object. + blk.store(I64, &keys_ptr, &oh_addr_3); + + // PerryTS/perry#4717: zero-fill the field slots with `undefined`, mirroring + // `js_object_alloc_with_parent` (runtime object/alloc.rs), which deliberately + // initializes ALL `max(field_count, 8)` slots "to prevent stale data from + // previously freed GC objects from bleeding through." This inline bump path + // wrote only the headers and left the slots uninitialized, so a field + // read-before-write — or a GC that scans the still-constructing instance — + // observed stale arena bytes. When those bytes were a previously-freed + // `undefined`/pointer (e.g. `marked`'s `this.defaults`), the constructor + // crashed with "Cannot read properties of undefined". Slots start at + // raw + GcHeader(8) + ObjectHeader(24) = raw + 32. + for i in 0..alloc_field_count { + let slot_off = GC_HEADER_SIZE + OBJECT_HEADER_SIZE + i * FIELD_SLOT_SIZE; + let slot_ptr = blk.gep(I8, &raw, &[(I64, &slot_off.to_string())]); + // GC_STORE_AUDIT(INIT): freshly allocated inline object slot initialized to undefined. + blk.store(I64, crate::nanbox::TAG_UNDEFINED_I64, &slot_ptr); + } - // User pointer = raw + 8 (the ObjectHeader address — what the - // function-call path returned). Convert to i64 to match what - // the existing nanbox_pointer_inline expects. - let user_ptr = blk.gep(I8, &raw, &[(I64, "8")]); - blk.ptrtoint(&user_ptr, I64) + // User pointer = raw + 8 (the ObjectHeader address — what the + // function-call path returned). Convert to i64 to match what + // the existing nanbox_pointer_inline expects. + let user_ptr = blk.gep(I8, &raw, &[(I64, "8")]); + blk.ptrtoint(&user_ptr, I64) } } else { // Fallback: build the packed-keys string at this site and diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 7a4188101..682dfe6be 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -238,6 +238,21 @@ pub extern "C" fn js_object_alloc_class_inline_keys( (*ptr).parent_class_id = parent_class_id; (*ptr).field_count = field_count; set_object_keys_array(ptr, keys_array); + + // PerryTS/perry#4717: initialize ALL `max(field_count, 8)` field slots to + // `undefined`, mirroring `js_object_alloc_with_parent`. The arena hands back + // recycled bytes, so without this a field read-before-write — or a GC that + // scans the still-constructing instance — would observe stale arena bytes + // from a previously-freed object (e.g. `marked`'s `this.defaults` crashing + // with "Cannot read properties of undefined"). This used to be the caller's + // job (the inline bump path and `json/parser.rs` both zero-filled by hand); + // folding it in here keeps every caller — including the outlined `new C()` + // codegen path — correct by construction. + let fields_ptr = (ptr as *mut u8).add(header_size) as *mut JSValue; + for i in 0..alloc_field_count { + // GC_STORE_AUDIT(INIT): freshly allocated object field slot initialized to undefined. + ptr::write(fields_ptr.add(i), JSValue::undefined()); + } crate::gc::layout_init_pointer_free(ptr as *mut u8); } ptr From fe26a809729a9c818553a3ab7143fc2d6daffcbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 17 Jun 2026 13:12:44 +0200 Subject: [PATCH 3/4] refactor(codegen): split field-init helpers out of new.rs to clear 2000-LOC gate The per-new-site allocator outlining pushed lower_call/new.rs to 2038 lines, tripping the check_file_size.sh CI gate. Move FieldInitMode + the apply_field_initializers_recursive walker (pure move, no behavior change) into a sibling field_init.rs and re-export from mod.rs; new.rs drops to 1736. --- .../src/lower_call/field_init.rs | 316 ++++++++++++++++++ crates/perry-codegen/src/lower_call/mod.rs | 7 +- crates/perry-codegen/src/lower_call/new.rs | 310 +---------------- 3 files changed, 324 insertions(+), 309 deletions(-) create mode 100644 crates/perry-codegen/src/lower_call/field_init.rs diff --git a/crates/perry-codegen/src/lower_call/field_init.rs b/crates/perry-codegen/src/lower_call/field_init.rs new file mode 100644 index 000000000..483ec7907 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/field_init.rs @@ -0,0 +1,316 @@ +//! Recursive field-initializer application for `new ClassName(...)`. +//! +//! Extracted from `new.rs` (pure move, no behavior change) to keep that +//! file under the 2,000-LOC CI size gate. Holds the `FieldInitMode` enum +//! and `apply_field_initializers_recursive`, which walks a class's +//! inheritance chain and installs each class's field initializers onto +//! `this` per the requested mode. + +use anyhow::Result; +use perry_hir::Expr; + +use crate::expr::{lower_expr, FnCtx}; +use crate::nanbox::{double_literal, POINTER_MASK_I64}; +use crate::types::{DOUBLE, I32, I64}; + +/// Walk the inheritance chain from the root down and apply each class's +/// field initializers to `this`. Call this inside `lower_new` after the +/// `this` slot is pushed but before the constructor body is inlined. +/// +/// Initializers run in declaration order: root parent first, then each +/// child, matching JavaScript / TypeScript class semantics where fields +/// are initialized before user-written constructor code executes (field +/// initializers are conceptually prepended to the constructor body). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum FieldInitMode { + /// Apply field initializers for the entire chain root → leaf. + All, + /// Apply only the ancestors' field initializers (skip the leaf class). + /// Used to set up parent fields before a parent ctor body runs. + AncestorsOnly, + /// Apply only the named class's own field initializers (skip ancestors). + /// Used after a parent ctor body has run to install the leaf's fields, + /// which may reference state set by the parent body (e.g. + /// `enumValues = this.config.enumValues` in drizzle's PgText). Refs #420. + SelfOnly, + /// Issue #631-followup: apply fields for the chain root → `stop_at` + /// (inclusive). Used in the no-own-ctor path BEFORE the inherited- + /// ctor body runs, so only the inherited-ctor class's chain has its + /// fields set up. Intermediate classes between `stop_at` and the leaf + /// (e.g. SQLiteBaseInteger between SQLiteColumn and SQLiteInteger) + /// have their fields applied AFTER the inherited-ctor body, via + /// `BetweenExclusiveTo`. + UpToInclusive(String), + /// Apply fields for chain (`stop_at` exclusive) → leaf (inclusive). + /// Mirror of `UpToInclusive` for the post-body chain. Skips + /// `stop_at` itself because that class's SelfOnly fields are + /// applied via the SuperCall site inside the inlined body. + BetweenExclusiveTo(String), + /// Apply every class after the root ancestor through the leaf. Used + /// when a default-derived constructor chain has no explicit inherited + /// constructor body, so there is no SuperCall site to apply intermediate + /// class fields. + AfterRoot, +} + +pub(crate) fn apply_field_initializers_recursive( + ctx: &mut FnCtx<'_>, + class_name: &str, + mode: FieldInitMode, +) -> Result<()> { + // Issue #26 / #321: prefer the authoritative, source-prefix-disambiguated + // ancestor chain (built once in `compile_module` alongside the per-class + // keys global). Walking `ctx.classes` by `extends_name` mis-resolves + // same-named cross-module parents (effect's `Type` in SchemaAST.ts vs + // ParseResult.ts) and writes that wrong parent's fields onto the instance + // as `undefined`, surfacing as spurious enumerable keys (`_tag,ast,actual, + // message` on a `PropertySignature`). The authoritative chain is root → + // leaf and carries each ancestor's resolved fields, so we use both its + // ORDER (for the mode filter) and its FIELDS (per class below). + let mut chain_field_override: std::collections::HashMap> = + std::collections::HashMap::new(); + // Collect the inheritance chain from root down. + let mut chain: Vec = Vec::new(); + if let Some(auth) = ctx.class_init_chains.get(class_name) { + for (name, fields) in auth { + chain.push(name.clone()); + chain_field_override.insert(name.clone(), fields.clone()); + } + } else { + let mut cur = Some(class_name.to_string()); + while let Some(c) = cur { + let Some(class) = ctx.classes.get(&c).copied() else { + break; + }; + chain.push(c.clone()); + cur = class.extends_name.clone(); + } + chain.reverse(); + } + + // Apply mode filter: + // All: keep entire chain + // AncestorsOnly: drop the leaf (last entry) + // SelfOnly: keep only the leaf + // UpToInclusive(stop_at): keep chain[0..=index_of(stop_at)] + // BetweenExclusiveTo(stop_at): keep chain[index_of(stop_at)+1..] + // AfterRoot: keep chain[1..] + let chain: Vec = match &mode { + FieldInitMode::All => chain, + FieldInitMode::AncestorsOnly => { + // Issue #631-followup: keep only the ROOT class's fields. + // Per ECMAScript spec, derived-class field initializers run + // AFTER super() returns (so they may depend on parent body + // state, e.g. drizzle's `class SQLiteBaseInteger extends + // SQLiteColumn { autoIncrement = this.config.autoIncrement }` + // — `this.config` is set by Column's body two levels up). + // Pre-#631 this kept all-ancestors-but-leaf which incorrectly + // ran SQLiteBaseInteger's init before Column's body. + // + // Each intermediate class's fields are applied via the + // SuperCall site (`expr.rs::Expr::SuperCall`'s post-body + // intermediate-walk added in this commit). Root's fields + // need to be applied here because root has no super() and + // its body may reference its own fields directly. + if chain.len() <= 1 { + Vec::new() + } else { + vec![chain[0].clone()] + } + } + FieldInitMode::SelfOnly => { + if let Some(last) = chain.last().cloned() { + vec![last] + } else { + Vec::new() + } + } + FieldInitMode::UpToInclusive(stop_at) => { + if let Some(idx) = chain.iter().position(|n| n == stop_at) { + chain[..=idx].to_vec() + } else { + Vec::new() + } + } + FieldInitMode::BetweenExclusiveTo(stop_at) => { + if let Some(idx) = chain.iter().position(|n| n == stop_at) { + if idx + 1 < chain.len() { + chain[idx + 1..].to_vec() + } else { + Vec::new() + } + } else { + Vec::new() + } + } + FieldInitMode::AfterRoot => { + if chain.len() > 1 { + chain[1..].to_vec() + } else { + Vec::new() + } + } + }; + + for class_name_in_chain in chain { + // Issue #26: prefer the authoritative chain's resolved fields for this + // class (correct cross-module parent layout); fall back to the + // name-keyed `ctx.classes` only when no authoritative entry exists. + // Local classes carry their real init exprs here; imported/inherited + // fields carry `init: None` (→ `undefined`), exactly as before — just + // resolved against the RIGHT parent. + let class_fields: Vec = + if let Some(fields) = chain_field_override.get(&class_name_in_chain) { + fields.clone() + } else { + match ctx.classes.get(&class_name_in_chain).copied() { + Some(c) => c.fields.clone(), + None => continue, + } + }; + // Collect (property_name, init_expr) pairs up-front to avoid + // holding an immutable borrow of ctx.classes across lower_expr. + // Computed-key fields (`[Symbol.for("k")]` etc.) live in a parallel + // list since their key is an expression that needs runtime evaluation. + // + // Fields declared without an initializer (`#x;` / `x: any;`) must + // still be written in the constructor as `undefined` — JS semantics + // is `new C().x === undefined`, not zero-bytes from the allocator. + // Without the explicit write, regular methods see `undefined` (the + // field-by-name dispatcher returns undefined for absent fields), + // but arrow-class-field bodies that load `this.x` through the + // captured-this slot read raw zero bytes — `0 ?? fallback` then + // takes the wrong branch (0 is falsy but not nullish), breaking + // common patterns like `this.#preparedHeaders ?? new Headers()` + // in hono's Context. Lower the missing-init case to + // `Expr::Undefined` so the constructor writes the spec-correct + // value into the field slot. Refs #486. + let mut init_pairs: Vec<(String, Expr)> = Vec::new(); + let mut init_pairs_computed: Vec<(Expr, Expr)> = Vec::new(); + for field in &class_fields { + // Wall 46: synthesized capture fields (`__perry_cap_*`) are populated + // EXCLUSIVELY by the constructor's capture-param assignments — for a + // class constructed directly, by its own ctor; for a subclass of an + // (inherited) dynamic parent, by super()'s parent-ctor run. They carry + // `init: None`, so the default `Expr::Undefined` write below would + // re-initialize them to `undefined` during the derived field-init + // phase (which runs AFTER super()), CLOBBERING the real captured value + // super already stored. That is the Next.js `NextNodeServer extends + // _baseserver.default` failure: base-server's `_iserror`/`_utils`/ + // `_log` read `undefined` in inherited methods. Field-init must never + // touch these — skip them so the ctor param assignment is the sole + // writer (verified: captures are correct at the parent ctor end and + // only vanish during the derived ctor's post-super field-init). + if field.key_expr.is_none() && field.name.starts_with("__perry_cap_") { + continue; + } + let init = match &field.init { + Some(e) => e.clone(), + None => Expr::Undefined, + }; + match &field.key_expr { + Some(key) => init_pairs_computed.push((key.clone(), init)), + None => init_pairs.push((field.name.clone(), init)), + } + } + if init_pairs.is_empty() && init_pairs_computed.is_empty() { + continue; + } + + // Temporarily swap class_stack so `this.field` in the init + // resolves against the correct class. + ctx.class_stack.push(class_name_in_chain.clone()); + for (prop, init_expr) in init_pairs { + // Issue #263: arrow-function class fields like + // `arrowField = () => this.value` need their reserved `this` + // capture slot patched with the constructor's `this` AFTER + // the closure is built — same pattern `lower_object_literal` + // already uses for object-literal methods. Without this, the + // arrow's body reads slot `auto_captures.len()` of the + // closure's capture array (initialized to 0.0 by the + // closure-build site at expr.rs:3294-3304), then `this.value` + // dereferences address 0 and SIGSEGVs. + if let Expr::Closure { + params: cparams, + body: cbody, + captures: ccaps, + captures_this: true, + .. + } = &init_expr + { + let auto_caps = + crate::type_analysis::compute_auto_captures(ctx, cparams, cbody, ccaps); + let this_idx = auto_caps.len() as u32; + + // Lower the closure expression to a NaN-boxed pointer. + let closure_val = lower_expr(ctx, &init_expr)?; + + // Read the current `this` from the constructor's this_stack. + let this_val = if let Some(slot) = ctx.this_stack.last().cloned() { + ctx.block().load(DOUBLE, &slot) + } else { + double_literal(0.0) + }; + + // Patch the closure's reserved this-slot in-place, then + // store the closure as the field via the runtime FFI. + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(&closure_val); + let closure_handle = blk.and(I64, &bits, POINTER_MASK_I64); + let idx_str = this_idx.to_string(); + blk.call_void( + "js_closure_set_capture_f64", + &[(I64, &closure_handle), (I32, &idx_str), (DOUBLE, &this_val)], + ); + + // Now store the patched closure as the field. Emit the + // property-write call directly, mirroring PropertySet's + // codegen path (expr.rs:2559+) — we can't go through + // `lower_expr` again because that would re-lower the + // closure expression and produce a fresh, unpatched + // closure pointer. + let key_idx = ctx.strings.intern(&prop); + let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); + let this_bits = blk.bitcast_double_to_i64(&this_val); + let this_raw = blk.and(I64, &this_bits, POINTER_MASK_I64); + blk.call_void( + "js_object_set_field_by_name", + &[(I64, &this_raw), (I64, &key_raw), (DOUBLE, &closure_val)], + ); + continue; + } + + // Non-closure (or non-this-capturing closure) initializer: + // build a PropertySet { this, prop, init_expr } and lower + // through the existing path. + let set_expr = Expr::PropertySet { + object: Box::new(Expr::This), + property: prop, + value: Box::new(init_expr), + }; + let _ = lower_expr(ctx, &set_expr)?; + } + + // Computed-key fields: `[Parent.Symbol.X] = init` lowers to + // `this[Parent.Symbol.X] = init`. The key expression is evaluated + // at construction time per ES spec — `Object.defineProperty(this, k, …)` + // semantics through the IndexSet path. arrow-with-this-capture is + // unusual on a computed-key field; if it ever surfaces in real code + // we extend this branch the same way the string-keyed loop above + // does. + for (key_expr, init_expr) in init_pairs_computed { + let set_expr = Expr::IndexSet { + object: Box::new(Expr::This), + index: Box::new(key_expr), + value: Box::new(init_expr), + }; + let _ = lower_expr(ctx, &set_expr)?; + } + ctx.class_stack.pop(); + } + Ok(()) +} diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 99789357c..a5aafc0b7 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -39,6 +39,7 @@ mod console_promise; mod early_branches; mod event_target; mod extern_func; +mod field_init; mod func_ref; mod jsx; mod method_override; @@ -100,10 +101,8 @@ pub(crate) use native::lower_native_method_call; // Re-export pub(crate) `new.rs` items consumed outside this module // (codegen.rs / expr.rs / stmt.rs) so `crate::lower_call::lower_new` // etc. keep resolving after the split. -pub(crate) use new::{ - apply_field_initializers_recursive, bind_inline_constructor_params, lower_new, - restore_inline_constructor_scope, FieldInitMode, -}; +pub(crate) use field_init::{apply_field_initializers_recursive, FieldInitMode}; +pub(crate) use new::{bind_inline_constructor_params, lower_new, restore_inline_constructor_scope}; // `extract_options_fields` is consumed by `expr.rs` as // `crate::lower_call::extract_options_fields` — keep that path stable. pub(crate) use options::extract_options_fields; diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 53e00c50e..7614462e3 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1,13 +1,15 @@ -//! `new ClassName(args…)` lowering + recursive field-initializer application. +//! `new ClassName(args…)` lowering. //! //! Extracted from `lower_call.rs` (#1099, part of #1097) — pure move, -//! no behavior change. Holds `lower_new` (Phase C.1 constructor inlining), -//! the `FieldInitMode` enum, and `apply_field_initializers_recursive`. +//! no behavior change. Holds `lower_new` (Phase C.1 constructor inlining). +//! The `FieldInitMode` enum and `apply_field_initializers_recursive` live +//! in the sibling `field_init` module. use anyhow::Result; use perry_hir::{Expr, Param}; use perry_types::Type as HirType; +use super::field_init::{apply_field_initializers_recursive, FieldInitMode}; use super::lower_builtin_new; use super::new_helpers::{ collect_decl_local_ids, ctor_body_calls_super, ctor_body_closure_calls_super, @@ -1494,305 +1496,3 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> ctx.class_stack.pop(); Ok(final_box) } - -/// Walk the inheritance chain from the root down and apply each class's -/// field initializers to `this`. Call this inside `lower_new` after the -/// `this` slot is pushed but before the constructor body is inlined. -/// -/// Initializers run in declaration order: root parent first, then each -/// child, matching JavaScript / TypeScript class semantics where fields -/// are initialized before user-written constructor code executes (field -/// initializers are conceptually prepended to the constructor body). -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum FieldInitMode { - /// Apply field initializers for the entire chain root → leaf. - All, - /// Apply only the ancestors' field initializers (skip the leaf class). - /// Used to set up parent fields before a parent ctor body runs. - AncestorsOnly, - /// Apply only the named class's own field initializers (skip ancestors). - /// Used after a parent ctor body has run to install the leaf's fields, - /// which may reference state set by the parent body (e.g. - /// `enumValues = this.config.enumValues` in drizzle's PgText). Refs #420. - SelfOnly, - /// Issue #631-followup: apply fields for the chain root → `stop_at` - /// (inclusive). Used in the no-own-ctor path BEFORE the inherited- - /// ctor body runs, so only the inherited-ctor class's chain has its - /// fields set up. Intermediate classes between `stop_at` and the leaf - /// (e.g. SQLiteBaseInteger between SQLiteColumn and SQLiteInteger) - /// have their fields applied AFTER the inherited-ctor body, via - /// `BetweenExclusiveTo`. - UpToInclusive(String), - /// Apply fields for chain (`stop_at` exclusive) → leaf (inclusive). - /// Mirror of `UpToInclusive` for the post-body chain. Skips - /// `stop_at` itself because that class's SelfOnly fields are - /// applied via the SuperCall site inside the inlined body. - BetweenExclusiveTo(String), - /// Apply every class after the root ancestor through the leaf. Used - /// when a default-derived constructor chain has no explicit inherited - /// constructor body, so there is no SuperCall site to apply intermediate - /// class fields. - AfterRoot, -} - -pub(crate) fn apply_field_initializers_recursive( - ctx: &mut FnCtx<'_>, - class_name: &str, - mode: FieldInitMode, -) -> Result<()> { - // Issue #26 / #321: prefer the authoritative, source-prefix-disambiguated - // ancestor chain (built once in `compile_module` alongside the per-class - // keys global). Walking `ctx.classes` by `extends_name` mis-resolves - // same-named cross-module parents (effect's `Type` in SchemaAST.ts vs - // ParseResult.ts) and writes that wrong parent's fields onto the instance - // as `undefined`, surfacing as spurious enumerable keys (`_tag,ast,actual, - // message` on a `PropertySignature`). The authoritative chain is root → - // leaf and carries each ancestor's resolved fields, so we use both its - // ORDER (for the mode filter) and its FIELDS (per class below). - let mut chain_field_override: std::collections::HashMap> = - std::collections::HashMap::new(); - // Collect the inheritance chain from root down. - let mut chain: Vec = Vec::new(); - if let Some(auth) = ctx.class_init_chains.get(class_name) { - for (name, fields) in auth { - chain.push(name.clone()); - chain_field_override.insert(name.clone(), fields.clone()); - } - } else { - let mut cur = Some(class_name.to_string()); - while let Some(c) = cur { - let Some(class) = ctx.classes.get(&c).copied() else { - break; - }; - chain.push(c.clone()); - cur = class.extends_name.clone(); - } - chain.reverse(); - } - - // Apply mode filter: - // All: keep entire chain - // AncestorsOnly: drop the leaf (last entry) - // SelfOnly: keep only the leaf - // UpToInclusive(stop_at): keep chain[0..=index_of(stop_at)] - // BetweenExclusiveTo(stop_at): keep chain[index_of(stop_at)+1..] - // AfterRoot: keep chain[1..] - let chain: Vec = match &mode { - FieldInitMode::All => chain, - FieldInitMode::AncestorsOnly => { - // Issue #631-followup: keep only the ROOT class's fields. - // Per ECMAScript spec, derived-class field initializers run - // AFTER super() returns (so they may depend on parent body - // state, e.g. drizzle's `class SQLiteBaseInteger extends - // SQLiteColumn { autoIncrement = this.config.autoIncrement }` - // — `this.config` is set by Column's body two levels up). - // Pre-#631 this kept all-ancestors-but-leaf which incorrectly - // ran SQLiteBaseInteger's init before Column's body. - // - // Each intermediate class's fields are applied via the - // SuperCall site (`expr.rs::Expr::SuperCall`'s post-body - // intermediate-walk added in this commit). Root's fields - // need to be applied here because root has no super() and - // its body may reference its own fields directly. - if chain.len() <= 1 { - Vec::new() - } else { - vec![chain[0].clone()] - } - } - FieldInitMode::SelfOnly => { - if let Some(last) = chain.last().cloned() { - vec![last] - } else { - Vec::new() - } - } - FieldInitMode::UpToInclusive(stop_at) => { - if let Some(idx) = chain.iter().position(|n| n == stop_at) { - chain[..=idx].to_vec() - } else { - Vec::new() - } - } - FieldInitMode::BetweenExclusiveTo(stop_at) => { - if let Some(idx) = chain.iter().position(|n| n == stop_at) { - if idx + 1 < chain.len() { - chain[idx + 1..].to_vec() - } else { - Vec::new() - } - } else { - Vec::new() - } - } - FieldInitMode::AfterRoot => { - if chain.len() > 1 { - chain[1..].to_vec() - } else { - Vec::new() - } - } - }; - - for class_name_in_chain in chain { - // Issue #26: prefer the authoritative chain's resolved fields for this - // class (correct cross-module parent layout); fall back to the - // name-keyed `ctx.classes` only when no authoritative entry exists. - // Local classes carry their real init exprs here; imported/inherited - // fields carry `init: None` (→ `undefined`), exactly as before — just - // resolved against the RIGHT parent. - let class_fields: Vec = - if let Some(fields) = chain_field_override.get(&class_name_in_chain) { - fields.clone() - } else { - match ctx.classes.get(&class_name_in_chain).copied() { - Some(c) => c.fields.clone(), - None => continue, - } - }; - // Collect (property_name, init_expr) pairs up-front to avoid - // holding an immutable borrow of ctx.classes across lower_expr. - // Computed-key fields (`[Symbol.for("k")]` etc.) live in a parallel - // list since their key is an expression that needs runtime evaluation. - // - // Fields declared without an initializer (`#x;` / `x: any;`) must - // still be written in the constructor as `undefined` — JS semantics - // is `new C().x === undefined`, not zero-bytes from the allocator. - // Without the explicit write, regular methods see `undefined` (the - // field-by-name dispatcher returns undefined for absent fields), - // but arrow-class-field bodies that load `this.x` through the - // captured-this slot read raw zero bytes — `0 ?? fallback` then - // takes the wrong branch (0 is falsy but not nullish), breaking - // common patterns like `this.#preparedHeaders ?? new Headers()` - // in hono's Context. Lower the missing-init case to - // `Expr::Undefined` so the constructor writes the spec-correct - // value into the field slot. Refs #486. - let mut init_pairs: Vec<(String, Expr)> = Vec::new(); - let mut init_pairs_computed: Vec<(Expr, Expr)> = Vec::new(); - for field in &class_fields { - // Wall 46: synthesized capture fields (`__perry_cap_*`) are populated - // EXCLUSIVELY by the constructor's capture-param assignments — for a - // class constructed directly, by its own ctor; for a subclass of an - // (inherited) dynamic parent, by super()'s parent-ctor run. They carry - // `init: None`, so the default `Expr::Undefined` write below would - // re-initialize them to `undefined` during the derived field-init - // phase (which runs AFTER super()), CLOBBERING the real captured value - // super already stored. That is the Next.js `NextNodeServer extends - // _baseserver.default` failure: base-server's `_iserror`/`_utils`/ - // `_log` read `undefined` in inherited methods. Field-init must never - // touch these — skip them so the ctor param assignment is the sole - // writer (verified: captures are correct at the parent ctor end and - // only vanish during the derived ctor's post-super field-init). - if field.key_expr.is_none() && field.name.starts_with("__perry_cap_") { - continue; - } - let init = match &field.init { - Some(e) => e.clone(), - None => Expr::Undefined, - }; - match &field.key_expr { - Some(key) => init_pairs_computed.push((key.clone(), init)), - None => init_pairs.push((field.name.clone(), init)), - } - } - if init_pairs.is_empty() && init_pairs_computed.is_empty() { - continue; - } - - // Temporarily swap class_stack so `this.field` in the init - // resolves against the correct class. - ctx.class_stack.push(class_name_in_chain.clone()); - for (prop, init_expr) in init_pairs { - // Issue #263: arrow-function class fields like - // `arrowField = () => this.value` need their reserved `this` - // capture slot patched with the constructor's `this` AFTER - // the closure is built — same pattern `lower_object_literal` - // already uses for object-literal methods. Without this, the - // arrow's body reads slot `auto_captures.len()` of the - // closure's capture array (initialized to 0.0 by the - // closure-build site at expr.rs:3294-3304), then `this.value` - // dereferences address 0 and SIGSEGVs. - if let Expr::Closure { - params: cparams, - body: cbody, - captures: ccaps, - captures_this: true, - .. - } = &init_expr - { - let auto_caps = - crate::type_analysis::compute_auto_captures(ctx, cparams, cbody, ccaps); - let this_idx = auto_caps.len() as u32; - - // Lower the closure expression to a NaN-boxed pointer. - let closure_val = lower_expr(ctx, &init_expr)?; - - // Read the current `this` from the constructor's this_stack. - let this_val = if let Some(slot) = ctx.this_stack.last().cloned() { - ctx.block().load(DOUBLE, &slot) - } else { - double_literal(0.0) - }; - - // Patch the closure's reserved this-slot in-place, then - // store the closure as the field via the runtime FFI. - let blk = ctx.block(); - let bits = blk.bitcast_double_to_i64(&closure_val); - let closure_handle = blk.and(I64, &bits, POINTER_MASK_I64); - let idx_str = this_idx.to_string(); - blk.call_void( - "js_closure_set_capture_f64", - &[(I64, &closure_handle), (I32, &idx_str), (DOUBLE, &this_val)], - ); - - // Now store the patched closure as the field. Emit the - // property-write call directly, mirroring PropertySet's - // codegen path (expr.rs:2559+) — we can't go through - // `lower_expr` again because that would re-lower the - // closure expression and produce a fresh, unpatched - // closure pointer. - let key_idx = ctx.strings.intern(&prop); - let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); - let this_bits = blk.bitcast_double_to_i64(&this_val); - let this_raw = blk.and(I64, &this_bits, POINTER_MASK_I64); - blk.call_void( - "js_object_set_field_by_name", - &[(I64, &this_raw), (I64, &key_raw), (DOUBLE, &closure_val)], - ); - continue; - } - - // Non-closure (or non-this-capturing closure) initializer: - // build a PropertySet { this, prop, init_expr } and lower - // through the existing path. - let set_expr = Expr::PropertySet { - object: Box::new(Expr::This), - property: prop, - value: Box::new(init_expr), - }; - let _ = lower_expr(ctx, &set_expr)?; - } - - // Computed-key fields: `[Parent.Symbol.X] = init` lowers to - // `this[Parent.Symbol.X] = init`. The key expression is evaluated - // at construction time per ES spec — `Object.defineProperty(this, k, …)` - // semantics through the IndexSet path. arrow-with-this-capture is - // unusual on a computed-key field; if it ever surfaces in real code - // we extend this branch the same way the string-keyed loop above - // does. - for (key_expr, init_expr) in init_pairs_computed { - let set_expr = Expr::IndexSet { - object: Box::new(Expr::This), - index: Box::new(key_expr), - value: Box::new(init_expr), - }; - let _ = lower_expr(ctx, &set_expr)?; - } - ctx.class_stack.pop(); - } - Ok(()) -} From d0ed314f578bcee7d6040d8c49dc8f17958d785c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 17 Jun 2026 13:49:10 +0200 Subject: [PATCH 4/4] chore: bump v0.5.1178 + changelog for per-new-site allocator outlining --- CHANGELOG.md | 22 ++++++++ CLAUDE.md | 2 +- Cargo.lock | 148 +++++++++++++++++++++++++-------------------------- Cargo.toml | 2 +- 4 files changed, 98 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77307edaf..e337c0c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## v0.5.1178 — perf(codegen): outline per-new-site inline allocator (smaller IR + faster) + +`new ClassName(...)` previously emitted the full object-allocation prologue +inline at every call site. That bloated the IR (and the resulting binary) and +slowed codegen/compile. The allocation now calls the outlined runtime helper +`js_object_alloc_class_inline_keys`, so each new-site shrinks to a single call. +Complementary to #5304 (which outlined the constructor *call*); this outlines +the *allocation*. The two touch different regions of `lower_call/new.rs`. + +Two supporting changes: + +- **Runtime (#4717):** folded the field-slot zero-fill into + `js_object_alloc_class_inline_keys`. The allocation moved out of per-site + codegen, where callers used to zero-fill `max(field_count, 8)` slots by hand; + doing it inside the helper keeps every caller — including the outlined `new C()` + path — correct by construction. Without it, a field read-before-write or a GC + scan of the still-constructing instance could observe stale recycled arena + bytes. +- Split the `FieldInitMode` enum + `apply_field_initializers_recursive` walker + out of `lower_call/new.rs` into a sibling `field_init.rs` (pure move) to keep + the file under the 2,000-LOC CI size gate. + ## v0.5.1177 — fix(codegen): injective function-symbol names (distinct names that sanitize alike) Two distinct module-level functions could mangle to the same LLVM symbol, so diff --git a/CLAUDE.md b/CLAUDE.md index fe5baee01..0b11dc724 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1177 +**Current Version:** 0.5.1178 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 209740c45..598dc28de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,7 +5283,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "base64", @@ -5340,14 +5340,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "cc", "libc", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "log", @@ -5370,7 +5370,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "perry-hir", @@ -5379,7 +5379,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "perry-hir", @@ -5387,7 +5387,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "perry-dispatch", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "perry-hir", @@ -5406,7 +5406,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "base64", @@ -5419,7 +5419,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "perry-hir", @@ -5427,7 +5427,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "async-trait", @@ -5456,14 +5456,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "serde", "serde_json", @@ -5471,7 +5471,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1177" +version = "0.5.1178" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5482,7 +5482,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "clap", @@ -5497,14 +5497,14 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-argon2" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "argon2", "perry-ffi", @@ -5512,7 +5512,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "reqwest", @@ -5521,7 +5521,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "bcrypt", "perry-ffi", @@ -5529,7 +5529,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "rusqlite", @@ -5537,7 +5537,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "scraper", @@ -5545,7 +5545,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "perry-runtime", @@ -5553,7 +5553,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "chrono", "cron 0.16.0", @@ -5563,7 +5563,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "chrono", "perry-ffi", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "rust_decimal", @@ -5579,7 +5579,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "serde_json", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "perry-runtime", @@ -5603,14 +5603,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "bytes", "http-body-util", @@ -5627,7 +5627,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "lazy_static", "perry-ffi", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5652,7 +5652,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "bytes", "h2", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "lazy_static", "perry-ffi", @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "jsonwebtoken", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "lru", "perry-ffi", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "chrono", "perry-ffi", @@ -5712,7 +5712,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "bson", "futures-util", @@ -5724,7 +5724,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "chrono", "perry-ffi", @@ -5734,7 +5734,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "nanoid", "perry-ffi", @@ -5743,7 +5743,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "perry-runtime", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "lettre", "perry-ffi", @@ -5765,7 +5765,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "printpdf", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "sqlx", @@ -5782,7 +5782,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "governor", "perry-ffi", @@ -5790,7 +5790,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "image", @@ -5799,14 +5799,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "lazy_static", "perry-ffi", @@ -5815,7 +5815,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "uuid", @@ -5823,7 +5823,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ffi", "regex", @@ -5833,7 +5833,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "futures-util", "lazy_static", @@ -5845,7 +5845,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "brotli", "flate2", @@ -5854,7 +5854,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "dashmap", "once_cell", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "perry-api-manifest", @@ -5881,7 +5881,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "perry-diagnostics", @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "base64", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6017,7 +6017,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "perry-hir", @@ -6027,7 +6027,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6035,14 +6035,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "itoa", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "rand 0.8.6", "serde", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "cairo-rs", @@ -6092,7 +6092,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "block2", @@ -6108,7 +6108,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "block2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1177" +version = "0.5.1178" [[package]] name = "perry-ui-test" @@ -6131,11 +6131,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1177" +version = "0.5.1178" [[package]] name = "perry-ui-tvos" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "block2", @@ -6151,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "block2", @@ -6167,7 +6167,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "block2", "libc", @@ -6180,7 +6180,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "libc", @@ -6197,14 +6197,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "base64", "ed25519-dalek", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1177" +version = "0.5.1178" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 2c7d52876..af266dc0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -215,7 +215,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1177" +version = "0.5.1178" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"