From acd2709aa045883a99e3d35a8ad0f4f233c54953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 11:08:01 +0000 Subject: [PATCH 1/3] fix(codegen): run field initializers at native-base super() and guard typed Map/Set receivers - #10443: a class whose direct parent is a built-in Error type (or any non-user base) never ran its own field initializers when it had its own super()-calling constructor; the Error arm of this_super_call.rs was the one arm that skipped applying them. - #10446: .add/.set/.get/... on a statically-typed Set/Map receiver lowered straight to js_set_*/js_map_* with no tag check, so an undefined/null/primitive receiver dereferenced its unboxed payload and segfaulted instead of throwing a catchable TypeError. --- crates/perry-codegen/src/expr/arrays_finds.rs | 2 +- crates/perry-codegen/src/expr/bigint_set.rs | 79 ++--- .../src/expr/collection_receiver.rs | 97 ++++++ .../src/expr/logical_collections.rs | 5 +- crates/perry-codegen/src/expr/math_simple.rs | 18 +- crates/perry-codegen/src/expr/mod.rs | 2 + crates/perry-codegen/src/expr/property_get.rs | 4 +- .../src/expr/string_regex_proc.rs | 2 +- .../perry-codegen/src/expr/this_super_call.rs | 13 + .../src/lower_call/field_init.rs | 49 +++- .../src/lower_call/property_get/map_set.rs | 34 ++- .../src/runtime_decls/strings.rs | 8 + .../tests/error_subclass_field_init.rs | 277 ++++++++++++++++++ .../tests/typed_collection_receiver_guard.rs | 203 +++++++++++++ .../perry-runtime/src/collection_receiver.rs | 63 ++++ crates/perry-runtime/src/lib.rs | 1 + ...est_gap_10443_error_subclass_field_init.ts | 245 ++++++++++++++++ ...est_gap_10446_typed_collection_receiver.ts | 162 ++++++++++ 18 files changed, 1177 insertions(+), 87 deletions(-) create mode 100644 crates/perry-codegen/src/expr/collection_receiver.rs create mode 100644 crates/perry-codegen/tests/error_subclass_field_init.rs create mode 100644 crates/perry-codegen/tests/typed_collection_receiver_guard.rs create mode 100644 crates/perry-runtime/src/collection_receiver.rs create mode 100644 test-files/test_gap_10443_error_subclass_field_init.ts create mode 100644 test-files/test_gap_10446_typed_collection_receiver.ts diff --git a/crates/perry-codegen/src/expr/arrays_finds.rs b/crates/perry-codegen/src/expr/arrays_finds.rs index c4ea2bfbbd..d9244d2925 100644 --- a/crates/perry-codegen/src/expr/arrays_finds.rs +++ b/crates/perry-codegen/src/expr/arrays_finds.rs @@ -563,8 +563,8 @@ pub(crate) fn lower( // -------- Map.clear -------- Expr::MapClear(map) => { let m_box = lower_expr(ctx, map)?; + let m_handle = super::unbox_collection_receiver(ctx, &m_box, "clear"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); blk.call_void("js_map_clear", &[(I64, &m_handle)]); // Map.prototype.clear() returns undefined, not 0. Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) diff --git a/crates/perry-codegen/src/expr/bigint_set.rs b/crates/perry-codegen/src/expr/bigint_set.rs index 7cddc74221..8f4bff5eba 100644 --- a/crates/perry-codegen/src/expr/bigint_set.rs +++ b/crates/perry-codegen/src/expr/bigint_set.rs @@ -20,7 +20,7 @@ use super::{ nanbox_pointer_inline, record_collection_number_key_fallback, record_collection_number_key_selected, record_collection_string_key_fallback, record_collection_string_key_selected, record_collection_typed_value_fallback, - record_collection_typed_value_selected, unbox_to_i64, FnCtx, + record_collection_typed_value_selected, unbox_collection_receiver, unbox_to_i64, FnCtx, }; fn number_coerce_operand_is_already_primitive_number(ctx: &FnCtx<'_>, operand: &Expr) -> bool { @@ -153,13 +153,16 @@ fn guarded_set_number_add(ctx: &mut FnCtx<'_>, set_handle: &str, value_box: &str /// is exactly what it was before this change. On the protected path the /// handle has to come from the *re-read* box, below the value's lowering, so /// it is derived in [`reread_set_receiver`] instead. -fn eager_set_handle(ctx: &mut FnCtx<'_>, group: &RootedGroup<'_>) -> Result> { +fn eager_set_handle( + ctx: &mut FnCtx<'_>, + group: &RootedGroup<'_>, + method: &str, +) -> Result> { if group.is_rooted() { return Ok(None); } let s_box = group.reread(ctx, 0)?; - let blk = ctx.block(); - Ok(Some(unbox_to_i64(blk, &s_box))) + Ok(Some(unbox_collection_receiver(ctx, &s_box, method))) } /// Re-derive the `Set` receiver handle AFTER `value` has been lowered (#9523). @@ -174,13 +177,13 @@ fn reread_set_receiver( ctx: &mut FnCtx<'_>, group: &RootedGroup<'_>, s_handle_unrooted: &Option, + method: &str, ) -> Result { if let Some(handle) = s_handle_unrooted { return Ok(handle.clone()); } let s_box = group.reread(ctx, 0)?; - let blk = ctx.block(); - Ok(unbox_to_i64(blk, &s_box)) + Ok(unbox_collection_receiver(ctx, &s_box, method)) } fn guarded_set_number_has(ctx: &mut FnCtx<'_>, set_handle: &str, value_box: &str) -> String { @@ -648,10 +651,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_i32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); let new_handle = { let blk = ctx.block(); blk.call( @@ -675,10 +675,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_u32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::U32)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); let new_handle = { let blk = ctx.block(); blk.call( @@ -702,10 +699,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_f32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::F32)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); let new_handle = { let blk = ctx.block(); blk.call( @@ -729,10 +723,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_i1 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I1)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); let new_handle = { let blk = ctx.block(); let value_i32 = blk.zext(I1, &value_i1.value, I32); @@ -756,17 +747,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_number_set { let v = lower_expr(ctx, value)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); guarded_set_number_add(ctx, &set_handle, &v) } else { let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); if use_string_set { let value_ref = lower_expr_native( ctx, @@ -931,11 +916,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_collects = operand_may_collect(ctx, value); let i32_v = with_rooted_group(ctx, 1, |ctx, group| { group.lower(ctx, set, value_collects)?; - let s_handle_unrooted = eager_set_handle(ctx, group)?; + let s_handle_unrooted = eager_set_handle(ctx, group, "has")?; let i32_v = if use_i32_set { let value_i32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -958,7 +943,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_u32_set { let value_u32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::U32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -981,7 +966,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_f32_set { let value_f32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::F32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -1004,7 +989,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_boolean_set { let value_i1 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I1)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); let value_i32 = blk.zext(I1, &value_i1.value, I32); @@ -1027,7 +1012,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else if use_number_set { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; guarded_set_number_has(ctx, &s_handle, &v_box) } else { if use_string_set { @@ -1036,7 +1021,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { value, crate::native_value::ExpectedNativeRep::StringRef, )?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); let i32_v = blk.call( @@ -1067,7 +1052,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); blk.call(I32, "js_set_has", &[(I64, &s_handle), (DOUBLE, &v_box)]) @@ -1187,11 +1172,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_collects = operand_may_collect(ctx, value); let i32_v = with_rooted_group(ctx, 1, |ctx, group| { group.lower(ctx, set, value_collects)?; - let s_handle_unrooted = eager_set_handle(ctx, group)?; + let s_handle_unrooted = eager_set_handle(ctx, group, "delete")?; let i32_v = if use_i32_set { let value_i32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -1214,7 +1199,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_u32_set { let value_u32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::U32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -1237,7 +1222,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_f32_set { let value_f32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::F32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -1260,7 +1245,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_boolean_set { let value_i1 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I1)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); let value_i32 = blk.zext(I1, &value_i1.value, I32); @@ -1283,7 +1268,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else if use_number_set { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; guarded_set_number_delete(ctx, &s_handle, &v_box) } else { if use_string_set { @@ -1292,7 +1277,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { value, crate::native_value::ExpectedNativeRep::StringRef, )?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); let i32_v = blk.call( @@ -1323,7 +1308,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call(I32, "js_set_delete", &[(I64, &s_handle), (DOUBLE, &v_box)]) @@ -1415,8 +1400,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // -------- set.size -> number -------- Expr::SetSize(set) => { let s_box = lower_expr(ctx, set)?; + let s_handle = unbox_collection_receiver(ctx, &s_box, "size"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let i32_v = blk.call(I32, "js_set_size", &[(I64, &s_handle)]); Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } diff --git a/crates/perry-codegen/src/expr/collection_receiver.rs b/crates/perry-codegen/src/expr/collection_receiver.rs new file mode 100644 index 0000000000..864fb93ecd --- /dev/null +++ b/crates/perry-codegen/src/expr/collection_receiver.rs @@ -0,0 +1,97 @@ +//! The receiver check in front of every static-type `Map` / `Set` fast path +//! (#10446). +//! +//! `s.add(v)`, `m.get(k)`, `this.labels.has(x)`, `m.forEach(cb)`, ... lower to +//! a direct `js_set_*` / `js_map_*` call when the receiver's DECLARED type is +//! `Set` / `Map`, and those helpers take the receiver as its unboxed +//! 48-bit payload. A declaration is not a runtime fact. `undefined` (an +//! uninitialized field, a missing option) unboxes to the address `0x1`, which +//! `js_set_add` / `js_map_get` dereferenced: SIGSEGV with no JS stack instead +//! of the catchable `TypeError: Cannot read properties of undefined (reading +//! 'add')` the same call throws when the receiver is untyped. mongodb 7.5.0 +//! died that way in `MongoError.addErrorLabel` about 100 ms after connecting. +//! +//! [`unbox_collection_receiver`] replaces the bare `unbox_to_i64` at those +//! sites. The fast path costs one compare of the box's top 16 bits against the +//! object tag, which a genuine `Map` / `Set` (or a subclass instance, or any +//! other object the runtime helpers already brand-check) always passes. The +//! miss path keeps passing through the two word shapes the helpers have always +//! accepted without a tag — an untagged raw word and a JS handle — and throws +//! for every primitive, none of which can carry a collection method. + +use crate::expr::FnCtx; +use crate::nanbox::{POINTER_MASK_I64, POINTER_TAG_TOP16_I64}; +use crate::types::{DOUBLE, I1, I64, PTR}; + +/// Top 16 bits of `JS_HANDLE_TAG` (`0x7FFB`). +const JS_HANDLE_TAG_TOP16_I64: &str = "32763"; + +/// Check that `recv_box` can be a `Map` / `Set` receiver for method `method`, +/// then return its unboxed handle (what `unbox_to_i64` returns). +/// +/// Emits at the current block, which is left at the check's success block: +/// +/// ```text +/// %bits = bitcast double %recv to i64 +/// %top16 = lshr i64 %bits, 48 +/// %obj = icmp eq i64 %top16, 32765 ; POINTER_TAG +/// br i1 %obj, label %collection_recv.ok, label %collection_recv.miss +/// collection_recv.miss: ; untagged raw word or JS handle +/// br i1 (%top16 == 0 | %top16 == 0x7FFB), label %ok, label %throw +/// collection_recv.throw: +/// call void @js_throw_collection_receiver_type_error(%recv, "") +/// unreachable +/// collection_recv.ok: +/// %handle = and i64 %bits, POINTER_MASK +/// ``` +/// +/// Emit it after the call's operands are lowered, on the box the runtime call +/// consumes (a re-read box on a rooted path): the check never collects, and +/// its throwing arm never returns, so it adds no collection point between the +/// receiver's root and its use. +pub(crate) fn unbox_collection_receiver( + ctx: &mut FnCtx<'_>, + recv_box: &str, + method: &str, +) -> String { + let (bits, top16, is_object) = { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(recv_box); + let top16 = blk.lshr(I64, &bits, "48"); + let is_object = blk.icmp_eq(I64, &top16, POINTER_TAG_TOP16_I64); + (bits, top16, is_object) + }; + let miss_idx = ctx.new_block("collection_recv.miss"); + let throw_idx = ctx.new_block("collection_recv.throw"); + let ok_idx = ctx.new_block("collection_recv.ok"); + let miss_label = ctx.block_label(miss_idx); + let throw_label = ctx.block_label(throw_idx); + let ok_label = ctx.block_label(ok_idx); + ctx.block().cond_br(&is_object, &ok_label, &miss_label); + + ctx.current_block = miss_idx; + { + let blk = ctx.block(); + let is_raw_word = blk.icmp_eq(I64, &top16, "0"); + let is_js_handle = blk.icmp_eq(I64, &top16, JS_HANDLE_TAG_TOP16_I64); + let passes = blk.or(I1, &is_raw_word, &is_js_handle); + blk.cond_br(&passes, &ok_label, &throw_label); + } + + ctx.current_block = throw_idx; + let method_idx = ctx.strings.intern(method); + let method_entry = ctx.strings.entry(method_idx); + let method_bytes = format!("@{}", method_entry.bytes_global); + let method_len = method_entry.byte_len.to_string(); + { + let blk = ctx.block(); + blk.call_void( + "js_throw_collection_receiver_type_error", + &[(DOUBLE, recv_box), (PTR, &method_bytes), (I64, &method_len)], + ); + blk.unreachable(); + } + + ctx.current_block = ok_idx; + ctx.block().and(I64, &bits, POINTER_MASK_I64) +} diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index b0c0de265c..edd96397a2 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -903,10 +903,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // #7615 slice 2: the map is live across the key's lowering. rooting::with_operands_rooted(ctx, &[map, key], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); - let m_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &m_box) - }; + let m_handle = super::unbox_collection_receiver(ctx, &m_box, "delete"); let i32_v = if use_string_key_map { let (k_handle, i32_v) = { let blk = ctx.block(); diff --git a/crates/perry-codegen/src/expr/math_simple.rs b/crates/perry-codegen/src/expr/math_simple.rs index 6320a14954..ed9b1e51ed 100644 --- a/crates/perry-codegen/src/expr/math_simple.rs +++ b/crates/perry-codegen/src/expr/math_simple.rs @@ -19,7 +19,7 @@ use super::{ record_collection_number_key_selected, record_collection_string_key_fallback, record_collection_string_key_selected, record_collection_string_key_value_selected, record_collection_typed_value_fallback, record_collection_typed_value_selected, - unbox_str_handle, unbox_to_i64, FnCtx, + unbox_collection_receiver, unbox_str_handle, unbox_to_i64, FnCtx, }; fn is_static_string_number_map(ctx: &FnCtx<'_>, map: &Expr) -> bool { @@ -286,8 +286,7 @@ fn reread_map_set_receiver_and_key( Some(handle) => handle.clone(), None => { let m_box = values[0].clone(); - let blk = ctx.block(); - unbox_to_i64(blk, &m_box) + unbox_collection_receiver(ctx, &m_box, "set") } }; Ok((m_handle, k_box)) @@ -595,8 +594,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { None } else { let m_box = group.reread(ctx, 0)?; - let blk = ctx.block(); - Some(unbox_to_i64(blk, &m_box)) + Some(unbox_collection_receiver(ctx, &m_box, "set")) }; let new_handle = if use_string_i32_map { let value_i32 = @@ -911,10 +909,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // receiver would otherwise sit unrooted in an SSA register across it. let value = with_operands_rooted(ctx, &[map, key], |ctx, values| { let (m_box, k_box) = (values[0].clone(), values[1].clone()); - let m_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &m_box) - }; + let m_handle = unbox_collection_receiver(ctx, &m_box, "get"); let value = if use_string_key_map { let (k_handle, value) = { let blk = ctx.block(); @@ -971,10 +966,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // while the receiver is live only in an SSA register. let i32_v = with_operands_rooted(ctx, &[map, key], |ctx, values| { let (m_box, k_box) = (values[0].clone(), values[1].clone()); - let m_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &m_box) - }; + let m_handle = unbox_collection_receiver(ctx, &m_box, "has"); let i32_v = if use_string_key_map { let (k_handle, i32_v) = { let blk = ctx.block(); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index fae77a1dba..f93279ee9b 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -41,6 +41,7 @@ pub(crate) use bitset_test::is_u32_bitset_test; mod buffer_access; mod buffer_views; mod channel; +mod collection_receiver; #[cfg(test)] mod class_method_arguments_object_tests; #[cfg(test)] @@ -81,6 +82,7 @@ pub(crate) use buffer_views::{ invalidate_native_owned_views_for_dispose, native_arena_canonical_owner_id, record_native_arena_owner_assignment, update_buffer_view_for_assignment, }; +pub(crate) use collection_receiver::unbox_collection_receiver; pub(crate) use channel::{ extract_array_of_object_shape, lower_channel_reduction, try_match_channel_reduction, variant_name, diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index c568fed651..689ccf3156 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -590,8 +590,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { object, property, .. } if property == "size" && is_set_expr(ctx, object) => { let recv_box = lower_expr(ctx, object)?; + let recv_handle = super::unbox_collection_receiver(ctx, &recv_box, "size"); let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); let i32_v = blk.call(I32, "js_set_size", &[(I64, &recv_handle)]); Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } @@ -599,8 +599,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { object, property, .. } if property == "size" && is_map_expr(ctx, object) => { let recv_box = lower_expr(ctx, object)?; + let recv_handle = super::unbox_collection_receiver(ctx, &recv_box, "size"); let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); let i32_v = blk.call(I32, "js_map_size", &[(I64, &recv_handle)]); Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } diff --git a/crates/perry-codegen/src/expr/string_regex_proc.rs b/crates/perry-codegen/src/expr/string_regex_proc.rs index 6414f4491d..6d0c05e2e1 100644 --- a/crates/perry-codegen/src/expr/string_regex_proc.rs +++ b/crates/perry-codegen/src/expr/string_regex_proc.rs @@ -19,8 +19,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::SetClear(s) => { let s_box = lower_expr(ctx, s)?; + let s_handle = super::unbox_collection_receiver(ctx, &s_box, "clear"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); blk.call_void("js_set_clear", &[(I64, &s_handle)]); Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) } diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 0da471ab7c..18b51c5578 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -1359,6 +1359,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } bind_derived_this_after_super(ctx); + // #10443: derived field initializers run once `super()` + // returns, exactly as in every other arm of this block. + // This was the one arm without it, so `class E extends + // Error { labels = new Set(); constructor(m) { super(m); } }` + // constructed directly left `labels` undefined (mongodb's + // `MongoError.errorLabelSet`). A root reached as an + // ANCESTOR is not staged up front for the same reason + // (`root_fields_run_at_own_super`), so this runs once. + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } }; diff --git a/crates/perry-codegen/src/lower_call/field_init.rs b/crates/perry-codegen/src/lower_call/field_init.rs index 012acd8a75..9ef5391122 100644 --- a/crates/perry-codegen/src/lower_call/field_init.rs +++ b/crates/perry-codegen/src/lower_call/field_init.rs @@ -500,6 +500,44 @@ pub(crate) enum FieldInitMode { FromInclusive(String), } +/// Does the inheritance-chain ROOT `root` install its own field initializers +/// at its own `super()` call? +/// +/// A root has no user-class parent. When it also owns a constructor body, that +/// body's `super(...)` lowers through the non-user-parent block of +/// `expr/this_super_call.rs` — a built-in base (`Error`, `EventEmitter`, `Map`, +/// a stream, ...) or a runtime `extends ` value — and every arm of that +/// block applies the root's fields (`SelfOnly`) once the base constructor has +/// returned, which is the spec position for a derived class. +/// +/// The construction-time staging below (`AncestorsOnly` / `UpToInclusive`) +/// must then leave the root out. Staging it as well ran every initializer +/// twice when the root was built as an ANCESTOR (a private `#field` throws on +/// the second install), and ahead of the base constructor. #10443: the `Error` +/// arm was the one arm that did not apply the fields, so `class E extends +/// Error { labels = new Set(); constructor(m) { super(m); } }` constructed +/// directly never ran its initializers at all, while a subclass of `E` only +/// got them through this up-front staging. +fn root_fields_run_at_own_super(ctx: &FnCtx<'_>, root: &str) -> bool { + if ctx.imported_class_ctors.contains_key(root) { + return false; + } + let Some(class) = ctx.classes.get(root).copied() else { + return false; + }; + if class.constructor.is_none() { + return false; + } + // Mirrors `this_super_call.rs`'s `static_parent_lookup`: a dynamic + // heritage value, or a parent name that is not a local class, reaches the + // non-user-parent block. + class.extends_expr.is_some() + || class + .extends_name + .as_deref() + .is_some_and(|parent| !ctx.classes.contains_key(parent)) +} + /// Whether a named public field initializer can populate the allocation's /// predeclared own slot through the ordinary by-name store. /// @@ -583,8 +621,11 @@ pub(crate) fn apply_field_initializers_recursive( // 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 { + // its body may reference its own fields directly — unless the + // root's own constructor calls `super()` into a non-user parent, + // which installs them itself (#10443, see + // `root_fields_run_at_own_super`). + if chain.len() <= 1 || root_fields_run_at_own_super(ctx, &chain[0]) { Vec::new() } else { vec![chain[0].clone()] @@ -599,7 +640,9 @@ pub(crate) fn apply_field_initializers_recursive( } FieldInitMode::UpToInclusive(stop_at) => { if let Some(idx) = chain.iter().position(|n| n == stop_at) { - chain[..=idx].to_vec() + // Same root exception as `AncestorsOnly` (#10443). + let start = usize::from(root_fields_run_at_own_super(ctx, &chain[0])); + chain[start..=idx].to_vec() } else { Vec::new() } diff --git a/crates/perry-codegen/src/lower_call/property_get/map_set.rs b/crates/perry-codegen/src/lower_call/property_get/map_set.rs index d3304ca3e1..2b0013f533 100644 --- a/crates/perry-codegen/src/lower_call/property_get/map_set.rs +++ b/crates/perry-codegen/src/lower_call/property_get/map_set.rs @@ -26,7 +26,9 @@ use anyhow::Result; use perry_hir::Expr; -use crate::expr::{lower_expr, nanbox_pointer_inline, unbox_to_i64, FnCtx}; +use crate::expr::{ + lower_expr, nanbox_pointer_inline, unbox_collection_receiver, unbox_to_i64, FnCtx, +}; use crate::nanbox::double_literal; use crate::rooting; use crate::type_analysis::{ @@ -86,8 +88,8 @@ pub(crate) fn try_lower_map_set_methods( |ctx, vals| { let (m_box, k_box, v_box) = (vals[0].clone(), vals[1].clone(), vals[2].clone()); + let m_handle = unbox_collection_receiver(ctx, &m_box, "set"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); // #9523: `js_map_set` returns the RECEIVER as it stands // after the insert. For a `class X extends Map` instance // that receiver is a movable `ObjectHeader` the runtime @@ -113,8 +115,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); + let m_handle = unbox_collection_receiver(ctx, &m_box, "get"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); Ok(Some(blk.call( DOUBLE, "js_map_get", @@ -127,8 +129,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); + let m_handle = unbox_collection_receiver(ctx, &m_box, "has"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); let i32_v = blk.call( crate::types::I32, "js_map_has", @@ -142,8 +144,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); + let m_handle = unbox_collection_receiver(ctx, &m_box, "delete"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); let i32_v = blk.call( crate::types::I32, "js_map_delete", @@ -154,8 +156,8 @@ pub(crate) fn try_lower_map_set_methods( } "clear" if args.is_empty() => { let m_box = lower_expr(ctx, object)?; + let m_handle = unbox_collection_receiver(ctx, &m_box, "clear"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); blk.call_void("js_map_clear", &[(I64, &m_handle)]); return Ok(Some(double_literal(f64::from_bits( crate::nanbox::TAG_UNDEFINED, @@ -179,8 +181,8 @@ pub(crate) fn try_lower_map_set_methods( // `Expr::MapEntries`/etc HIR variants. "entries" | "keys" | "values" if args.is_empty() => { let m_box = lower_expr(ctx, object)?; + let m_handle = unbox_collection_receiver(ctx, &m_box, property); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); let runtime_fn = match property { "entries" => "js_map_entries_iter_obj", "keys" => "js_map_keys_iter_obj", @@ -200,8 +202,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, v_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, "add"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); blk.call_void("js_set_add", &[(I64, &s_handle), (DOUBLE, &v_box)]); Ok(Some(s_box)) }); @@ -211,8 +213,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, v_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, "has"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let i32_v = blk.call( crate::types::I32, "js_set_has", @@ -226,8 +228,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, v_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, "delete"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let i32_v = blk.call( crate::types::I32, "js_set_delete", @@ -238,8 +240,8 @@ pub(crate) fn try_lower_map_set_methods( } "clear" if args.is_empty() => { let s_box = lower_expr(ctx, object)?; + let s_handle = unbox_collection_receiver(ctx, &s_box, "clear"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); blk.call_void("js_set_clear", &[(I64, &s_handle)]); return Ok(Some(double_literal(f64::from_bits( crate::nanbox::TAG_UNDEFINED, @@ -260,8 +262,8 @@ pub(crate) fn try_lower_map_set_methods( // typed-Set HIR path; for Sets `entries` yields `[v, v]` pairs. "values" | "keys" | "entries" if args.is_empty() => { let s_box = lower_expr(ctx, object)?; + let s_handle = unbox_collection_receiver(ctx, &s_box, property); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let runtime_fn = match property { "values" => "js_set_values_iter_obj", "keys" => "js_set_keys_iter_obj", @@ -281,8 +283,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, other_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, property); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let runtime_fn = match property { "union" => "js_set_union", "intersection" => "js_set_intersection", @@ -300,8 +302,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, other_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, property); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let runtime_fn = match property { "isSubsetOf" => "js_set_is_subset_of", "isSupersetOf" => "js_set_is_superset_of", @@ -352,8 +354,8 @@ pub(crate) fn try_lower_collection_foreach( double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }); { + let m_handle = unbox_collection_receiver(ctx, &m_box, "forEach"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); blk.call_void( "js_map_foreach", &[(I64, &m_handle), (DOUBLE, &cb_box), (DOUBLE, &this_arg)], @@ -379,8 +381,8 @@ pub(crate) fn try_lower_collection_foreach( double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }); { + let s_handle = unbox_collection_receiver(ctx, &s_box, "forEach"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); blk.call_void( "js_set_foreach", &[(I64, &s_handle), (DOUBLE, &cb_box), (DOUBLE, &this_arg)], diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index a73cd55079..0272ad5ca0 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -396,6 +396,14 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { VOID, &[I32, PTR, I64], ); + // #10446: the failure arm of the static-type Map/Set receiver guard + // (`expr::collection_receiver`). Args: (receiver, method_ptr, method_len). + // Helper diverges (`-> !`); declared as void-return for LLVM purposes. + module.declare_function( + "js_throw_collection_receiver_type_error", + VOID, + &[DOUBLE, PTR, I64], + ); // Issue #510: thrown by `lower_string_method`'s unknown-method // catch-all for primitive (string-typed) receivers. Args: // (kind_ptr, kind_len, prop_ptr, prop_len). Helper diverges diff --git a/crates/perry-codegen/tests/error_subclass_field_init.rs b/crates/perry-codegen/tests/error_subclass_field_init.rs new file mode 100644 index 0000000000..d2b0d9a42f --- /dev/null +++ b/crates/perry-codegen/tests/error_subclass_field_init.rs @@ -0,0 +1,277 @@ +//! #10443: `super()` into a built-in Error must be followed by the derived +//! class's own field initializers. +//! +//! Every other arm of the non-user-parent `super()` block (`EventEmitter`, +//! `Map`/`Set`, the streams, `Promise`, `DOMException`, ...) applies +//! `FieldInitMode::SelfOnly` once the base is installed. The Error-family arm +//! did not, so `class E extends Error { labels = new Set(); constructor(m) { +//! super(m); } }` constructed directly ran no initializer at all and every +//! field read `undefined` — mongodb's `MongoError.errorLabelSet`, and then a +//! SIGSEGV in `js_set_add` (#10446). +//! +//! An IR census rather than an execution test because the ORDER is the +//! contract: the field install has to come after the base's `super()` work +//! (spec: derived field initializers run when `super()` returns), and it has +//! to be emitted exactly once — a second copy is what the staging side of the +//! fix (`root_fields_run_at_own_super`) exists to prevent, and for a private +//! field it would throw at run time. + +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, ModuleInitKind, Stmt}; + +const CAPTURE_STACK: &str = "@js_error_subclass_capture_stack("; +const FIELD_INSTALL: [&str; 2] = ["@js_class_field_add(", "@js_object_set_field_by_name("]; + +fn ir_opts() -> CompileOptions { + CompileOptions { + is_entry_module: true, + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + } +} + +fn field(name: &str, init: Expr) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Number, + init: Some(init), + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } +} + +fn ctor(id: u32, body: Vec) -> Function { + Function { + id, + name: "constructor".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn class(id: u32, name: &str, extends: &str, fields: Vec, ctor: Option) -> Class { + Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: Some(extends.to_string()), + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields, + constructor: ctor, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +fn module_with(classes: Vec, body: Vec) -> Module { + Module { + name: "error_subclass_field_init.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes, + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init_is_strict: false, + init: Vec::new(), + classic_for_lexical_bindings: std::collections::HashSet::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + class_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn ir_for(classes: Vec, body: Vec) -> String { + String::from_utf8(compile_module(&module_with(classes, body), ir_opts()).unwrap()) + .expect("LLVM IR should be UTF-8") +} + +fn new_expr(name: &str) -> Expr { + Expr::New { + class_name: name.to_string(), + args: vec![Expr::String("m".to_string())], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + } +} + +/// The body of the emitted function whose definition line contains `needle`. +/// +/// Counting installs over the whole module would mix the inlined `new` site +/// with the per-class standalone `_constructor` symbol, which installs +/// the same fields for the cross-module construction path. Both are checked, +/// one at a time. +fn function_body<'a>(ir: &'a str, needle: &str) -> &'a str { + let define_at = ir + .match_indices("define ") + .find(|(idx, _)| { + let line_end = ir[*idx..].find('\n').map(|e| idx + e).unwrap_or(ir.len()); + ir[*idx..line_end].contains(needle) + }) + .map(|(idx, _)| idx) + .unwrap_or_else(|| panic!("no emitted function matching {needle}:\n{ir}")); + let rest = &ir[define_at..]; + let end = rest.find("\n}").map(|e| e + 2).unwrap_or(rest.len()); + &rest[..end] +} + +/// Byte offsets of every field-install call in `ir`. +fn field_install_offsets(ir: &str) -> Vec { + let mut offsets: Vec = Vec::new(); + for needle in FIELD_INSTALL { + let mut from = 0usize; + while let Some(idx) = ir[from..].find(needle) { + offsets.push(from + idx); + from += idx + needle.len(); + } + } + offsets.sort_unstable(); + offsets +} + +/// Assert `body` installs exactly `expected` fields, all of them after the +/// Error base's `super()` work. +fn assert_installs_after_super(body: &str, expected: usize, what: &str) { + let super_at = body + .find(CAPTURE_STACK) + .unwrap_or_else(|| panic!("{what}: the Error super() arm must run:\n{body}")); + let installs = field_install_offsets(body); + assert_eq!( + installs.len(), + expected, + "{what}: one install per declared field, no duplicate staging \ + (installs at {installs:?}):\n{body}" + ); + assert!( + installs.iter().all(|at| *at > super_at), + "{what}: field initializers run AFTER super() returns (super at \ + {super_at}, installs at {installs:?}):\n{body}" + ); +} + +#[test] +fn own_ctor_error_subclass_installs_its_fields_after_super() { + let e = class( + 5, + "E", + "Error", + vec![field("n", Expr::Integer(7))], + Some(ctor( + 2, + vec![Stmt::Expr(Expr::SuperCall(vec![Expr::String( + "m".to_string(), + )]))], + )), + ); + let ir = ir_for(vec![e], vec![Stmt::Expr(new_expr("E"))]); + + // The standalone `_constructor` symbol is the body every `new E` + // runs (the call site routes through it when the class owns a ctor), and + // the one a cross-module `new` or a dynamic construct replay reaches. + assert_installs_after_super(function_body(&ir, "E_constructor"), 1, "E ctor"); +} + +#[test] +fn an_error_rooted_chain_installs_each_level_once() { + // `class Mid extends Error { m = 1; ctor }` + `class Leaf extends Mid + // { n = 2; ctor }`: Mid is the chain ROOT, so before the fix its fields + // were staged up front by the construction site AND (once the Error arm + // started applying them) would have been installed a second time at its + // own `super()`. Two fields, two installs. + let mid = class( + 5, + "Mid", + "Error", + vec![field("m", Expr::Integer(1))], + Some(ctor( + 2, + vec![Stmt::Expr(Expr::SuperCall(vec![Expr::String( + "m".to_string(), + )]))], + )), + ); + let leaf = class( + 6, + "Leaf", + "Mid", + vec![field("n", Expr::Integer(2))], + Some(ctor( + 3, + vec![Stmt::Expr(Expr::SuperCall(vec![Expr::String( + "m".to_string(), + )]))], + )), + ); + let ir = ir_for(vec![mid, leaf], vec![Stmt::Expr(new_expr("Leaf"))]); + + // Leaf's constructor inlines Mid's body: `m` and `n`, once each. + assert_installs_after_super(function_body(&ir, "Leaf_constructor"), 2, "Leaf ctor"); + // Mid's own standalone symbol installs only Mid's field. + assert_installs_after_super(function_body(&ir, "Mid_constructor"), 1, "Mid ctor"); +} diff --git a/crates/perry-codegen/tests/typed_collection_receiver_guard.rs b/crates/perry-codegen/tests/typed_collection_receiver_guard.rs new file mode 100644 index 0000000000..ce2dd016c2 --- /dev/null +++ b/crates/perry-codegen/tests/typed_collection_receiver_guard.rs @@ -0,0 +1,203 @@ +//! #10446: the static-type `Map` / `Set` fast paths must check their receiver. +//! +//! Codegen picks `js_map_get` / `js_set_add` from the receiver's DECLARED +//! type and hands them the unboxed 48-bit payload. A declaration is not a +//! runtime fact: `undefined` unboxes to the address `0x1`, which those helpers +//! dereferenced (SIGSEGV, no JS stack, uncatchable — mongodb 7.5.0 died that +//! way inside `MongoError.addErrorLabel`). +//! +//! This is an IR census, and both halves matter. +//! +//! The positive half asserts the guard is LIVE: the object-tag compare, the +//! throw block, and the diverging call are all emitted, so a guard that is +//! silently never emitted fails here rather than in a segfault months later. +//! +//! The negative half asserts the guard is still a GUARD and not a detour: the +//! fast path keeps calling the same runtime helper it always did, and the +//! throwing arm ends in `unreachable` so the check adds no reachable call — and +//! therefore no collection point — between the receiver and its use. + +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, ModuleInitKind, Stmt}; + +/// The emitted blocks that exist only when the guard was lowered. +const THROW_BLOCK: &str = "collection_recv.throw"; +const OK_BLOCK: &str = "collection_recv.ok"; +/// `POINTER_TAG >> 48` — the one compare the fast path pays. +const OBJECT_TAG_TOP16: &str = "32765"; +/// The CALL, not the module's unconditional `declare` of the same symbol. +const THROW_CALL: &str = "call void @js_throw_collection_receiver_type_error("; + +fn ir_opts() -> CompileOptions { + CompileOptions { + is_entry_module: true, + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + } +} + +fn map_type() -> Type { + Type::Generic { + base: "Map".to_string(), + type_args: vec![Type::String, Type::Number], + } +} + +fn set_type() -> Type { + Type::Generic { + base: "Set".to_string(), + type_args: vec![Type::String], + } +} + +fn module_with(body: Vec) -> Module { + Module { + name: "typed_collection_receiver_guard.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init_is_strict: false, + init: Vec::new(), + classic_for_lexical_bindings: std::collections::HashSet::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + class_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn ir_for(body: Vec) -> String { + String::from_utf8(compile_module(&module_with(body), ir_opts()).unwrap()) + .expect("LLVM IR should be UTF-8") +} + +/// Every emitted call to `name`, with the instruction that follows it. +fn call_lines_with_successor<'a>(ir: &'a str, name: &str) -> Vec<(&'a str, &'a str)> { + let lines: Vec<&str> = ir.lines().collect(); + lines + .iter() + .enumerate() + .filter(|(_, line)| line.contains(name)) + .map(|(idx, line)| (*line, lines.get(idx + 1).copied().unwrap_or(""))) + .collect() +} + +#[test] +fn map_get_guards_its_receiver_before_the_runtime_call() { + let ir = ir_for(vec![ + Stmt::Let { + id: 1, + name: "m".to_string(), + ty: map_type(), + mutable: false, + init: Some(Expr::MapNew), + }, + Stmt::Expr(Expr::MapGet { + map: Box::new(Expr::LocalGet(1)), + key: Box::new(Expr::String("k".to_string())), + }), + ]); + + assert!( + ir.contains(THROW_BLOCK) && ir.contains(OK_BLOCK), + "the receiver guard's blocks must be emitted:\n{ir}" + ); + assert!( + ir.contains(OBJECT_TAG_TOP16), + "the fast path must test the object tag ({OBJECT_TAG_TOP16}):\n{ir}" + ); + // The guard is a guard: the ordinary lowering still reaches the helper. + assert!( + ir.contains("@js_map_get("), + "the guarded fast path must still call js_map_get:\n{ir}" + ); + + let throws = call_lines_with_successor(&ir, THROW_CALL); + assert!( + !throws.is_empty(), + "the miss path must call the diverging helper:\n{ir}" + ); + for (call, next) in throws { + assert!( + next.trim() == "unreachable", + "the throw helper diverges, so its block must end there \ + (call: {call}, next: {next})" + ); + } +} + +#[test] +fn set_add_guards_its_receiver_before_the_runtime_call() { + let ir = ir_for(vec![ + Stmt::Let { + id: 1, + name: "s".to_string(), + ty: set_type(), + mutable: false, + init: Some(Expr::SetNew), + }, + Stmt::Expr(Expr::SetAdd { + set_id: 1, + value: Box::new(Expr::String("x".to_string())), + }), + ]); + + assert!( + ir.contains(THROW_BLOCK) && ir.contains(THROW_CALL), + "set.add must guard its receiver:\n{ir}" + ); + assert!( + ir.contains("@js_set_add"), + "the guarded fast path must still call a js_set_add helper:\n{ir}" + ); +} + +#[test] +fn a_guardless_collection_free_body_emits_no_guard() { + // The control: nothing about the guard is unconditional module prelude. + let ir = ir_for(vec![Stmt::Expr(Expr::Integer(1))]); + assert!( + !ir.contains(THROW_BLOCK) && !ir.contains(THROW_CALL), + "a body with no collection receiver must emit no guard:\n{ir}" + ); +} diff --git a/crates/perry-runtime/src/collection_receiver.rs b/crates/perry-runtime/src/collection_receiver.rs new file mode 100644 index 0000000000..a3c6378081 --- /dev/null +++ b/crates/perry-runtime/src/collection_receiver.rs @@ -0,0 +1,63 @@ +//! The failure half of the receiver check codegen emits in front of every +//! static-type `Map` / `Set` fast path (#10446). +//! +//! Codegen lowers `s.add(v)`, `m.get(k)`, `this.labels.has(x)`, ... to a +//! direct `js_set_*` / `js_map_*` call when the receiver's DECLARED type is +//! `Set` / `Map`. Those helpers take the receiver as an unboxed 48-bit +//! payload, and a declared type is not a runtime fact: an uninitialized field +//! reads `undefined`, whose payload is the address `0x1`, and `js_set_add` +//! dereferenced it (SIGSEGV instead of a catchable TypeError — mongodb's +//! `MongoError.addErrorLabel` on an `errorLabelSet` that #10443 had left +//! undefined). +//! +//! The emitted guard is one compare of the box's top 16 bits against the +//! object tag. Every receiver that fails it and is not one of the untagged or +//! handle words the helpers have always accepted lands here. None of them +//! (`undefined`, `null`, booleans, numbers, strings, bigints) can have a +//! collection method, so this throws what the ordinary property lookup plus +//! call would have thrown. + +use crate::value::JSValue; + +/// Throw the `TypeError` for calling collection method `method` on the +/// non-object `receiver`. +/// +/// `undefined` / `null` produce V8's `Cannot read properties of undefined +/// (reading 'add')`; any other primitive produces perry's generic +/// `(number).add is not a function`, the same text the untyped method +/// dispatch throws for that receiver. +/// +/// `C-unwind` because generated code catches this through the same exception +/// path as `js_throw_type_error_property_access`, which it delegates to. +#[no_mangle] +pub extern "C-unwind" fn js_throw_collection_receiver_type_error( + receiver: f64, + method_ptr: *const u8, + method_len: usize, +) -> ! { + let value = JSValue::from_bits(receiver.to_bits()); + if value.is_undefined() || value.is_null() { + crate::error::js_throw_type_error_property_access( + u32::from(value.is_null()), + method_ptr, + method_len, + ); + } + let kind: &[u8] = if value.is_bool() { + b"boolean" + } else if value.is_any_string() { + b"string" + } else if value.is_bigint() { + b"bigint" + } else if value.is_int32() || value.is_number() { + b"number" + } else { + b"" + }; + crate::error::js_throw_type_error_not_a_function( + kind.as_ptr(), + kind.len(), + method_ptr, + method_len, + ) +} diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index f0f359f1f2..ceec019f32 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -91,6 +91,7 @@ pub mod cluster; pub mod cluster_sched; pub mod collection_iter; pub mod collection_iter_object; +pub mod collection_receiver; pub mod color_parse; pub mod date; #[cfg(feature = "mod-dgram")] diff --git a/test-files/test_gap_10443_error_subclass_field_init.ts b/test-files/test_gap_10443_error_subclass_field_init.ts new file mode 100644 index 0000000000..174f0a826b --- /dev/null +++ b/test-files/test_gap_10443_error_subclass_field_init.ts @@ -0,0 +1,245 @@ +// #10443: instance field initializers of a class whose DIRECT parent is a +// built-in Error constructor never ran when the class had its own constructor, +// so every field stayed `undefined` (mongodb's `MongoError.errorLabelSet`). +// Covers the whole Error family, with and without an explicit constructor, +// private `#fields`, static fields, and multi-level subclassing. + +function show(label: string, value: unknown): void { + console.log(label, value); +} + +class WithCtor extends Error { + n = 1; + labels: Set = new Set(); + list: string[] = []; + #secret = 'p'; + static kind = 'WithCtor'; + constructor(message: string) { + super(message); + show('in ctor after super():', `${this.n} ${typeof this.labels}`); + } + get secret(): string { + return this.#secret; + } +} + +const a = new WithCtor('m'); +show('WithCtor n/labels/list:', `${a.n} ${a.labels instanceof Set} ${a.list.length}`); +show('WithCtor private:', a.secret); +show('WithCtor static:', WithCtor.kind); +show('WithCtor message/name:', `${a.message} ${a.name}`); +show('WithCtor instanceof:', `${a instanceof WithCtor} ${a instanceof Error}`); +show('WithCtor stack:', typeof a.stack === 'string' && a.stack.length > 0); +a.labels.add('x'); +show('WithCtor labels after add:', a.labels.size); + +class NoCtor extends Error { + n = 2; + labels: Set = new Set(); +} +const b = new NoCtor('m2'); +show('NoCtor n/labels/message:', `${b.n} ${b.labels instanceof Set} ${b.message}`); + +// Every native error base, with an explicit constructor. +class TE extends TypeError { + n = 3; + constructor(m: string) { + super(m); + } +} +class RE extends RangeError { + n = 4; + constructor(m: string) { + super(m); + } +} +class SE extends SyntaxError { + n = 5; + constructor(m: string) { + super(m); + } +} +class RfE extends ReferenceError { + n = 6; + constructor(m: string) { + super(m); + } +} +class EE extends EvalError { + n = 7; + constructor(m: string) { + super(m); + } +} +class UE extends URIError { + n = 8; + constructor(m: string) { + super(m); + } +} +show('TypeError:', `${new TE('t').n} ${new TE('t').message} ${new TE('t') instanceof TypeError}`); +show('RangeError:', `${new RE('r').n} ${new RE('r').message} ${new RE('r') instanceof RangeError}`); +show('SyntaxError:', `${new SE('s').n} ${new SE('s').message}`); +show('ReferenceError:', `${new RfE('rf').n} ${new RfE('rf').message}`); +show('EvalError:', `${new EE('e').n} ${new EE('e').message}`); +show('URIError:', `${new UE('u').n} ${new UE('u').message}`); + +class AE extends AggregateError { + n = 9; + constructor(errors: Error[], m: string) { + super(errors, m); + } +} +const agg = new AE([new Error('inner')], 'agg'); +// Only the field-initializer half is compared: perry maps `super(errors, +// message)` to `super(message)` for an AggregateError subclass, so its +// `message` / `errors` are a separate, pre-existing defect. +show('AggregateError:', `${agg.n} ${agg instanceof AggregateError} ${agg instanceof Error}`); + +class DE extends DOMException { + n = 18; + constructor(m: string) { + super(m, 'AbortError'); + } +} +const dom = new DE('dm'); +show('DOMException:', `${dom.n} ${dom.message} ${dom.name} ${dom instanceof DOMException}`); + +class DENoCtor extends DOMException { + n = 19; +} +const dom2 = new DENoCtor('dm2', 'DataError'); +show('DOMException no ctor:', `${dom2.n} ${dom2.message} ${dom2.name}`); + +// Zero-argument constructor, statement before super(), class expression, +// class declared inside a function. +class ZeroArg extends Error { + n = 10; + constructor() { + super('zero'); + } +} +show('zero-arg ctor:', `${new ZeroArg().n} ${new ZeroArg().message}`); + +class BeforeSuper extends Error { + n = 11; + constructor(m: string) { + const upper = m.toUpperCase(); + super(upper); + } +} +show('stmt before super():', `${new BeforeSuper('bs').n} ${new BeforeSuper('bs').message}`); + +const Expr = class extends Error { + n = 12; + constructor(m: string) { + super(m); + } +}; +show('class expression:', `${new Expr('ce').n} ${new Expr('ce').message}`); + +function makeLocal(): Error & { n: number } { + class Local extends Error { + n = 13; + constructor(m: string) { + super(m); + } + } + return new Local('local'); +} +const local = makeLocal(); +show('class in function:', `${local.n} ${local.message}`); + +// Multi-level: fields on every level, each level with its own constructor. +class MidA extends Error { + m = 14; + constructor(msg: string) { + super(msg); + } +} +class LeafB extends MidA { + n = 15; + constructor(msg: string) { + super(msg); + } +} +const leaf = new LeafB('leaf'); +show('grandchild m/n/message:', `${leaf.m} ${leaf.n} ${leaf.message}`); +show('grandchild instanceof:', `${leaf instanceof LeafB} ${leaf instanceof MidA} ${leaf instanceof Error}`); + +// A ctor-less level in the middle, and a ctor-less leaf. +class MidC extends Error { + m = 16; + constructor(msg: string) { + super(msg); + } +} +class LeafD extends MidC { + n = 17; +} +const leafD = new LeafD('leafD'); +show('ctor-less leaf m/n/message:', `${leafD.m} ${leafD.n} ${leafD.message}`); + +// Field initializers must run exactly once — a side effect proves it. +let inits = 0; +class CountedBase extends Error { + tick = ++inits; + constructor(msg: string) { + super(msg); + } +} +class CountedLeaf extends CountedBase { + own = ++inits; + constructor(msg: string) { + super(msg); + } +} +const counted = new CountedLeaf('counted'); +show('init order/count:', `${counted.tick} ${counted.own} ${inits}`); + +// A private field installed twice would throw; this proves it is installed once. +class PrivBase extends Error { + #tag = 'base'; + constructor(msg: string) { + super(msg); + } + get tag(): string { + return this.#tag; + } +} +class PrivLeaf extends PrivBase { + #own = 'leaf'; + constructor(msg: string) { + super(msg); + } + get own(): string { + return this.#own; + } +} +const priv = new PrivLeaf('priv'); +show('private chain:', `${priv.tag} ${priv.own} ${priv.message}`); + +// The mongodb shape: a Set field plus a method that mutates it. +class MongoLikeError extends Error { + private readonly errorLabelSet: Set = new Set(); + constructor(message: string) { + super(message); + } + addErrorLabel(label: string): void { + this.errorLabelSet.add(label); + } + hasErrorLabel(label: string): boolean { + return this.errorLabelSet.has(label); + } +} +const mongo = new MongoLikeError('conn'); +mongo.addErrorLabel('ResetPool'); +show('mongo-like:', `${mongo.hasErrorLabel('ResetPool')} ${mongo.hasErrorLabel('Other')} ${mongo.message}`); + +// Error subclass used through a catch clause keeps its fields. +try { + throw new WithCtor('thrown'); +} catch (e: unknown) { + const err = e as WithCtor; + show('caught:', `${err.n} ${err.message} ${err instanceof WithCtor}`); +} diff --git a/test-files/test_gap_10446_typed_collection_receiver.ts b/test-files/test_gap_10446_typed_collection_receiver.ts new file mode 100644 index 0000000000..23388eabe9 --- /dev/null +++ b/test-files/test_gap_10446_typed_collection_receiver.ts @@ -0,0 +1,162 @@ +// #10446: a `Set`/`Map`-typed value holding `undefined` (or null, or a +// primitive) reached the direct js_set_*/js_map_* fast path with no receiver +// check, so the process died with SIGSEGV instead of throwing a catchable +// TypeError. Node's message text for a non-nullish receiver names the source +// expression (`h.set.add is not a function`), which perry does not reproduce +// on any path, so those rows print the error CLASS only. + +function nullish(label: string, fn: () => unknown): void { + try { + fn(); + console.log(label, 'no throw'); + } catch (e: unknown) { + const err = e as Error; + console.log(label, err.constructor.name, err.message); + } +} + +function threw(label: string, fn: () => unknown): void { + try { + fn(); + console.log(label, 'no throw'); + } catch (e: unknown) { + const err = e as Error; + console.log(label, 'threw', err.constructor.name); + } +} + +class Holder { + set: Set = new Set(); + map: Map = new Map(); + add(x: string): void { + this.set.add(x); + } + has(x: string): boolean { + return this.set.has(x); + } + del(x: string): boolean { + return this.set.delete(x); + } + clearSet(): void { + this.set.clear(); + } + setSize(): number { + return this.set.size; + } + put(k: string, v: number): void { + this.map.set(k, v); + } + get(k: string): number | undefined { + return this.map.get(k); + } + hasKey(k: string): boolean { + return this.map.has(k); + } + delKey(k: string): boolean { + return this.map.delete(k); + } + clearMap(): void { + this.map.clear(); + } + mapSize(): number { + return this.map.size; + } + eachSet(): void { + this.set.forEach(() => {}); + } + eachMap(): void { + this.map.forEach(() => {}); + } +} + +// The working shape first: a real Set/Map must behave exactly as before. +const ok = new Holder(); +ok.add('a'); +ok.put('k', 1); +console.log('works:', ok.has('a'), ok.get('k'), ok.setSize(), ok.mapSize(), ok.del('a'), ok.delKey('k')); + +// Field receivers holding undefined / null. +for (const bad of [undefined, null]) { + const h = new Holder(); + (h as any).set = bad; + (h as any).map = bad; + const tag = bad === null ? 'null' : 'undefined'; + nullish(`field set.add ${tag}:`, () => h.add('x')); + nullish(`field set.has ${tag}:`, () => h.has('x')); + nullish(`field set.delete ${tag}:`, () => h.del('x')); + nullish(`field set.clear ${tag}:`, () => h.clearSet()); + nullish(`field set.size ${tag}:`, () => h.setSize()); + nullish(`field set.forEach ${tag}:`, () => h.eachSet()); + nullish(`field map.set ${tag}:`, () => h.put('x', 1)); + nullish(`field map.get ${tag}:`, () => h.get('x')); + nullish(`field map.has ${tag}:`, () => h.hasKey('x')); + nullish(`field map.delete ${tag}:`, () => h.delKey('x')); + nullish(`field map.clear ${tag}:`, () => h.clearMap()); + nullish(`field map.size ${tag}:`, () => h.mapSize()); + nullish(`field map.forEach ${tag}:`, () => h.eachMap()); +} + +// Local bindings whose declared type is Set/Map. +function localSet(value: unknown, tag: string): void { + const s: Set = value as Set; + nullish(`local set.add ${tag}:`, () => s.add('x')); + nullish(`local set.has ${tag}:`, () => s.has('x')); + nullish(`local set.delete ${tag}:`, () => s.delete('x')); + nullish(`local set.clear ${tag}:`, () => s.clear()); +} + +function localMap(value: unknown, tag: string): void { + const m: Map = value as Map; + nullish(`local map.set ${tag}:`, () => m.set('x', 1)); + nullish(`local map.get ${tag}:`, () => m.get('x')); + nullish(`local map.has ${tag}:`, () => m.has('x')); + nullish(`local map.delete ${tag}:`, () => m.delete('x')); + nullish(`local map.clear ${tag}:`, () => m.clear()); +} + +localSet(undefined, 'undefined'); +localSet(null, 'null'); +localMap(undefined, 'undefined'); +localMap(null, 'null'); + +// Wrong-type receivers: a number, a string and a boolean can carry no +// collection method, so every engine throws a TypeError. Only the class is +// compared (see the header note about Node's expression-naming message). +function wrongType(value: unknown, tag: string): void { + const s: Set = value as Set; + const m: Map = value as Map; + threw(`local set.add ${tag}:`, () => s.add('x')); + threw(`local set.has ${tag}:`, () => s.has('x')); + threw(`local map.get ${tag}:`, () => m.get('x')); + threw(`local map.set ${tag}:`, () => m.set('x', 1)); + const h = new Holder(); + (h as any).set = value; + (h as any).map = value; + threw(`field set.add ${tag}:`, () => h.add('x')); + threw(`field map.get ${tag}:`, () => h.get('x')); +} + +wrongType(42, 'number'); +wrongType('str', 'string'); +wrongType(true, 'boolean'); + +// A method call on a genuine Set/Map reached through the same typed paths +// after all the throwing above still works. +const after = new Holder(); +after.add('z'); +after.put('z', 26); +console.log('still works:', after.has('z'), after.get('z'), after.setSize(), after.mapSize()); + +// Number- and string-keyed fast paths (the typed helper variants) on a +// nullish receiver take the same guard. +function typedKeys(): void { + const numKeys: Map = undefined as unknown as Map; + const strSet: Set = undefined as unknown as Set; + const numSet: Set = undefined as unknown as Set; + nullish('number-key map.set:', () => numKeys.set(1, 2)); + nullish('number-key map.get:', () => numKeys.get(1)); + nullish('string set.add:', () => strSet.add('s')); + nullish('number set.add:', () => numSet.add(1)); + nullish('number set.has:', () => numSet.has(1)); +} +typedKeys(); From 278b770a80bfebc8491fdf9b13dd55a5b310b7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 11:43:21 +0000 Subject: [PATCH 2/3] style: cargo fmt --- crates/perry-codegen/src/expr/bigint_set.rs | 6 ++++-- crates/perry-codegen/src/expr/mod.rs | 4 ++-- crates/perry-codegen/tests/error_subclass_field_init.rs | 8 +++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/perry-codegen/src/expr/bigint_set.rs b/crates/perry-codegen/src/expr/bigint_set.rs index 8f4bff5eba..4184dcd86f 100644 --- a/crates/perry-codegen/src/expr/bigint_set.rs +++ b/crates/perry-codegen/src/expr/bigint_set.rs @@ -1277,7 +1277,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { value, crate::native_value::ExpectedNativeRep::StringRef, )?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; + let s_handle = + reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); let i32_v = blk.call( @@ -1308,7 +1309,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; + let s_handle = + reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call(I32, "js_set_delete", &[(I64, &s_handle), (DOUBLE, &v_box)]) diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index f93279ee9b..1a3d1f5617 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -41,9 +41,9 @@ pub(crate) use bitset_test::is_u32_bitset_test; mod buffer_access; mod buffer_views; mod channel; -mod collection_receiver; #[cfg(test)] mod class_method_arguments_object_tests; +mod collection_receiver; #[cfg(test)] mod conforming_layout_note_tests; mod helpers; @@ -82,11 +82,11 @@ pub(crate) use buffer_views::{ invalidate_native_owned_views_for_dispose, native_arena_canonical_owner_id, record_native_arena_owner_assignment, update_buffer_view_for_assignment, }; -pub(crate) use collection_receiver::unbox_collection_receiver; pub(crate) use channel::{ extract_array_of_object_shape, lower_channel_reduction, try_match_channel_reduction, variant_name, }; +pub(crate) use collection_receiver::unbox_collection_receiver; pub(crate) use helpers::{ array_store_needs_layout_note, array_store_needs_write_barrier, buffer_alias_metadata_suffix, class_field_store_layout_note_is_conforming, class_field_store_needs_layout_note, diff --git a/crates/perry-codegen/tests/error_subclass_field_init.rs b/crates/perry-codegen/tests/error_subclass_field_init.rs index d2b0d9a42f..ef60d3a2ed 100644 --- a/crates/perry-codegen/tests/error_subclass_field_init.rs +++ b/crates/perry-codegen/tests/error_subclass_field_init.rs @@ -63,7 +63,13 @@ fn ctor(id: u32, body: Vec) -> Function { } } -fn class(id: u32, name: &str, extends: &str, fields: Vec, ctor: Option) -> Class { +fn class( + id: u32, + name: &str, + extends: &str, + fields: Vec, + ctor: Option, +) -> Class { Class { id, name: name.to_string(), From 68de1f42df3167eff63be6003da119dfbef48260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 12:01:01 +0000 Subject: [PATCH 3/3] changelog: 10617 field-init/collection-receiver-guard --- ...617-field-init-collection-receiver-guard.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 changelog.d/10617-field-init-collection-receiver-guard.md diff --git a/changelog.d/10617-field-init-collection-receiver-guard.md b/changelog.d/10617-field-init-collection-receiver-guard.md new file mode 100644 index 0000000000..3cfce52f66 --- /dev/null +++ b/changelog.d/10617-field-init-collection-receiver-guard.md @@ -0,0 +1,18 @@ +### Fixed + +A class whose direct parent is a built-in (`Error`/`TypeError`/other non-user base) and that +declares its own `super()`-calling constructor now runs its own field initializers. Every other +non-user-parent `super()` arm already did this after the base constructor returns; the +`Error`-family arm was the one that skipped it, so `class E extends Error { labels = +new Set(); constructor(m) { super(m); } }` left `labels` `undefined`. + +Calling a method (`.add`/`.set`/`.get`/`.has`/`.delete`/`.clear`/`.forEach`/...) on a +statically-typed `Set`/`Map` value that holds `undefined`, `null`, or another +primitive at runtime now throws a catchable `TypeError` instead of segfaulting. The static-type +fast path unboxed the receiver's declared-type payload with no tag check; a receiver guard now +runs first, on the fast/common path costing one compare of the receiver's tag bits. + +Together these fixed a crash in the mongodb 7.5.0 driver: `MongoError`'s +`errorLabelSet: Set` field was left `undefined` by the first bug, and +`addErrorLabel()` calling `.add()` on it segfaulted via the second, about 100ms after +`client.connect()`.