From d74d2988178b9117d2c6f015133367f59da6146e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 09:23:09 +0000 Subject: [PATCH 01/30] perf(regex): give a replacer its arguments without a JS array A regex replace with a function replacement built a fresh JS array per match to carry the callback's arguments: `js_array_alloc(0)`, then a push per argument that reallocated as it grew, each push opening a handle scope and a caught frame. `call` then read the whole array straight back out into native slots and dropped it. No user code could observe it -- except through a proxy replacer, whose `apply` trap does receive the arguments as an array. The slots `call` builds are already GC roots: it binds each one to the shadow stack before invoking the replacer. Binding them first and writing the arguments into them afterwards removes the array entirely. A value is rooted from the moment it is written, so producing the next argument may allocate and collect, which is what copying a match's capture strings does. The buffer is sized once from the program's capture count and reused across matches. A proxy replacer keeps the array path. Instructions per workload, control subtracted, same commit, release build, over 1.1-1.5M character subjects with ~200,000 matches each: replace with callback, ASCII 70,896,578,215 -> 51,624,603,466 -27.2% replace with callback, Unicode 79,956,564,097 -> 61,666,635,190 -22.9% replace with a string template 28,421,246,293 -> 28,429,203,739 +0.0% That is 30.3x -> 22.1x Node 26.5.1 on the ASCII row and 21.1x -> 16.3x on the Unicode one. The template row is the control: it does not take this path and does not move. A profile attributes the saving. Before, the callback path spent 40% of its instructions in the collector and 11.7% in JS array operations against a template path that spent ~0% in array operations; the regex engine did the same work in both (8.2B vs 7.3B instructions), which is what says the difference is host machinery rather than matching. `tests.rs` gains a regression test with a measured reason to exist: with the reset of the reused buffer removed, the whole 3,984-test lib suite still passed, and `(a)|(b)` over "aba" -- where each match leaves a capture unset that the previous match set -- returns `` instead of ``. The test asserts it reached the direct path, so it cannot quietly pass against the exec-object fallback. --- .../src/regex/perex_replace_direct.rs | 38 ++++++++- .../src/regex/perex_replace_storage.rs | 84 +++++++++++++++++++ crates/perry-runtime/src/regex/tests.rs | 47 +++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/regex/perex_replace_direct.rs b/crates/perry-runtime/src/regex/perex_replace_direct.rs index 85561d67e2..dd715048ff 100644 --- a/crates/perry-runtime/src/regex/perex_replace_direct.rs +++ b/crates/perry-runtime/src/regex/perex_replace_direct.rs @@ -20,7 +20,9 @@ use super::perex_api::{self as api, ExecOutput, Reuse}; use super::perex_match_search::advance; use super::perex_memory::{MemoryBudget, StorageError}; use super::perex_owner::HeapSubject; -use super::perex_replace_storage::{boxed, call, length, text, List, Pieces, Units}; +use super::perex_replace_storage::{ + boxed, call, call_native, length, text, List, NativeArgs, Pieces, Units, +}; use super::perex_runtime::{self as host, EngineError}; use super::perex_strings::SpanCopies; use super::RegExpHeader; @@ -251,6 +253,16 @@ pub(super) fn replace( } else { Pieces::new(scope)? }; + // An ordinary replacer's arguments never reach user code as an array, so + // they are produced straight into shadow-stack slots. A proxy replacer's do + // reach it, through the `apply` trap, and keep the JS array. Sized once: + // the program's capture count fixes the argument count for every match. + let mut native_args = + if tokens.is_some() || crate::proxy::js_proxy_is_proxy(replacement.get_nanbox_f64()) == 1 { + None + } else { + Some(NativeArgs::new(captures + 3)?) + }; let mut next_source = 0; for record in spans.values.chunks_exact(width) { let local = RuntimeHandleScope::new(); @@ -284,6 +296,30 @@ pub(super) fn replace( } } } + } else if let Some(args) = native_args.as_mut() { + let this = local.root_nanbox_f64(f64::from_bits(TAG_UNDEFINED)); + let copies = &mut copies; + let value = call_native(replacement, &this, args, memory, |set| { + let matched = copies.copy(start, end, budget)?; + set(0, js_nanbox_string(matched as i64)); + let mut slot = 1; + for pair in record[2..].as_chunks::<2>().0 { + // An unset capture is the `undefined` the slot already holds. + if pair[0] != u32::MAX { + let capture = copies.copy(pair[0] as usize, pair[1] as usize, budget)?; + set(slot, js_nanbox_string(capture as i64)); + } + slot += 1; + } + set(slot, position as f64); + set(slot + 1, boxed(input)); + Ok(()) + })?; + let value = local.root_nanbox_f64(value); + let value = text(&local, &value)?; + if accepted { + output.whole(&value, budget)?; + } } else { let mut args = List::new(&local)?; let matched = copies.copy(start, end, budget)?; diff --git a/crates/perry-runtime/src/regex/perex_replace_storage.rs b/crates/perry-runtime/src/regex/perex_replace_storage.rs index bc8f0fb1c1..27e9bdbcc4 100644 --- a/crates/perry-runtime/src/regex/perex_replace_storage.rs +++ b/crates/perry-runtime/src/regex/perex_replace_storage.rs @@ -130,6 +130,90 @@ pub(super) fn call( result } +/// Arguments for a replacer call, produced straight into shadow-stack slots. +/// +/// `call` builds its slots by copying a JS array the caller filled one push at +/// a time. For a replacer invoked once per match that array is pure overhead: +/// it is allocated, grown as each argument is pushed, read back out, and +/// dropped, and no user code can observe it. Here the slots are bound to the +/// shadow stack *before* any argument is produced, so a value is a traced root +/// from the moment it is written and producing the next one may allocate and +/// collect freely. The buffer is sized once and outlives the match loop. +/// +/// Not usable for a proxy replacer: `js_proxy_apply` takes the arguments as a +/// JS array, which the `apply` trap observes, so that path keeps `List`. +pub(super) struct NativeArgs { + slots: Vec>, +} + +impl NativeArgs { + pub(super) fn new(count: usize) -> Result { + let mut slots = Vec::new(); + slots + .try_reserve_exact(count) + .map_err(|_| StorageError::Allocation)?; + for _ in 0..count { + slots.push(std::cell::UnsafeCell::new(f64::from_bits( + crate::value::TAG_UNDEFINED, + ))); + } + Ok(Self { slots }) + } +} + +/// `call` for an ordinary replacer, with the arguments produced by `fill` +/// directly into `args`. +/// +/// Every slot is reset to `undefined` and bound before `fill` runs, so an +/// argument `fill` does not write stays `undefined` (an unset capture), and one +/// it does write is rooted immediately. `fill` may therefore allocate between +/// arguments, which is what producing a match's capture strings does. +pub(super) fn call_native( + method: &RuntimeHandle<'_>, + receiver: &RuntimeHandle<'_>, + args: &mut NativeArgs, + memory: &MemoryBudget, + fill: impl FnOnce(&mut dyn FnMut(usize, f64)) -> Result<(), EngineError>, +) -> Result { + struct Frame(u64); + impl Drop for Frame { + fn drop(&mut self) { + crate::gc::js_shadow_frame_pop(self.0); + } + } + let scope = RuntimeHandleScope::new(); + let previous = scope.root_nanbox_f64(crate::object::js_implicit_this_get()); + let slots = &args.slots; + for slot in slots { + // SAFETY: nothing else holds a reference to these cells, and the + // shadow stack is not yet bound to them. + unsafe { *slot.get() = f64::from_bits(crate::value::TAG_UNDEFINED) }; + } + let frame = Frame(crate::gc::js_shadow_frame_push(slots.len() as u32)); + for (i, slot) in slots.iter().enumerate() { + crate::gc::js_shadow_slot_bind(i as u32, slot.get().cast()); + } + // SAFETY as above; the slots are bound, so a write publishes a root. + let mut set = |i: usize, value: f64| unsafe { *slots[i].get() = value }; + fill(&mut set)?; + let reservation = Reservation::new( + memory, + slots.len().checked_mul(8).ok_or(StorageError::Limit)?, + )?; + let result = api::caught(|| unsafe { + crate::object::js_implicit_this_set(receiver.get_nanbox_f64()); + crate::closure::js_native_call_value( + method.get_nanbox_f64(), + slots.as_ptr().cast(), + slots.len(), + ) + }); + crate::object::js_implicit_this_set(previous.get_nanbox_f64()); + drop(reservation); + drop(frame); + result +} + /// A reusable original-input reader. A read retains only Perex offsets across /// collection, and adjacent reads do not repeat the initial Unicode seek. pub(super) struct Units<'a, 's> { diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index 4eb2544d33..5abf8edf85 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -89,6 +89,53 @@ fn js_replacement_expands_special_patterns() { } } +/// A replacer's argument buffer is reused across matches, so it must not carry +/// one match's capture into the next. `(a)|(b)` over "aba" participates in +/// capture 1, then capture 2, then capture 1 again, so each match has an unset +/// capture that the previous match set. +/// +/// This is a regression test with a measured reason to exist: with the reset in +/// `call_native` removed, the whole 3,984-test lib suite still passed, and this +/// case returns `` instead of the correct answer. +#[test] +fn direct_replace_callback_does_not_carry_a_capture_between_matches() { + let _lock = crate::gc::global_side_table_test_lock(); + let scope = crate::gc::RuntimeHandleScope::new(); + let pattern = scope.root_string_ptr(make_string("(a)|(b)")); + let flags = scope.root_string_ptr(make_string("g")); + let re = scope.root_raw_mut_ptr( + pattern.with_const_ptr(|p| flags.with_const_ptr(|f| js_regexp_new(p, f))), + ); + let subject = scope.root_string_ptr(make_string("aba")); + let replacer = scope.root_nanbox_f64(crate::dyn_eval::dyn_function_from_strings(&[ + "m".to_string(), + "p1".to_string(), + "p2".to_string(), + "return '<' + String(p1) + ',' + String(p2) + '>';".to_string(), + ])); + let before = super::perex_replace_direct::direct_replaces(); + let out = subject.with_const_ptr::(|s| { + re.with_const_ptr::(|r| { + super::perex_replace::js_string_replace_js( + crate::value::js_nanbox_string(s as i64), + crate::value::js_nanbox_pointer(r as i64), + replacer.get_nanbox_f64(), + ) + }) + }); + // Without this the test would pass just as well against the exec-object + // fallback, which has no shared buffer and so cannot show the bug. + assert!( + super::perex_replace_direct::direct_replaces() > before, + "did not reach the direct replace path, so this test proves nothing" + ); + let out = crate::value::js_nanbox_get_pointer(out) as *const StringHeader; + assert_eq!( + string_as_str(out), + "" + ); +} + fn replace_case(pattern: &str, subject: &str, replacement: &str) -> String { let scope = crate::gc::RuntimeHandleScope::new(); let pattern = scope.root_string_ptr(make_string(pattern)); From 22c4002b94470331e26c80d78cf70a038d1f11c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 09:24:01 +0000 Subject: [PATCH 02/30] docs(changelog): fragment for #10605 --- changelog.d/10605-replacer-native-args.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10605-replacer-native-args.md diff --git a/changelog.d/10605-replacer-native-args.md b/changelog.d/10605-replacer-native-args.md new file mode 100644 index 0000000000..c9802226b0 --- /dev/null +++ b/changelog.d/10605-replacer-native-args.md @@ -0,0 +1,3 @@ +### Faster + +- `String.prototype.replace` with a function replacement spends about 27% fewer instructions, and roughly a quarter less on non-ASCII subjects. Each match used to build a JS array to carry the replacer's arguments, growing it one push at a time and then reading it straight back out into the native slots the call actually uses; the arguments now go directly into those slots, which were already the collector's roots for them. A proxy replacer, whose `apply` trap really does receive an arguments array, is unaffected (#10165). From 5befcedbbd8ee632b16c75a46c8ecacb463d9228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 09:50:14 +0000 Subject: [PATCH 03/30] fix(runtime): resolve a shadowed inherited field to its most-derived slot An overridden field (`class Sub extends Base { tag = ... }` where `Base` also declares `tag`) is not deduplicated in the packed inline-slot layout: the object holds one slot per declaration, ancestor first. The compile-time-typed read path already resolves to the most-derived slot ("TS shadowing"); every dynamic by-name lookup returned the first (ancestor's, never-written) slot instead -- observable from an inherited accessor's `this.field`, a computed `obj[key]` read, and Reflect/has-own checks. --- .../src/object/field_get_set/ic_miss.rs | 3 +- .../perry-runtime/src/object/keys_lookup.rs | 89 ++++++++++++++++++- crates/perry-runtime/src/object/shapes.rs | 21 ++++- .../perry-runtime/src/object/shapes_tests.rs | 37 ++++++++ scripts/check_file_size.sh | 6 ++ ...ap_10595_inherited_accessor_field_shape.ts | 81 +++++++++++++++++ 6 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 test-files/test_gap_10595_inherited_accessor_field_shape.ts diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 9d3b4ea774..4005e85cf2 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -893,7 +893,8 @@ pub(super) fn get_field_ic_miss_impl( let key_count = shape.logical_key_count as usize; let keys_data = (keys as *const u8).add(8) as *const f64; let alloc_limit = shape.live_inline_slot_count as usize; - for i in 0..key_count { + for i in (0..key_count).rev() { + // #10595: back-to-front so a shadowed field's most-derived slot wins; see keys_lookup.rs. let k_bits = (*keys_data.add(i)).to_bits(); let k_ptr = (k_bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::StringHeader; if !k_ptr.is_null() && crate::string::js_string_equals(k_ptr, key) != 0 { diff --git a/crates/perry-runtime/src/object/keys_lookup.rs b/crates/perry-runtime/src/object/keys_lookup.rs index fcff25538c..111b783a1d 100644 --- a/crates/perry-runtime/src/object/keys_lookup.rs +++ b/crates/perry-runtime/src/object/keys_lookup.rs @@ -100,7 +100,20 @@ pub(crate) unsafe fn keys_find_slot_by_bytes( } let n = (key_count as usize).min(slot_len); let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - for i in 0..n { + // #10595: scan back-to-front. A subclass field that re-declares an + // ancestor's field name (`class Sub extends Base { tag = ... }` where + // `Base` also declares `tag`) is NOT deduplicated in the packed keys — + // `codegen/mod.rs` lists ancestor fields first, then the class's own, so + // the array holds one entry per DECLARATION, oldest ancestor first, most + // derived last. `class_field_global_index` (the compile-time-typed read's + // index resolver) already picks the most-derived declaration ("TS + // shadowing"); this dynamic by-name lookup must agree, or a receiver + // whose static type is unknown (an inherited accessor's `this.field`, a + // computed `obj[key]`) sees the ancestor's stale slot instead of the + // override. Scanning in reverse finds that same most-derived match first, + // with no change to storage layout and no cost in the (common, no + // shadowing) case where a name occurs once. + for i in (0..n).rev() { let v = crate::JSValue::from_bits((*slots.add(i)).to_bits()); if let Some(stored) = crate::string::js_string_key_bytes(v, &mut sso) { if stored == key_bytes { @@ -204,7 +217,8 @@ pub(crate) unsafe fn keys_find_slot_by_key_ptr( return None; } let n = (key_count as usize).min(slot_len); - for i in 0..n { + // #10595: same most-derived-wins scan direction as the fast path above. + for i in (0..n).rev() { let v = crate::JSValue::from_bits((*slots.add(i)).to_bits()); if crate::string::js_string_key_matches(v, key) { return Some(i as u32); @@ -249,3 +263,74 @@ pub(crate) fn keys_index_insert( } shapes::shape_note_append(keys, new_count, key_hash, slot); } + +#[cfg(test)] +mod tests_10595 { + use super::*; + + /// #10595: a subclass field that re-declares an ancestor's field name is + /// not deduplicated in the packed keys array built by + /// `crates/perry-codegen/src/codegen/mod.rs` (ancestor fields first, + /// then the class's own) — the array genuinely holds two entries for + /// one logical property, oldest declaration first. Only the LAST + /// (most-derived) slot is ever written, matching + /// `class_field_global_index`'s "TS shadowing" resolution for the + /// compile-time-typed path. A dynamic by-name lookup that returned the + /// first match instead found the never-initialized ancestor slot. + #[test] + fn duplicate_key_name_resolves_to_the_last_occurrence() { + let ancestor_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let override_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let keys = crate::array::js_array_alloc(4); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(ancestor_key)); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(override_key)); + + let lookup_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + unsafe { + assert_eq!( + keys_find_slot_by_key_ptr(keys, 2, lookup_key), + Some(1), + "must resolve to the most-derived slot (index 1), not the ancestor's (index 0)" + ); + // `keys_find_slot_by_bytes` is the byte-slice twin the pointer + // form delegates to for a valid header; pin it directly too. + assert_eq!( + keys_find_slot_by_bytes(keys, 2, b"tag"), + Some(1), + "byte-slice lookup must agree with the pointer-key lookup" + ); + } + } + + /// The common (non-shadowing) case — a name that occurs exactly once — + /// must be completely unaffected by scanning in reverse. + #[test] + fn single_occurrence_key_is_unaffected_by_scan_direction() { + let a = crate::string::js_string_from_bytes(b"x".as_ptr(), 1); + let b = crate::string::js_string_from_bytes(b"y".as_ptr(), 1); + let keys = crate::array::js_array_alloc(4); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(a)); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(b)); + + let lookup_x = crate::string::js_string_from_bytes(b"x".as_ptr(), 1); + let lookup_y = crate::string::js_string_from_bytes(b"y".as_ptr(), 1); + unsafe { + assert_eq!(keys_find_slot_by_key_ptr(keys, 2, lookup_x), Some(0)); + assert_eq!(keys_find_slot_by_key_ptr(keys, 2, lookup_y), Some(1)); + } + } + + /// A key that is genuinely absent must still miss, in both scan + /// directions. + #[test] + fn absent_key_is_not_found() { + let a = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let keys = crate::array::js_array_alloc(4); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(a)); + + let lookup = crate::string::js_string_from_bytes(b"tagViaGetter".as_ptr(), 12); + unsafe { + assert_eq!(keys_find_slot_by_key_ptr(keys, 1, lookup), None); + } + } +} diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 329caeeb07..215a2ea560 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -2027,6 +2027,22 @@ pub(crate) unsafe fn shape_slot_lookup_verdict( }; let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; let (slots, slot_len) = super::keys_array_dense_slots(keys); + // #10595: keep scanning past the first content match and keep the + // HIGHEST slot index among them, not the probe order's first hit. + // A field name that a subclass re-declares (`class Sub extends Base { + // tag = ... }` where `Base` also declares `tag`) is NOT deduplicated in + // the packed keys — `codegen/mod.rs` lists ancestor fields first, then + // the class's own, so a genuine duplicate always has the most-derived + // declaration at the HIGHER slot index, regardless of this table's probe + // order (which is insertion order for a fresh table, but open-addressing + // growth/rehash can reshuffle it). `class_field_global_index` — the + // compile-time-typed read's index resolver — already picks the + // most-derived declaration ("TS shadowing"); this dynamic by-name lookup + // must agree, or a receiver whose static type is unknown (an inherited + // accessor's `this.field`, a computed `obj[key]`) sees the ancestor's + // stale slot instead of the override. A name with only one candidate + // (the common, non-shadowing case) is unaffected. + let mut found: Option = None; for i in shape.slots.candidates(key_hash) { if (i as usize) >= slot_len || i >= key_count { continue; @@ -2034,10 +2050,13 @@ pub(crate) unsafe fn shape_slot_lookup_verdict( let v = crate::JSValue::from_bits((*slots.add(i as usize)).to_bits()); if let Some(stored) = crate::string::js_string_key_bytes(v, &mut sso) { if stored == key_bytes { - return KeysIndexVerdict::Found(i); + found = Some(found.map_or(i, |prev| prev.max(i))); } } } + if let Some(i) = found { + return KeysIndexVerdict::Found(i); + } // Hash-bucket candidates existed but none matched: with a complete index // that still proves absence (the bucket held colliding OTHER keys). absent diff --git a/crates/perry-runtime/src/object/shapes_tests.rs b/crates/perry-runtime/src/object/shapes_tests.rs index 4325b432f9..5fd8e5fa71 100644 --- a/crates/perry-runtime/src/object/shapes_tests.rs +++ b/crates/perry-runtime/src/object/shapes_tests.rs @@ -1182,3 +1182,40 @@ fn the_ordinary_slot_query_declines_a_class_kind_shape() { ); } } + +#[cfg(test)] +mod issue_10595_tests { + use super::*; + + /// #10595: the >=`KEYS_INDEX_THRESHOLD` indexed lookup must agree with + /// the linear-scan lookups fixed in `object/keys_lookup.rs` — a + /// duplicate key name (a subclass field re-declaring an ancestor's + /// field, never deduplicated in the packed keys) must resolve to the + /// HIGHEST slot index among the candidates a hash bucket returns, not + /// whichever one the open-addressing probe order happens to visit + /// first. + #[test] + fn indexed_lookup_duplicate_key_name_resolves_to_the_highest_slot() { + let ancestor_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let override_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let keys = crate::array::js_array_alloc(4); + let keys = crate::array::js_array_push(keys, crate::JSValue::string_ptr(ancestor_key)); + let keys = crate::array::js_array_push(keys, crate::JSValue::string_ptr(override_key)); + + let h = crate::object::key_bytes_hash(b"tag".as_ptr(), 3); + unsafe { + // build=true: force the index to cover both slots regardless of + // KEYS_INDEX_THRESHOLD — the verdict function itself does not + // gate on that threshold, only its linear-scan callers do. + let verdict = shape_slot_lookup_verdict(keys, b"tag", h, 2, true); + match verdict { + KeysIndexVerdict::Found(slot) => assert_eq!( + slot, 1, + "must resolve to the most-derived slot (index 1), not the ancestor's (index 0)" + ), + KeysIndexVerdict::Absent => panic!("expected Found(1), got Absent"), + KeysIndexVerdict::Unindexed => panic!("expected Found(1), got Unindexed"), + } + } + } +} diff --git a/scripts/check_file_size.sh b/scripts/check_file_size.sh index f34edab097..27dc952b75 100755 --- a/scripts/check_file_size.sh +++ b/scripts/check_file_size.sh @@ -80,6 +80,12 @@ crates/perry-hir/src/lower_decl/body_stmt.rs # finally-wrapper tail (~790 lines) into a sibling module — is a mechanical # cut deferred to a focused follow-up, same pattern as body_stmt.rs above. crates/perry-runtime/src/promise/then.rs +# #10595 shadowed-field fix: `get_field_ic_miss_impl`'s inline own-key scan +# now walks back-to-front (1 line) with a 1-line rationale comment, matching +# the compile-time-typed path's most-derived-wins rule. The file was already +# exactly at the 2000-line gate; a structural split of the IC-miss ladder +# into a sibling module is deferred to a focused follow-up. +crates/perry-runtime/src/object/field_get_set/ic_miss.rs # --- Representation-aware type lowering (#5466 / #5464) --- # These files crossed the gate on the type-lowering branch (native i32/u32/f64/ # i128/StringRef reps, guarded fast/fallback splits, and the material-evidence diff --git a/test-files/test_gap_10595_inherited_accessor_field_shape.ts b/test-files/test_gap_10595_inherited_accessor_field_shape.ts new file mode 100644 index 0000000000..06e4fc8c68 --- /dev/null +++ b/test-files/test_gap_10595_inherited_accessor_field_shape.ts @@ -0,0 +1,81 @@ +// #10595: a field a subclass overrides must read as the SUBCLASS's value +// from every read path, not just a direct `obj.field` access — including +// from inside an inherited accessor (`Object.defineProperty` getter/setter), +// a dynamic `obj[computedKey]` read, and across a two-level `extends` chain. +// +// Root cause: a subclass field declaration that shares a name with an +// ancestor field is not deduplicated in the class's packed inline-slot +// layout (ancestor fields first, then the class's own), so the object ends +// up with two inline slots for the same logical property. The compile-time +// typed path (`obj.field` on a statically-known class) already resolves to +// the most-derived slot. Every DYNAMIC name-based lookup (an inherited +// accessor's `this.field`, a computed `obj[key]`) used to return the FIRST +// (ancestor's, uninitialized) slot instead. + +class Base { + tag = "base-tag"; +} + +Object.defineProperty(Base.prototype, "tagViaGetter", { + get() { + return (this as any).tag; + }, +}); + +const tagSym = Symbol("tagSym"); +Object.defineProperty(Base.prototype, tagSym, { + get() { + return (this as any).tag; + }, +}); + +let setterLog = ""; +Object.defineProperty(Base.prototype, "tagViaAccessor", { + get() { + return (this as any).tag; + }, + set(v: string) { + setterLog = (this as any).tag + ":" + v; + }, +}); + +class Sub extends Base { + tag = "sub-tag"; +} + +class SubSub extends Sub { + tag = "subsub-tag"; +} + +// A field overridden with a different runtime type than its ancestor. +class SubTyped extends Base { + tag: any = 42; +} + +const base = new Base(); +console.log("base.tag", base.tag); +console.log("base.tagViaGetter", (base as any).tagViaGetter); +console.log("base[tagSym]", (base as any)[tagSym]); + +const sub = new Sub(); +// Direct-read control: the non-accessor path already saw the override +// correctly before this fix, and must keep doing so. +console.log("sub.tag", sub.tag); +console.log("sub.tagViaGetter", (sub as any).tagViaGetter); +console.log("sub[tagSym]", (sub as any)[tagSym]); +const key = "tag"; +console.log("sub[computedKey]", (sub as any)[key]); + +const subsub = new SubSub(); +console.log("subsub.tag", subsub.tag); +console.log("subsub.tagViaGetter", (subsub as any).tagViaGetter); +console.log("subsub[tagSym]", (subsub as any)[tagSym]); + +const typed = new SubTyped(); +console.log("typed.tag", typed.tag); +console.log("typed.tagViaGetter", (typed as any).tagViaGetter); +console.log("typed[tagSym]", (typed as any)[tagSym]); + +(sub as any).tagViaAccessor = "written"; +console.log("setterLog", setterLog); +console.log("sub.tag after setter", sub.tag); From ee75e236128a3ad8bc5ed32b72febe77adcfaa02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 09:52:06 +0000 Subject: [PATCH 04/30] changelog: fragment for #10607 --- .../10607-shadowed-field-most-derived-slot.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 changelog.d/10607-shadowed-field-most-derived-slot.md diff --git a/changelog.d/10607-shadowed-field-most-derived-slot.md b/changelog.d/10607-shadowed-field-most-derived-slot.md new file mode 100644 index 0000000000..c3fc2c6cbd --- /dev/null +++ b/changelog.d/10607-shadowed-field-most-derived-slot.md @@ -0,0 +1,23 @@ +### Fixed + +- **A field a subclass overrides could read as the base class's value from + every read that isn't a compile-time-typed `obj.field`.** `class Sub + extends Base { tag = "sub-tag" }` (with `Base { tag = "base-tag" }`) gave + `Sub` two inline field slots named `"tag"` — the packed class layout + (`crates/perry-codegen/src/codegen/mod.rs`) never deduplicates a name a + subclass re-declares — but only the most-derived one is ever written + (`class_field_global_index` already resolves a compile-time-typed + `obj.field` to that slot, "TS shadowing"). Every *dynamic* by-name lookup — + an inherited `Object.defineProperty` accessor's `this.field`, a computed + `obj[key]`, `Reflect.get`, `hasOwnProperty` — instead returned the + ancestor's never-written slot. Fixed in three runtime lookup sites + (`object/keys_lookup.rs`, `object/shapes.rs`, `object/field_get_set/ic_miss.rs`) + to agree with the compile-time path: `keys_find_slot_by_bytes` / + `keys_find_slot_by_key_ptr` and the IC-miss fast-path scan now walk + back-to-front, and the ≥32-key indexed lookup keeps the highest matching + slot index instead of the first. No storage-layout change; validated with + `test-files/test_gap_10595_inherited_accessor_field_shape.ts` (string and + Symbol keys, getter+setter, a two-level subclass, and a field overridden + with a different runtime type) plus new `perry-runtime` unit tests. Still + open: `Object.keys()`/`for...in` list a shadowed field's name twice, since + enumeration reads the same undeduplicated keys array — a follow-up. From 5ab5db7d2837cdb017ecc64f9cef0b3ba7b4e935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 08:15:20 +0000 Subject: [PATCH 05/30] fix(codegen): stop new(X) picking a builtin over a same-named imported function ctor lower_new_impl_inner called lower_builtin_new for any class_name absent from ctx.classes before checking import_function_prefixes. Imported classes already land in ctx.classes and skip the builtin block; an imported plain function constructor (Headers, EventEmitter-shaped, ...) never does, so any unconditional builtin arm (not gated by required_sources) fired regardless of whether the callee was a bare identifier or wrapped in (X as any) -- peel_new_callee strips that cast before lower_new branches on callee shape. --- crates/perry-codegen/src/lower_call/new.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 744750c441..ab8ed03fdf 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -276,7 +276,24 @@ fn lower_new_impl_inner<'a>( // These are checked BEFORE the ctx.classes lookup because the user // code may shadow the name — if they do, the class lookup below // wins. - if !ctx.classes.contains_key(class_name) { + // + // #10589: that shadowing check only covered CLASSES — imported classes + // ARE registered in `ctx.classes` for the importing module, so they + // already skip this block. A user-imported PLAIN FUNCTION constructor + // of the same name (`import { Headers } from "./lib.ts"`) never lands + // in `ctx.classes`, so any builtin arm not gated by `required_sources` + // (`Headers`, `EventEmitter`, …) fired unconditionally and constructed + // the BUILTIN instead of the user's function — for a bare identifier + // callee exactly as much as for one wrapped in `(X as any)`, since + // `peel_new_callee` strips that cast before `lower_new` ever branches + // on the callee shape. Route a genuine imported-function-constructor + // name past the whole builtin block the same way `ctx.classes` already + // does for classes; it falls through to the `import_function_prefixes` + // arm below `ctx.classes.get(class_name)`, which constructs the user's + // function correctly via `js_new_function_construct`. + let user_owns_construction = ctx.import_function_prefixes.contains_key(class_name) + && !ctx.import_function_v8_specifiers.contains_key(class_name); + if !ctx.classes.contains_key(class_name) && !user_owns_construction { if matches!(class_name, "Crypto" | "CryptoKey" | "SubtleCrypto") { for a in args { let _ = lower_expr(ctx, a)?; From e6770ae7059f590b790b82b35f96e24fb0abf101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 09:05:57 +0000 Subject: [PATCH 06/30] test(codegen): cover #10589's builtin-name shadowing across new()/new(X as any)() Gap test: Headers/EventEmitter/Stream x function/class declaration x named/default import x plain-new/cast-new, plus a local-alias control that already worked. Property-based discriminators, not instanceof -- #10477 (imported non-class instanceof) is not yet fixed on this base and would conflate the two bugs. Unit tests: direct Expr::New harness asserting js_new_function_construct fires (not the builtin arm) once a name resolves to an imported function constructor, the builtin still fires when unshadowed, a V8-fallback import of the same name still falls to the builtin, and an imported CLASS of the same name already shadowed the builtin before this fix (regression guard). --- crates/perry-codegen/src/lower_call/mod.rs | 2 + .../lower_call/new_builtin_shadow_tests.rs | 159 ++++++++++++++++++ .../d_emitter_class_default.ts | 26 +++ .../d_emitter_class_named.ts | 26 +++ .../d_emitter_fn_default.ts | 26 +++ .../d_emitter_fn_named.ts | 26 +++ .../d_headers_class_default.ts | 26 +++ .../d_headers_class_named.ts | 26 +++ .../d_headers_fn_default.ts | 26 +++ .../d_headers_fn_named.ts | 26 +++ .../d_stream_class_default.ts | 26 +++ .../d_stream_class_named.ts | 26 +++ .../d_stream_fn_default.ts | 26 +++ .../d_stream_fn_named.ts | 26 +++ .../emitter_class_lib.ts | 4 + .../emitter_fn_lib.ts | 4 + .../headers_class_lib.ts | 8 + .../headers_fn_lib.ts | 5 + .../stream_class_lib.ts | 4 + .../stream_fn_lib.ts | 4 + .../test_gap_10589_new_cast_builtin_shadow.ts | 54 ++++++ 21 files changed, 556 insertions(+) create mode 100644 crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/emitter_class_lib.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/emitter_fn_lib.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/headers_class_lib.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/headers_fn_lib.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/stream_class_lib.ts create mode 100644 test-files/_helpers/new_cast_builtin_shadow_10589/stream_fn_lib.ts create mode 100644 test-files/test_gap_10589_new_cast_builtin_shadow.ts diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 77cffef49f..28111be44f 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -85,6 +85,8 @@ mod native_module_rooting_tests; mod native_table; mod new; pub(crate) mod new_alloc; +#[cfg(test)] +mod new_builtin_shadow_tests; mod new_ctor_args; mod new_error_init; mod new_helpers; diff --git a/crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs b/crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs new file mode 100644 index 0000000000..98cbad3866 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs @@ -0,0 +1,159 @@ +//! #10589: `new X(...)` where `X` is a bare identifier whose NAME collides +//! with an unconditional (not `required_sources`-gated) builtin constructor +//! arm in `lower_builtin_new` must build the USER'S imported binding when one +//! exists, not the builtin. +//! +//! `lower_new_impl_inner` called `lower_builtin_new` for any `class_name` +//! absent from `ctx.classes` before ever checking `ctx.import_function_prefixes` +//! (where an imported PLAIN FUNCTION constructor is tracked — imported +//! CLASSES already land in `ctx.classes` and skip this block entirely, see +//! the `Headers`-as-class regression guard below, which passed before this +//! fix too). Since `"Headers"` has no `required_sources` gate, it fired +//! unconditionally. +//! +//! `new X()` and `new (X as any)()` are equivalent from `lower_new_impl_inner` +//! downward — HIR's `peel_new_callee` strips a `TsAs` cast before `lower_new` +//! ever branches on the callee's shape, so both forms lower to the identical +//! `Expr::New { class_name: "Headers", .. }`. There is deliberately no +//! separate "cast" test here for that reason; the two forms are provably one +//! code path once the AST reaches `Expr::New`. + +use crate::{compile_module, CompileOptions, ImportedClass}; +use perry_hir::{Expr, Module, Stmt}; + +fn new_headers_call() -> Module { + let mut module = Module::new("new_builtin_shadow.ts"); + module.init = vec![Stmt::Expr(Expr::New { + class_name: "Headers".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })]; + module +} + +fn compile(opts: CompileOptions) -> String { + let bytes = compile_module(&new_headers_call(), opts).expect("module compiles"); + String::from_utf8(bytes).expect("LLVM IR is UTF-8") +} + +#[test] +fn imported_function_constructor_shadows_the_builtin_arm() { + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.import_function_prefixes + .insert("Headers".to_string(), "lib_ts".to_string()); + let ir = compile(opts); + + assert!( + ir.contains("call double @js_new_function_construct("), + "an imported function constructor named `Headers` must construct \ + through the imported-function path:\n{ir}" + ); + assert!( + !ir.contains("call double @js_headers_new("), + "the builtin fetch Headers constructor must not fire once `Headers` \ + resolves to an imported function (#10589):\n{ir}" + ); +} + +#[test] +fn unshadowed_builtin_name_still_builds_the_builtin() { + // No `import_function_prefixes` entry for "Headers": nothing shadows the + // name, so the builtin fetch API constructor must still fire. Guards + // against an overly broad fix that stops builtin `new Headers()` from + // working when the program never imports anything of that name. + let ir = compile(CompileOptions { + emit_ir_only: true, + ..Default::default() + }); + + assert!( + ir.contains("call double @js_headers_new("), + "an unshadowed `Headers` must still build the builtin:\n{ir}" + ); + assert!( + !ir.contains("call double @js_new_function_construct("), + "nothing resolves this name to an imported function value:\n{ir}" + ); +} + +#[test] +fn v8_fallback_import_of_the_same_name_still_builds_the_builtin() { + // A V8-fallback specifier for "Headers" (present in + // `import_function_prefixes` but ALSO in `import_function_v8_specifiers`) + // is not a compiled-source binding this fix can construct via + // `js_new_function_construct` — it must keep falling through to the + // builtin, same as the codegen's existing `import_function_v8_specifiers` + // exclusion at the later `import_function_prefixes` arm. + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.import_function_prefixes + .insert("Headers".to_string(), "lib_ts".to_string()); + opts.import_function_v8_specifiers + .insert("Headers".to_string(), "./lib.ts".to_string()); + let ir = compile(opts); + + assert!( + ir.contains("call double @js_headers_new("), + "a V8-fallback import must still build the builtin:\n{ir}" + ); +} + +#[test] +fn imported_class_of_the_same_name_already_shadowed_the_builtin() { + // Regression guard for the OTHER half of the shadowing story, unchanged + // by this fix: an imported CLASS named "Headers" lands in `ctx.classes` + // and always skipped the builtin block, function-constructor collisions + // aside. + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.imported_classes.push(ImportedClass { + name: "Headers".to_string(), + local_alias: None, + namespace: None, + source_prefix: "lib_ts".to_string(), + constructor_param_count: 0, + has_own_constructor: false, + constructor_has_rest: false, + has_instance_fields: false, + method_names: Vec::new(), + proven_this_method_names: Vec::new(), + proven_this_tower_method_names: Vec::new(), + method_return_types: Vec::new(), + method_param_counts: Vec::new(), + method_has_rest: Vec::new(), + method_has_synthetic_arguments: Vec::new(), + method_arguments_length_only: Vec::new(), + static_field_names: Vec::new(), + static_method_names: Vec::new(), + static_method_return_types: Vec::new(), + static_method_param_counts: Vec::new(), + static_method_has_rest: Vec::new(), + static_method_has_user_rest: Vec::new(), + static_method_has_synthetic_arguments: Vec::new(), + getter_names: Vec::new(), + getter_return_types: Vec::new(), + setter_names: Vec::new(), + parent_name: None, + field_names: Vec::new(), + field_types: Vec::new(), + source_class_id: Some(9101), + return_shape_imports: Vec::new(), + object_literal: None, + }); + let ir = compile(opts); + + assert!( + !ir.contains("call double @js_headers_new("), + "an imported class must already shadow the builtin, before and \ + after #10589's fix:\n{ir}" + ); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts new file mode 100644 index 0000000000..89569c6e33 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts @@ -0,0 +1,26 @@ +import EventEmitter from "./emitter_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new EventEmitter())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (EventEmitter as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = EventEmitter; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts new file mode 100644 index 0000000000..34eefcea6f --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts @@ -0,0 +1,26 @@ +import { EventEmitter } from "./emitter_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new EventEmitter())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (EventEmitter as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = EventEmitter; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts new file mode 100644 index 0000000000..23919779ee --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts @@ -0,0 +1,26 @@ +import EventEmitter from "./emitter_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new EventEmitter())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (EventEmitter as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = EventEmitter; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts new file mode 100644 index 0000000000..8d23686566 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts @@ -0,0 +1,26 @@ +import { EventEmitter } from "./emitter_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new EventEmitter())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (EventEmitter as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = EventEmitter; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts new file mode 100644 index 0000000000..b3444dc1f8 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts @@ -0,0 +1,26 @@ +import Headers from "./headers_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Headers(1))); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Headers as any)(1))); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Headers; + try { + results.push("alias=" + isUser(new alias(1))); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts new file mode 100644 index 0000000000..5166ce2a45 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts @@ -0,0 +1,26 @@ +import { Headers } from "./headers_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Headers(1))); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Headers as any)(1))); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Headers; + try { + results.push("alias=" + isUser(new alias(1))); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts new file mode 100644 index 0000000000..dc8e83591c --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts @@ -0,0 +1,26 @@ +import Headers from "./headers_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Headers(1))); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Headers as any)(1))); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Headers; + try { + results.push("alias=" + isUser(new alias(1))); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts new file mode 100644 index 0000000000..185a73681a --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts @@ -0,0 +1,26 @@ +import { Headers } from "./headers_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Headers(1))); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Headers as any)(1))); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Headers; + try { + results.push("alias=" + isUser(new alias(1))); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts new file mode 100644 index 0000000000..13ae582025 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts @@ -0,0 +1,26 @@ +import Stream from "./stream_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Stream())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Stream as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Stream; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts new file mode 100644 index 0000000000..2548c21c1c --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts @@ -0,0 +1,26 @@ +import { Stream } from "./stream_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Stream())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Stream as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Stream; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts new file mode 100644 index 0000000000..16f9b972bf --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts @@ -0,0 +1,26 @@ +import Stream from "./stream_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Stream())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Stream as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Stream; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts new file mode 100644 index 0000000000..271e11039f --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts @@ -0,0 +1,26 @@ +import { Stream } from "./stream_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Stream())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Stream as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Stream; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_class_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_class_lib.ts new file mode 100644 index 0000000000..237e123f88 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_class_lib.ts @@ -0,0 +1,4 @@ +export class EventEmitter { + __mark = "user"; +} +export default EventEmitter; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_fn_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_fn_lib.ts new file mode 100644 index 0000000000..cd9d1faa1f --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_fn_lib.ts @@ -0,0 +1,4 @@ +export function EventEmitter(this: any) { + this.__mark = "user"; +} +export default EventEmitter; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/headers_class_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/headers_class_lib.ts new file mode 100644 index 0000000000..00bc0fe762 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/headers_class_lib.ts @@ -0,0 +1,8 @@ +export class Headers { + v: number; + __mark = "user"; + constructor(v: number) { + this.v = v; + } +} +export default Headers; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/headers_fn_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/headers_fn_lib.ts new file mode 100644 index 0000000000..c556d475c0 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/headers_fn_lib.ts @@ -0,0 +1,5 @@ +export function Headers(this: any, v: number) { + this.v = v; + this.__mark = "user"; +} +export default Headers; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/stream_class_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/stream_class_lib.ts new file mode 100644 index 0000000000..008a388d9a --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/stream_class_lib.ts @@ -0,0 +1,4 @@ +export class Stream { + __mark = "user"; +} +export default Stream; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/stream_fn_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/stream_fn_lib.ts new file mode 100644 index 0000000000..8135a29e9c --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/stream_fn_lib.ts @@ -0,0 +1,4 @@ +export function Stream(this: any) { + this.__mark = "user"; +} +export default Stream; diff --git a/test-files/test_gap_10589_new_cast_builtin_shadow.ts b/test-files/test_gap_10589_new_cast_builtin_shadow.ts new file mode 100644 index 0000000000..76bd2dc891 --- /dev/null +++ b/test-files/test_gap_10589_new_cast_builtin_shadow.ts @@ -0,0 +1,54 @@ +// #10589: `new X()` / `new (X as any)()` on an imported binding whose NAME +// collides with a Perry builtin constructor (Headers, EventEmitter, ...) +// constructed the BUILTIN instead of the user's own function/class of the +// same name. `peel_new_callee` strips a `(X as any)` cast before `new`'s +// lowering ever branches on the callee's shape, so the cast form and the +// bare-identifier form take the identical codegen path — the cast in the +// issue's title is not itself the trigger. The real gap: `lower_new_impl_inner` +// (crates/perry-codegen/src/lower_call/new.rs) called into the unconditional +// builtin-constructor table for any `class_name` absent from `ctx.classes` +// BEFORE checking whether that name is a user-imported PLAIN FUNCTION +// constructor (`ctx.import_function_prefixes`). Imported CLASSES already land +// in `ctx.classes` and always skipped the builtin table (see the `EventEmitter +// class` / `Stream class` controls below, which passed even before the fix) — +// only the function-declaration form was exposed. +// +// Each driver module below imports its constructor under the exact reserved +// name (`Headers`/`EventEmitter`/`Stream`) in its OWN module scope — a single +// module can only bind one top-level identifier per name, so each +// function-decl/class-decl x named/default-import combination needs its own +// tiny module. Every driver probes three shapes: `new X(...)` (plain), +// `new (X as any)(...)` (cast) and `new alias(...)` for a local variable +// holding the same value (the control that already worked — a regression +// there must be caught same as the other two). +// +// Note: this deliberately avoids `instanceof` as a discriminator (`#10477`, +// imported non-class constructors folding `x instanceof F` to `false`, is a +// separate bug not yet fixed on this base) — each library constructor stamps +// a `__mark: "user"` own-property instead, which a real Perry builtin +// Headers/EventEmitter never has. +import { run as headersFnNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts"; +import { run as headersFnDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts"; +import { run as headersClassNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts"; +import { run as headersClassDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts"; +import { run as emitterFnNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts"; +import { run as emitterFnDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts"; +import { run as emitterClassNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts"; +import { run as emitterClassDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts"; +import { run as streamFnNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts"; +import { run as streamFnDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts"; +import { run as streamClassNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts"; +import { run as streamClassDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts"; + +console.log("headers fn named:", headersFnNamed()); +console.log("headers fn default:", headersFnDefault()); +console.log("headers class named:", headersClassNamed()); +console.log("headers class default:", headersClassDefault()); +console.log("emitter fn named:", emitterFnNamed()); +console.log("emitter fn default:", emitterFnDefault()); +console.log("emitter class named:", emitterClassNamed()); +console.log("emitter class default:", emitterClassDefault()); +console.log("stream fn named:", streamFnNamed()); +console.log("stream fn default:", streamFnDefault()); +console.log("stream class named:", streamClassNamed()); +console.log("stream class default:", streamClassDefault()); From 0ee44217b7a16c79019d5326719ddfb441387f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 10:00:15 +0000 Subject: [PATCH 07/30] docs(changelog): add fragment for #10608 --- changelog.d/10608-new-cast-builtin-shadow.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10608-new-cast-builtin-shadow.md diff --git a/changelog.d/10608-new-cast-builtin-shadow.md b/changelog.d/10608-new-cast-builtin-shadow.md new file mode 100644 index 0000000000..23fd8efc8c --- /dev/null +++ b/changelog.d/10608-new-cast-builtin-shadow.md @@ -0,0 +1,3 @@ +### Fixed + +- **`new X()` on an imported plain-function constructor whose NAME collides with a builtin (`Headers`, and any other unconditional `lower_builtin_new` arm) constructed the builtin instead of the user's own function.** `lower_new_impl_inner` (`crates/perry-codegen/src/lower_call/new.rs`) called into the unconditional builtin-constructor table for any `class_name` absent from `ctx.classes` *before* checking `ctx.import_function_prefixes` (where an imported plain-function constructor is tracked). Imported CLASSES already land in `ctx.classes` and always skipped the builtin table — only the function-declaration form was exposed. `new X()` and `new (X as any)()` are equivalent from this function downward: HIR's `peel_new_callee` strips a `TsAs` cast before `lower_new` ever branches on the callee's shape, so both forms shared the bug identically; the cast in the original report is not itself the trigger — a local-variable alias (which takes a completely different, value-based codegen path) is what actually distinguished working from broken. Fixed by adding a `user_owns_construction` guard (mirrors the existing `required_sources` provenance gate already used for `Client`/`Pool`/`Database`/etc.) to the same condition that lets classes skip the builtin block. Verified the fix is a no-op for every already-correct `new` call via byte-identical emitted LLVM IR and a `perf stat` instruction-count A/B (both within noise). From 5d32d9437e17778b41745df09bea06514b09a686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 10:34:51 +0000 Subject: [PATCH 08/30] perf(gc): gate the residual prototype registry on an owner header bit (#10362) The registry holding an explicit [[Prototype]] for a non-meta-capable owner was gated only by OBJECT_PROTOTYPES_NONEMPTY, a process-global latch. One re-prototyped object anywhere armed it for the rest of the run, after which every traced owner-capable cell paid a lock plus a SipHash probe to ask a question that is false for almost all of them. A latch is a cliff: it turns the fast path off for every cell at once, invisibly to any benchmark that does not contain the trigger. Bit 6 of _reserved is OBJ_FLAG_NULL_PROTO, which has exactly one setter (returning *mut ObjectHeader) and seven readers, every one provably unreachable with a non-GC_TYPE_OBJECT cell: three by an explicit obj_type check, three by a converter that returns None first, one by a preceding conjunct in the same && chain. The registry excludes GC_TYPE_OBJECT by construction, so the bit is free across the registry's whole population, not only for arrays -- which is why both existing witnesses exercise it, one of them a lazy array that an array-scoped bit would have missed. GC_RESIDUAL_PROTO_OWNER is set at the single funnel, under the registry lock and before the insert: the proof is published before the fact it guards. It is never cleared, and that is sound. Entries outlive owners only when the owner is dead; the prune touches only dead owners; both rekey paths keep the entry while _reserved rides the move (#10381's contract, enforced by assert_relocation_copied_the_header). One writer under one lock writes both, so the dangerous direction -- entry present, bit absent -- has no producer. A GC_TYPE_OBJECT owner that reaches the registry anyway keeps the latch-only gate, since bit 6 means something else there. The latch stays as the first test -- one byte load, false for any process that never re-prototyped a non-object -- and the bit is the second, which is what stops an ARMED process paying per traced cell. Sabotage: with the setter made a process-wide no-op, both existing witnesses in gc/tests/residual_prototype_relocation.rs fail at their real verdicts, the registry entry no longer following the lazy header nor the array owner. The bit is load-bearing, not decorative. Measured on main 9df5075fb, exact instruction counts: the fixture that arms the latch -0.408%, and three that do not are flat (+0.015%, -0.060%, -0.021%). Attributed: -94.3M RandomState::hash_one, -58.2M SipHash write, -36.9M run_copied_minor_attempt, -30.0M transfer_residual_prototype. pointer_slots_read is identical between arms: the collector does bit-identical work. --- .../perry-runtime/src/gc/layout/transfer.rs | 9 +- .../perry-runtime/src/gc/layout_slot_visit.rs | 6 + .../gc/tests/residual_prototype_relocation.rs | 74 +++++++++ crates/perry-runtime/src/gc/types.rs | 38 +++++ .../src/object/prototype_chain.rs | 146 +++++++++++++++++- 5 files changed, 268 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/gc/layout/transfer.rs b/crates/perry-runtime/src/gc/layout/transfer.rs index e8eb7bbd98..c4fc849f45 100644 --- a/crates/perry-runtime/src/gc/layout/transfer.rs +++ b/crates/perry-runtime/src/gc/layout/transfer.rs @@ -95,10 +95,17 @@ pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { // layout kinds (module docs). The latch first: it is one byte load, false // for any process that never re-prototyped a non-object, and the move is // out of line. + let relocating_header = header_from_user_ptr(old_user as *const u8); if crate::object::prototype_chain::object_static_prototypes_maybe_nonempty() && crate::object::prototype_chain::residual_prototype_owner_type( - (*header_from_user_ptr(old_user as *const u8)).obj_type, + (*relocating_header).obj_type, ) + // #10362: the per-OWNER half, same bit and same proof as the trace + // path's. `_reserved` rides every relocation by construction — all four + // callers copy it before calling here and + // `assert_relocation_copied_the_header` enforces that — so the bit is + // already correct at this point and needs no transfer of its own. + && crate::object::prototype_chain::residual_entry_possible_for(relocating_header) { transfer_residual_prototype(old_user as usize, new_user as usize); } diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index b14865203b..85ddbe7316 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -249,6 +249,12 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( // arms, so no arm's early return can skip it. if crate::object::prototype_chain::object_static_prototypes_maybe_nonempty() && crate::object::prototype_chain::residual_prototype_owner_type(obj_type) + // #10362: the per-OWNER half. The latch above is exact for a process + // that never re-prototyped a non-object and useless for one that has — + // it is what made a single `Object.setPrototypeOf(anArray, p)` charge + // every traced cell of every owner-capable kind a global mutex and a + // SipHash probe. This asks the owner's own header instead. + && crate::object::prototype_chain::residual_entry_possible_for(header) { crate::object::prototype_chain::visit_object_static_prototype_slot_mut( user_ptr as usize, diff --git a/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs index 2340ea7f0a..12cb6ce779 100644 --- a/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs +++ b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs @@ -175,6 +175,80 @@ fn test_lazy_array_explicit_prototype_survives_a_copying_minor() { ); } +/// #10362 SABOTAGE: with `GC_RESIDUAL_PROTO_OWNER` never set, the per-owner +/// gates answer "no entry here" and the collector skips the prototype edge. +/// +/// This is the test that proves the bit is load-bearing rather than +/// documentation. Its subject is the same one the two tests around it assert on +/// — the entry must follow the owner and the recorded address must be rewritten +/// — so a green run here with the bit suppressed would mean the gates it feeds +/// are not gating anything, and A' should be withdrawn. +/// +/// The membership assertion (`debug_assert_residual_owner_bit`) stands itself +/// down while the sabotage is armed, so this test fails at the real verdict +/// rather than at an assertion the sabotage itself provoked. +#[test] +fn test_suppressing_the_residual_owner_bit_loses_the_prototype() { + let _serialized = crate::array::test_serialize(); + let _feedback = crate::typed_feedback::typed_feedback_test_lock(); + let _latch = ArrayPrototypeLatchRestore::capture(); + let _guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let owner = crate::array::js_array_alloc(4) as usize; + let obj_type = obj_type_at(owner); + assert!( + crate::arena::pointer_in_nursery(owner) && crate::gc::gc_type_is_movable(obj_type), + "premise: a nursery owner of a movable kind" + ); + assert_ne!( + obj_type, GC_TYPE_OBJECT, + "premise: the subject must be a kind the bit actually gates" + ); + js_shadow_slot_set(0, ptr_bits(owner)); + + let sabotage = crate::object::prototype_chain::residual_proto_bit_sabotage::Guard::arm(); + let proto = marked_prototype(); + crate::object::js_object_set_prototype_of( + f64::from_bits(ptr_bits(owner)), + f64::from_bits(ptr_bits(proto)), + ); + let owner = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_eq!( + crate::object::prototype_chain::object_static_prototype(owner), + Some(ptr_bits(proto)), + "premise: the entry is recorded even with the bit suppressed — the \ + sabotage removes the PROOF, not the entry" + ); + unsafe { + let header = + (owner as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + assert_eq!( + (*header)._reserved & crate::gc::GC_RESIDUAL_PROTO_OWNER, + 0, + "premise: the sabotage really did suppress the bit" + ); + } + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let owner_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!(owner_after, owner, "premise: the owner must actually move"); + + let recorded = crate::object::prototype_chain::object_static_prototype(owner_after); + forget_owners(&[owner, owner_after]); + js_shadow_slot_set(0, 0); + drop(sabotage); + + assert!( + recorded.is_none(), + "the bit is not load-bearing: with `GC_RESIDUAL_PROTO_OWNER` suppressed the \ + entry still followed its owner, so the per-owner gates in \ + `gc/layout_slot_visit.rs` and `gc/layout/transfer.rs` are not gating \ + anything. A' is documentation — withdraw it." + ); +} + /// Every movable receiver kind that keeps its prototype in the residual /// registry, with the prototype held by NOTHING but that entry. One copying /// minor has to rekey the entry, retain the prototype through it, and rewrite diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 835cb92074..2bbe5eaa2e 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -1294,6 +1294,44 @@ pub const OBJ_FLAG_TYPED_ARRAY_PROTO: u16 = 0x100; /// `JSValue` slots. This is only meaningful for `GC_TYPE_ARRAY`; object /// flags share the same `_reserved` word but never inspect this bit. pub(crate) const GC_ARRAY_RAW_F64_LAYOUT: u16 = 0x80; +/// #10362: this cell owns an entry in the residual static-prototype registry +/// (`object::prototype_chain`) — i.e. an `Object.setPrototypeOf` whose receiver +/// `meta_capable_object` turned away, so the prototype could not go in a meta +/// record and went into the address-keyed table instead. +/// +/// The registry's readers used to ask only the process-global +/// `OBJECT_PROTOTYPES_NONEMPTY` latch, which is exact for a process that has +/// never re-prototyped a non-object and useless for one that has: a single +/// `Object.setPrototypeOf(anArray, p)` made EVERY traced cell of every +/// owner-capable kind take the registry's global mutex and a SipHash probe, and +/// every relocation of one call the rekey hook. Measured on a 200k-array +/// fixture: 400,000 armed hook calls from three lines of setup, against 155 +/// unarmed. This bit answers the same question per OWNER, so an armed process +/// pays only for the cells that actually have an entry. +/// +/// Bit 6, shared with `OBJ_FLAG_NULL_PROTO` exactly as bits 7..12 are already +/// shared between the OBJECT and ARRAY namespaces, and disjoint from it by +/// `obj_type`: `OBJ_FLAG_NULL_PROTO` has one setter, +/// `js_object_alloc_null_proto`, which returns `*mut ObjectHeader`, and every +/// reader of it is behind an `obj_type == GC_TYPE_OBJECT` guard (audited: +/// `field_get_set/accessors.rs`, `field_get_set/for_in_stable.rs`, +/// `field_set_by_name.rs`, `field_set_by_name/tail.rs` via `object_is_regular`, +/// `builtins/formatting.rs` via both callers' `gc_type` dispatch, +/// `builtins/formatting/prototype_equality.rs` via `heap_object_addr`, +/// `native_call_method/object_proto.rs` via `object_ptr_from_value`). +/// So this bit is **only meaningful for `obj_type != GC_TYPE_OBJECT`**, and a +/// `GC_TYPE_OBJECT` owner that reaches the registry anyway keeps the +/// latch-only gate (see `residual_entry_possible_for`). +/// +/// Set-only, like `GC_ARRAY_NAMED_PROPS` and for the same reason: an entry is +/// never deleted while its owner lives (a second `setPrototypeOf` overwrites +/// the same key; the prune only removes DEAD owners, whose header is gone; the +/// two rekey paths remove-then-insert and the entry survives). So the error is +/// on the safe side by construction — a stale-set bit costs one probe that the +/// map answers `None` to, while the dangerous direction (entry present, bit +/// absent) has no code path that can produce it, because the only writer of the +/// entry is also the only writer of the bit, under one lock. +pub(crate) const GC_RESIDUAL_PROTO_OWNER: u16 = 0x40; /// Array was synthesized for a function's `arguments` binding. This is only /// meaningful for `GC_TYPE_ARRAY`; it lets `util.types.isArgumentsObject` /// distinguish Perry's internal `arguments` arrays from user rest arrays. diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 3376c3a07b..ff8f7a20eb 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -160,6 +160,130 @@ pub(crate) fn any_user_prototype_override() -> bool { USER_PROTO_OVERRIDE_EVER.load(Ordering::Acquire) } +/// #10362: mark `obj_ptr`'s own header as an owner in the residual registry. +/// +/// Called under the registry lock and BEFORE the insert, the same discipline +/// `OBJECT_PROTOTYPES_NONEMPTY` uses one line below: the proof is published +/// before the fact it guards, so a reader that can observe the entry already +/// observes the bit. +/// +/// Silently does nothing for a `GC_TYPE_OBJECT` owner. Such an owner normally +/// never reaches the registry at all (`meta_capable_object` takes it), but it +/// can when that function turns it away for a non-type reason — and bit 6 means +/// `OBJ_FLAG_NULL_PROTO` there, so it must not be reused. Those owners keep the +/// latch-only gate, which is what every owner had before this change. +unsafe fn set_residual_proto_owner_bit(obj_ptr: usize) { + #[cfg(test)] + if residual_proto_bit_sabotage::suppressed() { + return; + } + let Some(header) = crate::value::addr_class::try_read_gc_header(obj_ptr) else { + return; + }; + if header.obj_type == crate::gc::GC_TYPE_OBJECT { + return; + } + let header = (obj_ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + (*header)._reserved |= crate::gc::GC_RESIDUAL_PROTO_OWNER; +} + +/// Can the cell at `header` own a residual-prototype entry, judged from its own +/// header rather than from the process-global latch? +/// +/// This is the per-owner half of the registry's gate. Callers keep asking +/// [`object_static_prototypes_maybe_nonempty`] FIRST — it is one byte load and +/// false for any process that never re-prototyped a non-object — and ask this +/// second, which is what stops an ARMED process paying per traced cell. +/// +/// Conservative for `GC_TYPE_OBJECT`: see `set_residual_proto_owner_bit`. +/// +/// # Safety +/// +/// `header` is a readable `GcHeader` of a live allocation. +#[inline] +pub(crate) unsafe fn residual_entry_possible_for(header: *const crate::gc::GcHeader) -> bool { + if (*header).obj_type == crate::gc::GC_TYPE_OBJECT { + return true; + } + (*header)._reserved & crate::gc::GC_RESIDUAL_PROTO_OWNER != 0 +} + +/// The invariant the collector's gates rest on: a LIVE non-object owner that +/// has an entry in the registry carries the bit. +/// +/// Asserted in test and debug builds — including `cargo test --release`, how the +/// GC suites run — for the same reason +/// `gc::layout::transfer::assert_relocation_copied_the_header` is: a test proves +/// today's code, an assertion proves tomorrow's. A future path that inserts an +/// entry without the bit would make every collector gate skip that owner's +/// prototype edge, which is #10493's bug exactly — correct before a collection, +/// wrong after, exit code 0 and no warning. +/// +/// Only this direction is an invariant. The reverse (bit set implies an entry) +/// is deliberately NOT asserted: the bit is set-only, and the two rekey paths +/// remove-then-insert with the lock released in between, so a bit without an +/// entry is a legal transient and a benign steady state. +#[inline] +pub(crate) unsafe fn debug_assert_residual_owner_bit(obj_ptr: usize) { + #[cfg(any(test, debug_assertions))] + { + #[cfg(test)] + if residual_proto_bit_sabotage::suppressed() { + return; + } + if let Some(header) = crate::value::addr_class::try_read_gc_header(obj_ptr) { + if header.obj_type != crate::gc::GC_TYPE_OBJECT { + assert!( + header._reserved & crate::gc::GC_RESIDUAL_PROTO_OWNER != 0, + "residual prototype registry: owner {obj_ptr:#x} (obj_type {}) has an \ + entry but not `GC_RESIDUAL_PROTO_OWNER`, so every collector gate will \ + skip its prototype edge — the prototype is neither retained nor \ + rewritten (#10493's failure mode)", + header.obj_type + ); + } + } + } + #[cfg(not(any(test, debug_assertions)))] + { + let _ = obj_ptr; + } +} + +/// Test-only sabotage for [`set_residual_proto_owner_bit`]: the bit is never +/// set, so every per-owner gate falls back to "no entry here" and the collector +/// skips the prototype edge. +/// +/// Both tests in `gc/tests/residual_prototype_relocation.rs` MUST fail while +/// this is armed. If they pass, the bit is not load-bearing and is +/// documentation — the failure mode CLAUDE.md calls "a gate that cannot fail". +#[cfg(test)] +pub(crate) mod residual_proto_bit_sabotage { + use std::cell::Cell; + + thread_local! { + static SUPPRESSED: Cell = const { Cell::new(false) }; + } + + pub(crate) fn suppressed() -> bool { + SUPPRESSED.with(Cell::get) + } + + pub(crate) struct Guard(bool); + + impl Guard { + pub(crate) fn arm() -> Self { + Self(SUPPRESSED.with(|s| s.replace(true))) + } + } + + impl Drop for Guard { + fn drop(&mut self) { + SUPPRESSED.with(|s| s.set(self.0)); + } + } +} + fn get_object_prototypes() -> &'static Mutex> { OBJECT_PROTOTYPES.get_or_init(|| Mutex::new(HashMap::new())) } @@ -325,6 +449,9 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: // The publish property is unchanged: a reader that sees `true` takes // the lock and therefore sees whatever the writer committed. OBJECT_PROTOTYPES_NONEMPTY.store(true, Ordering::Release); + // #10362: the per-OWNER half of the same proof, published under the + // same lock and before the same insert, for the same reason. + unsafe { set_residual_proto_owner_bit(obj_ptr) }; let slot = map.entry(obj_ptr).or_insert(0); *slot = proto_bits; slot_addr = slot as *mut u64 as usize; @@ -360,10 +487,17 @@ pub fn object_static_prototype(obj_ptr: usize) -> Option { if !OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) { return None; } - get_object_prototypes() + let recorded = get_object_prototypes() .lock() .ok() - .and_then(|map| map.get(&obj_ptr).copied()) + .and_then(|map| map.get(&obj_ptr).copied()); + // #10362: the invariant every collector gate rests on, checked on the read + // paths that are NOT gated by the bit — asserting it inside a bit-gated + // path would be vacuous. + if recorded.is_some() { + unsafe { debug_assert_residual_owner_bit(obj_ptr) }; + } + recorded } /// Look up the residual prototype registry for a caller that has already @@ -383,10 +517,14 @@ pub(crate) fn object_static_prototype_known_non_meta(obj_ptr: usize) -> Option Date: Fri, 18 Sep 2026 12:36:03 +0200 Subject: [PATCH 09/30] changelog: fragment for #10611 --- changelog.d/10611-residual-proto-owner-bit.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/10611-residual-proto-owner-bit.md diff --git a/changelog.d/10611-residual-proto-owner-bit.md b/changelog.d/10611-residual-proto-owner-bit.md new file mode 100644 index 0000000000..289bf29f23 --- /dev/null +++ b/changelog.d/10611-residual-proto-owner-bit.md @@ -0,0 +1,9 @@ +### Performance + +- **The residual prototype registry is gated on an owner header bit instead of a process-global latch (#10362).** An explicit `[[Prototype]]` on a non-meta-capable owner lives in an address-keyed registry (#9304) whose relocation obligations were gated only by `OBJECT_PROTOTYPES_NONEMPTY`. One re-prototyped object anywhere armed that latch for the rest of the run, and every traced owner-capable cell then paid a lock plus a SipHash probe to ask a question false for almost all of them — a cliff, not a slope, invisible to any benchmark without the trigger. Measured: routine code leaves it alone (`class X extends Error`, live Array/Map/Set/Error subclass instances: 372–405 relocation-hook calls), while one `Object.setPrototypeOf` on an array takes it to 400,000. + + `GC_RESIDUAL_PROTO_OWNER` reuses bit 6 of `_reserved`, which is `OBJ_FLAG_NULL_PROTO` for `GC_TYPE_OBJECT` and free for every other kind — audited to all seven readers, each provably unreachable with a non-object cell (three by an explicit `obj_type` check, three by a converter returning `None` first, one by a preceding conjunct). Since the registry excludes `GC_TYPE_OBJECT` by construction the bit covers the registry's whole population, which is why both existing witnesses exercise it — one of them a lazy array an array-scoped bit would have missed. Set-only at the single funnel, under the registry lock and before the insert, so the proof is published before the fact it guards; never cleared, because the dangerous direction (entry present, bit absent) has no producer when one writer under one lock writes both. The latch stays as a one-load first test for processes that never re-prototype anything. + + Sabotage: with the setter made a no-op, both existing relocation witnesses fail at their real verdicts. The invariant is additionally asserted under `cfg(any(test, debug_assertions))`, which does run under `cargo test --release`. + + Measured exactly: the fixture that arms the latch −0.408%, three that do not are flat (+0.015%, −0.060%, −0.021%), attributed to −94.3M `RandomState::hash_one`, −58.2M SipHash `write`, −36.9M `run_copied_minor_attempt` and −30.0M `transfer_residual_prototype`. `pointer_slots_read` is identical between arms: the collector does bit-identical work. From b76aecbce486528dc49b8178d26ca1c3673cce2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 08:17:39 +0000 Subject: [PATCH 10/30] fix(hir,codegen,runtime): make `arguments` in class constructors reflect the call site HIR stops padding a new-site's argument list to the declared arity for a constructor that reads `arguments` (`monomorph/defaults.rs`) -- an appended `undefined` was indistinguishable from one the caller wrote. Runtime: the four dynamic-construct paths (super-apply caps arm, flat-ctor replay, and both class-object/registered-class replay paths) now share `constructor_user_arg_slots`, which packs the synthesized `arguments` slot from every call arg instead of binding it like a user `...rest` (only the args past the declared count) -- construction through a value, an imported class, or a CommonJS class all saw an empty `arguments` before this. Codegen: constructor ABI (`CtorAbi`: param count, has-rest, has-synthetic- arguments) is read from the constructor's fixed/rest/arguments layout instead of inspecting only its last declared parameter, which missed every capturing constructor -- i.e. every CommonJS class, since Perry adds capture params mechanically. The ABI threads through constructor-contract resolution (so a no-own-ctor forwarder inherits its ancestor's full ABI), imported-class metadata, and cross-module `new`-site arg marshaling, which can now pack up to two trailing arrays (a user rest, then `arguments`) instead of assuming at most one. --- .../src/codegen/constructor_contracts.rs | 58 ++-- .../perry-codegen/src/codegen/ctor_arity.rs | 262 +++++++++++++- crates/perry-codegen/src/codegen/mod.rs | 5 +- crates/perry-codegen/src/codegen/opts.rs | 24 +- .../perry-codegen/src/codegen/string_pool.rs | 30 +- .../src/expr/readonly_collection_tests.rs | 1 + crates/perry-codegen/src/lib.rs | 9 +- .../src/lower_call/new_ctor_args.rs | 29 +- .../src/lower_call/typed_shape_bake_tests.rs | 1 + crates/perry-hir/src/monomorph/defaults.rs | 10 + crates/perry-hir/src/monomorph/tests.rs | 115 +++++++ .../src/object/class_constructors.rs | 321 +++++++++++------- .../src/commands/compile/object_cache.rs | 8 + .../object_cache/object_cache_tests.rs | 4 + .../src/commands/compile/run_pipeline.rs | 12 +- .../issue_10484_ctor_arguments/classes.ts | 55 +++ .../issue_10484_ctor_arguments/request.cjs | 46 +++ ...t_gap_10484_class_constructor_arguments.ts | 291 ++++++++++++++++ 18 files changed, 1091 insertions(+), 190 deletions(-) create mode 100644 test-files/fixtures/issue_10484_ctor_arguments/classes.ts create mode 100644 test-files/fixtures/issue_10484_ctor_arguments/request.cjs create mode 100644 test-files/test_gap_10484_class_constructor_arguments.ts diff --git a/crates/perry-codegen/src/codegen/constructor_contracts.rs b/crates/perry-codegen/src/codegen/constructor_contracts.rs index 040df8de36..e8165f46ad 100644 --- a/crates/perry-codegen/src/codegen/constructor_contracts.rs +++ b/crates/perry-codegen/src/codegen/constructor_contracts.rs @@ -4,14 +4,14 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; -use super::ctor_arity::{context_free_ctor_param_count, UNRESOLVED_PARENT_FWD_ARITY}; +use super::ctor_arity::{context_free_ctor_abi, CtorAbi, UNRESOLVED_PARENT_FWD_ARITY}; use super::opts::{CompileOptions, ImportedClass}; type Symbol = (String, String); enum Contract { - Params(usize), - Parent(Symbol, usize), + Params(CtorAbi), + Parent(Symbol, CtorAbi), } /// A compact graph of constructor edges in each defining module's scope. @@ -23,7 +23,7 @@ pub struct ConstructorContracts { /// The only graph-wide constructor data needed during parallel codegen. pub struct ResolvedConstructorContracts { - arities: BTreeMap, + abis: BTreeMap, } impl ConstructorContracts { @@ -51,26 +51,30 @@ impl ConstructorContracts { imports.entry(imported.effective_name()).or_insert(imported); } for class in &module.classes { - let contract = if let Some(count) = context_free_ctor_param_count(class) { - Contract::Params(count) + let contract = if let Some(abi) = context_free_ctor_abi(class) { + Contract::Params(abi) } else { let mut parent = class.extends_name.as_deref(); let mut visited = BTreeSet::new(); - let mut contract = Contract::Params(UNRESOLVED_PARENT_FWD_ARITY); + let mut contract = + Contract::Params(CtorAbi::positional(UNRESOLVED_PARENT_FWD_ARITY)); while let Some(name) = parent { if !visited.insert(name) { break; } if let Some(local) = locals.get(name) { if let Some(ctor) = &local.constructor { - contract = Contract::Params(ctor.params.len()); + // The forwarder hands every slot to this ctor's + // symbol untouched, so it inherits its ABI — packed + // trailing arrays included (#10484). + contract = Contract::Params(CtorAbi::from_params(&ctor.params)); break; } parent = local.extends_name.as_deref(); } else if let Some(imported) = imports.get(name) { contract = Contract::Parent( (imported.source_prefix.clone(), imported.name.clone()), - imported.constructor_param_count, + imported.ctor_abi(), ); break; } else { @@ -90,26 +94,26 @@ impl ConstructorContracts { for symbol in self.contracts.keys() { resolve(symbol, &self.contracts, &mut resolved, &mut BTreeSet::new()); } - ResolvedConstructorContracts { arities: resolved } + ResolvedConstructorContracts { abis: resolved } } } fn resolve( symbol: &Symbol, contracts: &BTreeMap, - resolved: &mut BTreeMap, + resolved: &mut BTreeMap, visiting: &mut BTreeSet, -) -> usize { - if let Some(count) = resolved.get(symbol) { - return *count; +) -> CtorAbi { + if let Some(abi) = resolved.get(symbol) { + return *abi; } if !visiting.insert(symbol.clone()) { // Cyclic heritage has no constructor-bearing ancestor. Keep the // standalone fallback and, crucially, the same ABI on every edge. - return UNRESOLVED_PARENT_FWD_ARITY; + return CtorAbi::positional(UNRESOLVED_PARENT_FWD_ARITY); } - let count = match &contracts[symbol] { - Contract::Params(count) => *count, + let abi = match &contracts[symbol] { + Contract::Params(abi) => *abi, Contract::Parent(parent, fallback) => { if contracts.contains_key(parent) { resolve(parent, contracts, resolved, visiting) @@ -121,8 +125,8 @@ fn resolve( } }; visiting.remove(symbol); - resolved.insert(symbol.clone(), count); - count + resolved.insert(symbol.clone(), abi); + abi } impl ResolvedConstructorContracts { @@ -132,16 +136,22 @@ impl ResolvedConstructorContracts { .classes .iter() .map(|class| { - let count = self.arities[&(prefix.to_owned(), class.name.clone())]; - (class.name.clone(), count) + let abi = self.abis[&(prefix.to_owned(), class.name.clone())]; + (class.name.clone(), abi.param_count) }) .collect(); for imported in &mut opts.imported_classes { - if let Some(count) = self - .arities + if let Some(abi) = self + .abis .get(&(imported.source_prefix.clone(), imported.name.clone())) { - imported.constructor_param_count = *count; + imported.constructor_param_count = abi.param_count; + // #10484: a no-own-ctor class emits a positional forwarder into + // its ancestor's symbol, so the `new` site here must pack the + // trailing arrays the ANCESTOR declares, not the (empty) set the + // forwarder's own class HIR shows. + imported.constructor_has_rest = abi.has_rest; + imported.constructor_has_synthetic_arguments = abi.has_synthetic_arguments; } } } diff --git a/crates/perry-codegen/src/codegen/ctor_arity.rs b/crates/perry-codegen/src/codegen/ctor_arity.rs index 4001fceb3e..f221453511 100644 --- a/crates/perry-codegen/src/codegen/ctor_arity.rs +++ b/crates/perry-codegen/src/codegen/ctor_arity.rs @@ -11,28 +11,74 @@ use std::collections::{BTreeMap, HashMap}; /// [`synthesized_ctor_param_count`]). pub const UNRESOLVED_PARENT_FWD_ARITY: usize = 8; -/// The standalone-constructor arity of `class` when it can be decided from the +/// The ABI of a class's standalone `_constructor` symbol: how many slots +/// it takes and which of the trailing ones are arrays a caller has to PACK — a +/// user `...rest` and/or the HIR-synthesized `arguments` slot (#10484). +/// +/// A class with no own constructor emits the `super(...args)` forwarder, which +/// passes every slot on to the ancestor's symbol unchanged, so it carries the +/// ancestor's ABI verbatim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CtorAbi { + pub param_count: usize, + pub has_rest: bool, + pub has_synthetic_arguments: bool, +} + +impl CtorAbi { + /// Positional-only, `param_count` slots. + pub(crate) fn positional(param_count: usize) -> Self { + CtorAbi { + param_count, + ..CtorAbi::default() + } + } + + /// Read a constructor's own parameter list. Synthesized `__perry_cap_*` + /// capture params trail the arrays and are bound from the class's capture + /// snapshot rather than from the call, so a capturing constructor reports + /// no packable slot and keeps positional marshaling. + pub(crate) fn from_params(params: &[perry_hir::Param]) -> Self { + if params.iter().any(|p| p.name.starts_with("__perry_cap_")) { + return CtorAbi::positional(params.len()); + } + CtorAbi { + param_count: params.len(), + has_rest: params + .iter() + .any(|p| p.is_rest && p.arguments_object.is_none()), + has_synthetic_arguments: params.iter().any(|p| p.arguments_object.is_some()), + } + } +} + +/// The standalone-constructor ABI of `class` when it can be decided from the /// class definition alone, without the defining module's class table or /// imports. The source-graph constructor-contract resolver uses this, so it /// MUST agree with [`synthesized_ctor_param_count`] for every case it answers: /// an own constructor, a native parent, no heritage, and a heritage that is only /// a runtime value (`extends_expr` with no resolvable `extends_name`), which -/// always synthesizes the fixed forwarding band. `None` means the arity depends -/// on the defining module's ancestor walk (#10258). -pub fn context_free_ctor_param_count(class: &perry_hir::Class) -> Option { +/// always synthesizes the fixed positional forwarding band. `None` means the ABI +/// depends on the defining module's ancestor walk (#10258). +pub fn context_free_ctor_abi(class: &perry_hir::Class) -> Option { if let Some(c) = class.constructor.as_ref() { - return Some(c.params.len()); + return Some(CtorAbi::from_params(&c.params)); } if class.native_extends.is_some() { - return Some(0); + return Some(CtorAbi::positional(0)); } match (&class.extends_name, &class.extends_expr) { - (None, None) => Some(0), - (None, Some(_)) => Some(UNRESOLVED_PARENT_FWD_ARITY), + (None, None) => Some(CtorAbi::positional(0)), + (None, Some(_)) => Some(CtorAbi::positional(UNRESOLVED_PARENT_FWD_ARITY)), _ => None, } } +/// [`context_free_ctor_abi`]'s arity half. +pub fn context_free_ctor_param_count(class: &perry_hir::Class) -> Option { + context_free_ctor_abi(class).map(|abi| abi.param_count) +} + /// The standalone-constructor arity Perry emits for `class`, accounting for the /// JS spec default ctor `constructor(...args) { super(...args) }` that a class /// with NO own constructor but WITH heritage inherits. Walks the ancestor chain @@ -115,3 +161,203 @@ pub(super) fn synthesized_ctor_param_count( // so over-declaring is correct for any (non-native) parent up to this band. UNRESOLVED_PARENT_FWD_ARITY } + +/// The parameter list whose trailing-array layout (user rest, synthesized +/// `arguments`) the emitted standalone `_constructor` symbol follows. +/// +/// An own constructor is its own layout. A class with NO own constructor emits +/// the `super(...args)` forwarder, whose `__forward_arg` params adopt the +/// nearest ctor-bearing LOCAL ancestor's params positionally and pass every slot +/// to that ancestor's symbol unchanged, so a caller has to fill them exactly as +/// it would fill the ancestor's (#10484: the ancestor's `arguments` slot must +/// receive the whole argument list, not one positional argument). +/// +/// `None` wherever the forwarder does not make that static positional call: a +/// dynamic heritage edge (`extends_expr`, forwarded through the runtime +/// dynamic-parent dispatcher as plain arguments), a native parent, an imported +/// ancestor, or an arity that does not match `emitted_param_count`. Also `None` +/// for an ancestor with `__perry_cap_*` params: the forwarder registers no +/// capture slots, so its callers bind every slot positionally as before. +pub(super) fn constructor_layout_params<'a>( + class: &'a perry_hir::Class, + class_table: &HashMap, + emitted_param_count: u32, +) -> Option<&'a [perry_hir::Param]> { + if let Some(ctor) = class.constructor.as_ref() { + return Some(&ctor.params); + } + if class.native_extends.is_some() || class.extends_expr.is_some() { + return None; + } + let mut parent = class.extends_name.as_deref(); + let mut depth = 0usize; + while let Some(name) = parent { + let ancestor = *class_table.get(name)?; + // Imported stubs carry id 0 and no constructor body in this module. + if ancestor.id == 0 || ancestor.native_extends.is_some() || depth > 64 { + return None; + } + if let Some(ctor) = ancestor.constructor.as_ref() { + let positional = ctor.params.len() == emitted_param_count as usize + && !ctor.params.iter().any(|p| p.name.starts_with("__perry_cap_")); + return positional.then_some(ctor.params.as_slice()); + } + if ancestor.extends_expr.is_some() { + return None; + } + parent = ancestor.extends_name.as_deref(); + depth += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + use perry_hir::{ArgumentsObjectMeta, Class, Function, Param}; + + fn param(name: &str, is_rest: bool, arguments_object: bool) -> Param { + Param { + id: 0, + name: name.to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest, + arguments_object: arguments_object.then(|| ArgumentsObjectMeta { + strict: true, + simple_parameters: false, + mapped_parameter_ids: Vec::new(), + restricted_callee: true, + }), + } + } + + fn class(name: &str, extends: Option<&str>, ctor_params: Option>) -> Class { + Class { + id: 1, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: extends.map(|e| e.to_string()), + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: ctor_params.map(|params| Function { + id: 7, + name: format!("{}_constructor", name), + type_params: Vec::new(), + params, + return_type: Type::Void, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }), + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + aliases: Vec::new(), + } + } + + fn arguments_ctor_params() -> Vec { + vec![ + param("p", false, false), + param("q", false, false), + param("arguments", true, true), + ] + } + + #[test] + fn reads_the_trailing_arrays_of_an_own_constructor() { + let abi = CtorAbi::from_params(&arguments_ctor_params()); + assert_eq!( + abi, + CtorAbi { + param_count: 3, + has_rest: false, + has_synthetic_arguments: true, + } + ); + let with_user_rest = vec![ + param("first", false, false), + param("rest", true, false), + param("arguments", false, true), + ]; + assert_eq!( + CtorAbi::from_params(&with_user_rest), + CtorAbi { + param_count: 3, + has_rest: true, + has_synthetic_arguments: true, + } + ); + } + + #[test] + fn a_capturing_constructor_reports_no_packable_slot() { + // The capture params trail the arrays, so the packed slots are not the + // last ones and every caller binds positionally instead. + let mut params = arguments_ctor_params(); + params.push(param("__perry_cap_4", false, false)); + assert_eq!(CtorAbi::from_params(¶ms), CtorAbi::positional(4)); + } + + #[test] + fn a_forwarder_adopts_its_local_ancestors_layout() { + let base = class("Base", None, Some(arguments_ctor_params())); + let middle = class("Middle", Some("Base"), None); + let leaf = class("Leaf", Some("Middle"), None); + let table: HashMap = [ + ("Base".to_string(), &base), + ("Middle".to_string(), &middle), + ("Leaf".to_string(), &leaf), + ] + .into_iter() + .collect(); + + let layout = constructor_layout_params(&leaf, &table, 3).expect("ancestor layout"); + assert!(layout.iter().any(|p| p.arguments_object.is_some())); + assert_eq!(layout.len(), 3); + + // An arity the emitted forwarder does not have must not claim a layout: + // the caller would pack an array into a slot nobody forwards. + assert!(constructor_layout_params(&leaf, &table, 2).is_none()); + } + + #[test] + fn a_dynamic_or_unknown_heritage_forwarder_has_no_layout() { + let base = class("Base", None, Some(arguments_ctor_params())); + let mut dynamic = class("Dynamic", Some("Base"), None); + dynamic.extends_expr = Some(Box::new(perry_hir::Expr::LocalGet(3))); + let orphan = class("Orphan", Some("Missing"), None); + let table: HashMap = [ + ("Base".to_string(), &base), + ("Dynamic".to_string(), &dynamic), + ("Orphan".to_string(), &orphan), + ] + .into_iter() + .collect(); + + assert!(constructor_layout_params(&dynamic, &table, 3).is_none()); + assert!(constructor_layout_params(&orphan, &table, 3).is_none()); + } +} diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 925c42f4cd..fd80687d6d 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -190,7 +190,9 @@ mod closure_collect; mod constructor_contracts; pub use constructor_contracts::{ConstructorContracts, ResolvedConstructorContracts}; mod ctor_arity; -pub use ctor_arity::{context_free_ctor_param_count, UNRESOLVED_PARENT_FWD_ARITY}; +pub use ctor_arity::{ + context_free_ctor_abi, context_free_ctor_param_count, CtorAbi, UNRESOLVED_PARENT_FWD_ARITY, +}; #[cfg(test)] mod declared_string_add_tests; #[cfg(test)] @@ -2476,6 +2478,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> has_own_constructor: ic.has_own_constructor, has_instance_fields: ic.has_instance_fields, has_rest: ic.constructor_has_rest, + has_synthetic_arguments: ic.constructor_has_synthetic_arguments, }, ) }) diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index d262040d7b..da977f7d82 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -562,14 +562,18 @@ pub struct ImportedClass { pub constructor_param_count: usize, /// Whether the source class declared its own constructor body. pub has_own_constructor: bool, - /// Whether the source class's constructor's last declared parameter is - /// `...rest`. Symmetric to `method_has_rest` but for the constructor: the + /// Whether the source class's constructor declares a user `...rest` + /// parameter. Symmetric to `method_has_rest` but for the constructor: the /// source module compiled `_constructor(this, arg0, …)` expecting /// the rest slot to receive a PACKED ARRAY of the trailing args. Without /// this flag the cross-module `new C(a, b, c)` dispatch passed the args /// positionally, so `arg0 = a` (raw) and `b`/`c` were dropped — a /// `constructor(...args)` saw `args = a`, length 1. pub constructor_has_rest: bool, + /// Whether the source constructor reads `arguments`, i.e. its signature + /// ends in the HIR-synthesized `arguments` slot (after any user rest). That + /// slot receives a packed array of EVERY argument (#10484). + pub constructor_has_synthetic_arguments: bool, /// Whether the source class has instance fields that require initializer replay. pub has_instance_fields: bool, /// Method names defined on this class. @@ -686,6 +690,17 @@ pub struct ImportedClass { } impl ImportedClass { + /// The standalone-constructor ABI this class's defining module compiled, + /// as recorded on the import route (the constructor-contract resolver + /// overwrites all three fields for classes in the source graph). + pub(crate) fn ctor_abi(&self) -> super::ctor_arity::CtorAbi { + super::ctor_arity::CtorAbi { + param_count: self.constructor_param_count, + has_rest: self.constructor_has_rest, + has_synthetic_arguments: self.constructor_has_synthetic_arguments, + } + } + /// Consumer-side registry key for this class. pub fn effective_name(&self) -> String { let member = self.local_alias.as_deref().unwrap_or(&self.name); @@ -779,10 +794,13 @@ pub(crate) struct ImportedCtor { pub param_count: usize, pub has_own_constructor: bool, pub has_instance_fields: bool, - /// True when the constructor's last declared param is `...rest`. Tells + /// True when the constructor declares a user `...rest` param. Tells /// the cross-module `new` dispatch to pack the trailing args into an /// array for the rest slot rather than passing them positionally. pub has_rest: bool, + /// True when the constructor's last param is the synthesized `arguments` + /// slot, which receives every argument packed into one array. + pub has_synthetic_arguments: bool, } impl ImportedCtor { diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 6f68e99303..ef7b16e68d 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -7,6 +7,7 @@ use crate::module::LlModule; use crate::strings::StringPool; use crate::types::{DOUBLE, I32, I64, PTR, VOID}; +use super::ctor_arity::constructor_layout_params; use super::helpers::{sanitize, sanitize_member, scoped_static_method_name}; use super::retained_source_pool::{SourcePool, SourceRange}; use super::spec_function_length; @@ -950,12 +951,15 @@ pub(super) fn emit_string_pool( .unwrap_or(0) }); let ctor_symbol = format!("{}__{}_constructor", module_prefix, class_name); + // The trailing-array layout of the emitted standalone ctor. A class with + // no own ctor emits the `super(...args)` forwarder, which adopts the + // nearest local ancestor ctor's params positionally and hands every slot + // to that ctor unchanged, so it takes the same layout (#10484: a dynamic + // `new Sub(x)` must put all args in the ancestor's `arguments` slot). + let shape_params = constructor_layout_params(class, classes, ctor_params); // #wall3: record the rest-param position (in USER params) so the runtime // bundles trailing args at the dynamic member-new dispatch path. - if let Some(rest_idx) = class - .constructor - .as_ref() - .and_then(|c| c.params.iter().position(|p| p.is_rest)) + if let Some(rest_idx) = shape_params.and_then(|params| params.iter().position(|p| p.is_rest)) { ctor_rest_regs.push((ctor_symbol.clone(), rest_idx)); } @@ -964,13 +968,17 @@ pub(super) fn emit_string_pool( // a synthesized `arguments` slot receives ALL args (from index 0), a // user rest param only the args from the rest position onward. { - let last = class.constructor.as_ref().and_then(|c| c.params.last()); - let ctor_has_synth = last.map(|p| p.arguments_object.is_some()).unwrap_or(false); - let ctor_has_rest = class - .constructor - .as_ref() - .map(|c| { - c.params + // `any`, not `last`: a class declared inside a function carries + // synthesized `__perry_cap_*` params AFTER the `arguments` slot, so + // reading only the final param missed every capturing class — + // including every class in a compiled CommonJS module, whose + // wrapper function is what they capture from (#10484). + let ctor_has_synth = shape_params + .map(|params| params.iter().any(|p| p.arguments_object.is_some())) + .unwrap_or(false); + let ctor_has_rest = shape_params + .map(|params| { + params .iter() .any(|p| p.is_rest && p.arguments_object.is_none()) }) diff --git a/crates/perry-codegen/src/expr/readonly_collection_tests.rs b/crates/perry-codegen/src/expr/readonly_collection_tests.rs index 3e709d7b64..08682c916e 100644 --- a/crates/perry-codegen/src/expr/readonly_collection_tests.rs +++ b/crates/perry-codegen/src/expr/readonly_collection_tests.rs @@ -182,6 +182,7 @@ fn imported_archetype() -> ImportedClass { constructor_param_count: 0, has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: Vec::new(), proven_this_method_names: Vec::new(), diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 9da92cfed1..572f2c691c 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -76,10 +76,11 @@ pub mod types; pub mod unit_cache; pub use codegen::{ - compile_module, context_free_ctor_param_count, namespace_member_class_key, - namespace_member_func_key, namespace_member_var_key, resolve_target_triple, - short_spread_method_capabilities, user_function_symbol, AppMetadata, CompileOptions, - ConstructorContracts, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, + compile_module, context_free_ctor_abi, context_free_ctor_param_count, + namespace_member_class_key, namespace_member_func_key, namespace_member_var_key, + resolve_target_triple, short_spread_method_capabilities, user_function_symbol, AppMetadata, + CompileOptions, ConstructorContracts, CtorAbi, ExportedObjectLiteralCapability, FpContractMode, + ImportedClass, ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, ObjectLiteralMethodCandidate, ResolvedConstructorContracts, ShortSpreadMethodCandidate, }; diff --git a/crates/perry-codegen/src/lower_call/new_ctor_args.rs b/crates/perry-codegen/src/lower_call/new_ctor_args.rs index de9bd00491..24b72fbc5a 100644 --- a/crates/perry-codegen/src/lower_call/new_ctor_args.rs +++ b/crates/perry-codegen/src/lower_call/new_ctor_args.rs @@ -297,12 +297,13 @@ pub(super) fn lower_constructor_arg(ctx: &mut FnCtx<'_>, arg: &Expr) -> Result_constructor(this, p0, …)` with `ctor.param_count` -/// explicit slots. When the constructor's last param is `...rest` -/// (`ctor.has_rest`), that final slot must receive a PACKED ARRAY of every -/// trailing arg — not the first trailing arg passed raw. Mirrors the -/// inline-ctor `inline_constructor_param_values` rest packing and the -/// `method_has_rest` path for imported methods (#672). Returns exactly -/// `ctor.param_count` value strings; missing leading args are padded with +/// explicit slots, laid out as `[fixed..., user_rest?, arguments?]`. A user +/// `...rest` slot (`ctor.has_rest`) must receive a PACKED ARRAY of every +/// trailing arg — not the first trailing arg passed raw — and the synthesized +/// `arguments` slot (`ctor.has_synthetic_arguments`, #10484) a packed array of +/// EVERY arg. Mirrors the inline-ctor `inline_constructor_param_values` +/// packing and the `method_has_rest` path for imported methods (#672). Returns +/// exactly `ctor.param_count` value strings; missing fixed args are padded with /// `undefined`. pub(super) fn marshal_imported_ctor_args( ctx: &mut FnCtx<'_>, @@ -311,10 +312,9 @@ pub(super) fn marshal_imported_ctor_args( ) -> Vec { let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); let param_count = ctor.param_count; - if ctor.has_rest && param_count > 0 { - // The first `param_count - 1` slots are positional; the last slot is - // the rest array packing every remaining arg. - let n_positional = param_count - 1; + let trailing = usize::from(ctor.has_rest) + usize::from(ctor.has_synthetic_arguments); + if trailing > 0 && param_count >= trailing { + let n_positional = param_count - trailing; let mut out: Vec = Vec::with_capacity(param_count); for i in 0..n_positional { out.push( @@ -324,8 +324,13 @@ pub(super) fn marshal_imported_ctor_args( .unwrap_or_else(|| undef.clone()), ); } - let tail: Vec = lowered_args.iter().skip(n_positional).cloned().collect(); - out.push(pack_lowered_args_array(ctx, &tail)); + if ctor.has_rest { + let tail: Vec = lowered_args.iter().skip(n_positional).cloned().collect(); + out.push(pack_lowered_args_array(ctx, &tail)); + } + if ctor.has_synthetic_arguments { + out.push(pack_lowered_args_array(ctx, lowered_args)); + } out } else { // No rest: positional, padded to `param_count` with `undefined`. diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index fead87a8c6..ef8c19df45 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -492,6 +492,7 @@ fn imported_remote() -> ImportedClass { constructor_param_count: 1, has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec!["read".to_string()], proven_this_method_names: Vec::new(), diff --git a/crates/perry-hir/src/monomorph/defaults.rs b/crates/perry-hir/src/monomorph/defaults.rs index 1e87a1f7cd..1d5e7c21c3 100644 --- a/crates/perry-hir/src/monomorph/defaults.rs +++ b/crates/perry-hir/src/monomorph/defaults.rs @@ -34,6 +34,16 @@ pub(crate) fn fill_default_arguments(module: &mut Module) { let mut ctors: HashMap>> = HashMap::new(); for class in &module.classes { if let Some(ref ctor) = class.constructor { + // #10484: a constructor that reads `arguments` observes the call + // site's argument COUNT, and an appended `undefined` is + // indistinguishable from one the caller wrote (`new C("x")` + // reported `arguments.length === 2` for `constructor(p, q)`). + // Every constructor call path already binds an omitted parameter + // to `undefined` itself, so skip these exactly like the + // synth-`arguments` free functions below. + if ctor.params.iter().any(|p| p.arguments_object.is_some()) { + continue; + } // Stop at a trailing rest parameter. `constructor(...e)` (or // `constructor(a, b = 5, ...e)`) accepts zero or more trailing // args, so the call-site padding below must never synthesize an diff --git a/crates/perry-hir/src/monomorph/tests.rs b/crates/perry-hir/src/monomorph/tests.rs index 71b357be6f..533d60c064 100644 --- a/crates/perry-hir/src/monomorph/tests.rs +++ b/crates/perry-hir/src/monomorph/tests.rs @@ -1022,3 +1022,118 @@ fn a_specialized_class_reports_the_generics_display_name() { "only the specialization gets an entry" ); } + +/// #10484: a constructor that reads `arguments` must see the call site's own +/// argument count, so the default-fill pass may not pad its `new` sites. +#[test] +fn fill_defaults_skips_constructors_that_read_arguments() { + fn class_with_ctor(name: &str, id: u32, reads_arguments: bool) -> Class { + let mut params = vec![ + Param { + id: 1, + name: "p".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + Param { + id: 2, + name: "q".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + ]; + if reads_arguments { + params.push(Param { + id: 3, + name: "arguments".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: true, + arguments_object: Some(crate::ArgumentsObjectMeta { + strict: true, + simple_parameters: false, + mapped_parameter_ids: Vec::new(), + restricted_callee: true, + }), + }); + } + Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: Some(Function { + id: 100 + id, + name: format!("{}_constructor", name), + type_params: Vec::new(), + params, + return_type: Type::Void, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + }), + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + aliases: Vec::new(), + } + } + + let mut module = Module::new("test"); + module.classes.push(class_with_ctor("Plain", 1, false)); + module.classes.push(class_with_ctor("Args", 2, true)); + for class_name in ["Plain", "Args"] { + module.init.push(Stmt::Expr(Expr::New { + class_name: class_name.to_string(), + args: vec![Expr::String("x".to_string())], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + } + + super::fill_default_arguments(&mut module); + + let arg_counts: Vec = module + .init + .iter() + .map(|stmt| match stmt { + Stmt::Expr(Expr::New { args, .. }) => args.len(), + other => panic!("unexpected statement {:?}", other), + }) + .collect(); + assert_eq!( + arg_counts, + vec![2, 1], + "the plain constructor keeps its `undefined` padding; the one reading \ + `arguments` must observe exactly the argument the call site passed" + ); +} diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 6286afc168..f6d0ae2e4b 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -189,6 +189,78 @@ fn lookup_class_constructor_flags(class_id: u32) -> (bool, bool) { .unwrap_or((false, false)) } +/// Bind the USER parameter slots of `ctor_cid`'s registered constructor (every +/// slot before its trailing `__perry_cap_*` params) from a construct call's +/// full argument list. +/// +/// Codegen lowers the trailing array parameters as +/// `[fixed..., user_rest?, synthesized_arguments?]` and registers the position +/// of the first one in the closure-rest table (`ctor_rest_regs`). The flags say +/// which arrays follow: a user rest receives the arguments from that position +/// on, the synthesized `arguments` slot receives EVERY argument. +/// +/// #10484: the dynamic construct paths used to pack only a user-rest tail at +/// that position, so a constructor reading `arguments` saw just the arguments +/// past its declared parameters. `new K("x")` for `constructor(p, q)` reported +/// `arguments.length === 0`, which is how undici's `new Request(url)` failed +/// its own `argumentLengthCheck(arguments, 1)`. +/// +/// The returned words are not rooted. Callers hand them to the constructor call +/// without allocating in between. +unsafe fn constructor_user_arg_slots( + ctor_ptr: usize, + ctor_cid: u32, + user_params: usize, + args_ptr: *const f64, + args_len: usize, +) -> Vec { + let undef = f64::from_bits(crate::value::TAG_UNDEFINED); + let arg = |i: usize| { + if !args_ptr.is_null() && i < args_len { + *args_ptr.add(i) + } else { + undef + } + }; + let (has_synth, flagged_rest) = lookup_class_constructor_flags(ctor_cid); + // An unflagged registration is a plain `constructor(a, ...rest)`. + let has_rest = flagged_rest || !has_synth; + let trailing = usize::from(has_rest) + usize::from(has_synth); + let Some(fixed) = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) + .map(|fixed| fixed as usize) + .filter(|fixed| fixed + trailing <= user_params) + else { + return (0..user_params).map(arg).collect(); + }; + + let scope = crate::gc::RuntimeHandleScope::new(); + let supplied: Vec = (0..args_len).map(arg).collect(); + let supplied_handles = scope.root_nanbox_f64_slice(&supplied); + let user_rest = has_rest.then(|| { + let refreshed = + crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&supplied_handles); + let tail = &refreshed[fixed.min(refreshed.len())..]; + scope.root_nanbox_f64(crate::closure::build_rest_array(tail, false)) + }); + let arguments = has_synth.then(|| { + let refreshed = + crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&supplied_handles); + scope.root_nanbox_f64(crate::closure::build_rest_array(&refreshed, true)) + }); + + let mut slots = Vec::with_capacity(user_params); + for i in 0..fixed { + slots.push( + supplied_handles + .get(i) + .map_or(undef, |handle| handle.get_nanbox_f64()), + ); + } + slots.extend(user_rest.map(|handle| handle.get_nanbox_f64())); + slots.extend(arguments.map(|handle| handle.get_nanbox_f64())); + slots +} + crate::perry_thread_local! { /// Decl-site snapshots of a function-nested class DECLARATION's captured /// outer locals, keyed by class_id. Filled by the codegen-emitted @@ -514,37 +586,16 @@ pub unsafe extern "C" fn js_super_construct_apply( // would swallow) — then append exactly `sig_caps` snapshot // values. Call with synth/rest OFF since we packed the trailing // slot manually. - let mut fa: Vec = Vec::with_capacity(total_params as usize); - let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) - .map(|ri| ri as usize) - .filter(|ri| *ri < user_params); - if let Some(ri) = rest_idx { - for i in 0..ri { - fa.push(if i < n { - crate::array::js_array_get_f64(arr, i as u32) - } else { - undef - }); - } - let mut rest_arr = crate::array::js_array_alloc(0); - let mut i = ri; - while i < n { - rest_arr = crate::array::js_array_push_f64( - rest_arr, - crate::array::js_array_get_f64(arr, i as u32), - ); - i += 1; - } - fa.push(crate::value::js_nanbox_pointer(rest_arr as i64)); - } else { - for i in 0..user_params { - fa.push(if i < n { - crate::array::js_array_get_f64(arr, i as u32) - } else { - undef - }); - } - } + let spread: Vec = (0..n) + .map(|i| crate::array::js_array_get_f64(arr, i as u32)) + .collect(); + let mut fa = constructor_user_arg_slots( + ctor_ptr, + cur, + user_params, + spread.as_ptr(), + spread.len(), + ); for slot in 0..sig_caps as usize { fa.push(caps.get(slot).map(|b| f64::from_bits(*b)).unwrap_or(undef)); } @@ -923,33 +974,8 @@ pub(crate) unsafe fn run_class_constructor_on_this_flat( crate::closure::lookup_closure_rest(ctor_ptr as *const u8) ); } - let get = |i: usize| -> f64 { - if !args_ptr.is_null() && i < args_len { - unsafe { *args_ptr.add(i) } - } else { - undef - } - }; - let mut final_args: Vec = Vec::with_capacity(total_params as usize); - let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) - .map(|ri| ri as usize) - .filter(|ri| *ri < user_params); - if let Some(ri) = rest_idx { - for i in 0..ri { - final_args.push(get(i)); - } - let mut rest_arr = crate::array::js_array_alloc(0); - let mut i = ri; - while i < args_len { - rest_arr = crate::array::js_array_push_f64(rest_arr, get(i)); - i += 1; - } - final_args.push(crate::value::js_nanbox_pointer(rest_arr as i64)); - } else { - for i in 0..user_params { - final_args.push(get(i)); - } - } + let mut final_args = + constructor_user_arg_slots(ctor_ptr, cur, user_params, args_ptr, args_len); for slot in 0..sig_caps as usize { final_args.push(caps.get(slot).map(|b| f64::from_bits(*b)).unwrap_or(undef)); } @@ -1276,45 +1302,17 @@ pub(crate) unsafe fn replay_class_object_constructor( // exists, and the old `max(per-eval, snapshot)` subtraction ate user args). let user_params = (total_params as usize).saturating_sub(sig_caps as usize); let undef = f64::from_bits(crate::value::TAG_UNDEFINED); - let mut final_args: Vec = Vec::with_capacity(total_params as usize); // #wall3: a `constructor(...args)` (rest param) called via the dynamic // member-new path (`new ns.Sub(opts)` → js_new_function_construct → // is_class_object_value → here) must BUNDLE the trailing call args into a JS // array for the rest slot. call_vtable_method's own `has_rest` can't do it // because the rest param is NOT last here — the positional `__perry_cap_*` - // capture params follow it — so we pack the rest array ourselves at the rest - // index, then append caps. Without this the rest binds to the first arg as a + // capture params follow it — so the trailing arrays are packed before the + // caps are appended. Without this the rest binds to the first arg as a // scalar (`args`=opts, not [opts]) and `super(...args)` spreads a bare object // → 0x400000000 mis-box → crash (Next.js `new c.AppPageRouteModule({...})`). - let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) - .map(|ri| ri as usize) - .filter(|ri| *ri < user_params); - if let Some(ri) = rest_idx { - for i in 0..ri { - if !args_ptr.is_null() && i < args_len { - final_args.push(*args_ptr.add(i)); - } else { - final_args.push(undef); - } - } - let mut rest_arr = crate::array::js_array_alloc(0); - if !args_ptr.is_null() { - let mut i = ri; - while i < args_len { - rest_arr = crate::array::js_array_push_f64(rest_arr, *args_ptr.add(i)); - i += 1; - } - } - final_args.push(crate::value::js_nanbox_pointer(rest_arr as i64)); - } else { - for i in 0..user_params { - if !args_ptr.is_null() && i < args_len { - final_args.push(*args_ptr.add(i)); - } else { - final_args.push(undef); - } - } - } + let mut final_args = + constructor_user_arg_slots(ctor_ptr, ctor_cid, user_params, args_ptr, args_len); // Exactly `sig_caps` trailing cap slots: per-evaluation snapshot first // (class EXPRESSIONS carry `__perry_ctor_caps`), decl-site snapshot second // (class DECLARATIONS reached as heap values), undefined last. @@ -1407,46 +1405,18 @@ pub(crate) unsafe fn replay_registered_class_constructor( let user_params = (total_params as usize).saturating_sub(sig_caps as usize); let undef = f64::from_bits(crate::value::TAG_UNDEFINED); - let mut final_args: Vec = Vec::with_capacity(total_params as usize); // #wall3: a `constructor(...args)` reached via the dynamic class-REF member-new // path (`new ns.Sub(opts)` where ns.Sub resolves to an INT32 ClassRef at // runtime → js_new_function_construct → constructor_class_ref_id → // construct_registered_class_ref → here) must BUNDLE trailing call args into a // JS array for the rest slot. The rest is NOT the last ctor param (positional // `__perry_cap_*` capture params follow it), so call_vtable_method's own - // `has_rest` can't pack it — we pack the rest array ourselves at the rest - // index, then append caps. Without this the rest binds to the first arg as a + // `has_rest` can't pack it — the trailing arrays are packed before the caps + // are appended. Without this the rest binds to the first arg as a // scalar (`args`=opts, not [opts]) and `super(...args)` spreads a bare object // → 0x400000000 mis-box → crash (Next.js `new c.AppPageRouteModule({...})`). - let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) - .map(|ri| ri as usize) - .filter(|ri| *ri < user_params); - if let Some(ri) = rest_idx { - for i in 0..ri { - if !args_ptr.is_null() && i < args_len { - final_args.push(*args_ptr.add(i)); - } else { - final_args.push(undef); - } - } - let mut rest_arr = crate::array::js_array_alloc(0); - if !args_ptr.is_null() { - let mut i = ri; - while i < args_len { - rest_arr = crate::array::js_array_push_f64(rest_arr, *args_ptr.add(i)); - i += 1; - } - } - final_args.push(crate::value::js_nanbox_pointer(rest_arr as i64)); - } else { - for i in 0..user_params { - if !args_ptr.is_null() && i < args_len { - final_args.push(*args_ptr.add(i)); - } else { - final_args.push(undef); - } - } - } + let mut final_args = + constructor_user_arg_slots(ctor_ptr, ctor_cid, user_params, args_ptr, args_len); for slot in 0..sig_caps as usize { final_args.push(caps.get(slot).map(|b| f64::from_bits(*b)).unwrap_or(undef)); } @@ -1460,3 +1430,110 @@ pub(crate) unsafe fn replay_registered_class_constructor( false, ) } + +#[cfg(test)] +mod constructor_arg_slot_tests { + use super::*; + + /// Distinct registry keys per case: `CLASS_CONSTRUCTOR_FLAGS` is + /// process-global and the closure-rest table is keyed by function pointer. + fn key(case: u32) -> (usize, u32) { + // Any stable non-null address works — the helper only READS the + // registrations under this key, it never calls through the pointer. + let ptr = (0x10_484_000usize) + case as usize * 0x40; + (ptr, 10_484_000 + case) + } + + fn array_of(value: f64) -> (usize, u32) { + let arr = crate::value::js_nanbox_get_pointer(value) as *const crate::array::ArrayHeader; + assert!(!arr.is_null(), "expected a packed array, got {value}"); + let len = unsafe { crate::array::js_array_length(arr) }; + (arr as usize, len) + } + + fn element(value: f64, index: u32) -> f64 { + let arr = crate::value::js_nanbox_get_pointer(value) as *const crate::array::ArrayHeader; + unsafe { crate::array::js_array_get_f64(arr, index) } + } + + #[test] + fn the_synthesized_arguments_slot_takes_every_argument() { + let (ptr, cid) = key(1); + // `constructor(p, q)` reading `arguments`: two fixed slots, then the + // synthesized array at index 2. + js_register_class_constructor_flags(cid as i64, 1, 0); + crate::closure::js_register_closure_rest(ptr as *const u8, 2); + + let args = [11.0, 22.0, 33.0]; + let slots = + unsafe { constructor_user_arg_slots(ptr, cid, 3, args.as_ptr(), args.len()) }; + assert_eq!(slots.len(), 3); + assert_eq!(slots[0], 11.0); + assert_eq!(slots[1], 22.0); + let (arr, len) = array_of(slots[2]); + assert_eq!(len, 3, "`arguments` must hold every supplied argument"); + assert_eq!(element(slots[2], 2), 33.0); + assert!( + unsafe { + crate::array::array_has_arguments_object_flag( + arr as *const crate::array::ArrayHeader, + ) + }, + "the packed array must be marked as an Arguments object" + ); + + // Fewer arguments than declared parameters: the fixed slots pad with + // `undefined` while `arguments.length` stays at what was passed. + let one = [11.0]; + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 3, one.as_ptr(), one.len()) }; + assert_eq!(slots[1].to_bits(), crate::value::TAG_UNDEFINED); + assert_eq!(array_of(slots[2]).1, 1); + + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 3, std::ptr::null(), 0) }; + assert_eq!(array_of(slots[2]).1, 0); + } + + #[test] + fn a_user_rest_and_arguments_constructor_fills_both_arrays() { + let (ptr, cid) = key(2); + // `constructor(first, ...rest)` reading `arguments`: one fixed slot, + // the rest array at index 1, the full argument list at index 2. + js_register_class_constructor_flags(cid as i64, 1, 1); + crate::closure::js_register_closure_rest(ptr as *const u8, 1); + + let args = [11.0, 22.0, 33.0]; + let slots = + unsafe { constructor_user_arg_slots(ptr, cid, 3, args.as_ptr(), args.len()) }; + assert_eq!(slots.len(), 3); + assert_eq!(slots[0], 11.0); + assert_eq!(array_of(slots[1]).1, 2, "rest holds the tail only"); + assert_eq!(element(slots[1], 0), 22.0); + assert_eq!(array_of(slots[2]).1, 3, "`arguments` holds all three"); + } + + #[test] + fn an_unflagged_rest_constructor_keeps_tail_only_packing() { + let (ptr, cid) = key(3); + // No flags registered at all — a plain `constructor(a, ...rest)`. + crate::closure::js_register_closure_rest(ptr as *const u8, 1); + + let args = [11.0, 22.0, 33.0]; + let slots = + unsafe { constructor_user_arg_slots(ptr, cid, 2, args.as_ptr(), args.len()) }; + assert_eq!(slots.len(), 2); + assert_eq!(slots[0], 11.0); + assert_eq!(array_of(slots[1]).1, 2); + } + + #[test] + fn a_constructor_with_no_trailing_array_stays_positional() { + let (_, cid) = key(4); + // An unregistered constructor pointer: positional, padded to the + // declared parameter count. + let args = [11.0]; + let slots = unsafe { constructor_user_arg_slots(0x10_484_900, cid, 2, args.as_ptr(), 1) }; + assert_eq!(slots.len(), 2); + assert_eq!(slots[0], 11.0); + assert_eq!(slots[1].to_bits(), crate::value::TAG_UNDEFINED); + } +} diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 01c0f0f466..21868bf7f4 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -651,6 +651,14 @@ fn compute_object_cache_key_with_env( buf.push_str(namespace); buf.push('|'); } + // #10484: the constructor's trailing-array layout decides how a + // `new` site packs its arguments. Only constructors reading + // `arguments` add a component, so other keys stay byte-identical. + if c.constructor_has_synthetic_arguments { + buf.push_str(":ctor_arguments=1:ctor_rest="); + buf.push_str(if c.constructor_has_rest { "1" } else { "0" }); + buf.push('|'); + } buf.push_str("method_rest="); buf.push_str( &c.method_has_rest diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index e1fe164098..2060069aa7 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -354,6 +354,7 @@ fn key_stable_for_nested_type_hashmap_order() { constructor_param_count: 0, has_own_constructor: false, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec![], proven_this_method_names: vec![], @@ -426,6 +427,7 @@ fn key_changes_with_imported_class_signature() { constructor_param_count: 1, has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec!["bar".into()], proven_this_method_names: vec![], @@ -460,6 +462,7 @@ fn key_changes_with_imported_class_signature() { constructor_param_count: 2, // different arity has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec!["bar".into()], proven_this_method_names: vec![], @@ -502,6 +505,7 @@ fn key_changes_with_imported_class_codegen_surface() { constructor_param_count: 1, has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec!["bar".into()], proven_this_method_names: vec![], diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index e0596c6081..557f9b1a4d 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -288,6 +288,7 @@ fn imported_class_from_hir( proven_this_method_names: Vec, proven_this_tower_method_names: Vec, ) -> perry_codegen::ImportedClass { + let ctor_abi = perry_codegen::context_free_ctor_abi(class).unwrap_or_default(); perry_codegen::ImportedClass { name: class.name.clone(), local_alias, @@ -300,11 +301,11 @@ fn imported_class_from_hir( .as_ref() .map_or(0, |ctor| ctor.params.len()), has_own_constructor: class.constructor.is_some(), - constructor_has_rest: class - .constructor - .as_ref() - .map(|ctor| ctor.params.iter().any(|param| param.is_rest)) - .unwrap_or(false), + // Trailing array slots this class's own constructor declares. A + // no-own-ctor class emits a positional forwarder, so it reports none + // until the constructor contract resolves its ancestor's ABI (#10484). + constructor_has_rest: ctor_abi.has_rest, + constructor_has_synthetic_arguments: ctor_abi.has_synthetic_arguments, has_instance_fields: !class.fields.is_empty(), method_names: class .methods @@ -436,6 +437,7 @@ fn imported_object_literal_from_capability( constructor_param_count: capability.field_names.len(), has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: !capability.field_names.is_empty(), method_names: Vec::new(), proven_this_method_names: Vec::new(), diff --git a/test-files/fixtures/issue_10484_ctor_arguments/classes.ts b/test-files/fixtures/issue_10484_ctor_arguments/classes.ts new file mode 100644 index 0000000000..7130c87f27 --- /dev/null +++ b/test-files/fixtures/issue_10484_ctor_arguments/classes.ts @@ -0,0 +1,55 @@ +// #10484: classes whose constructors read `arguments`, imported by +// test_gap_10484_class_constructor_arguments.ts through ESM bindings. + +export function fmtArgs(a: ArrayLike): string { + return a.length + ":" + JSON.stringify(Array.from(a)); +} + +export class ExpTwo { + r: string; + constructor(p?: any, q?: any) { + this.r = fmtArgs(arguments); + } +} + +export class ExpDefault { + r: string; + constructor(p: any, q: any = "dq") { + this.r = fmtArgs(arguments) + " q=" + q; + } +} + +export class ExpRest { + r: string; + constructor(first?: any, ...rest: any[]) { + this.r = fmtArgs(arguments) + " rest=" + JSON.stringify(rest); + } +} + +export class ExpLength { + n: number; + constructor(p?: any, q?: any) { + this.n = arguments.length; + } +} + +export class ExpDerived extends ExpTwo { + constructor(x?: any) { + super(...arguments); + } +} + +export class ExpNoCtorDerived extends ExpTwo {} + +export class ExpNoCtorRest extends ExpRest {} + +const tag = "cap"; +export function makeCapturing() { + const local = tag + "-" + "tured"; + return class Capturing { + r: string; + constructor(p?: any) { + this.r = fmtArgs(arguments) + " " + local; + } + }; +} diff --git a/test-files/fixtures/issue_10484_ctor_arguments/request.cjs b/test-files/fixtures/issue_10484_ctor_arguments/request.cjs new file mode 100644 index 0000000000..80108b1375 --- /dev/null +++ b/test-files/fixtures/issue_10484_ctor_arguments/request.cjs @@ -0,0 +1,46 @@ +'use strict'; +// #10484: undici 8.x `lib/web/fetch/request.js` / webidl2js shapes. Every class +// in a compiled CommonJS package is constructed through a runtime value. + +function argumentLengthCheck({ length }, min, ctx) { + if (length < min) { + throw new TypeError(`${ctx}: ${min} argument${min !== 1 ? 's' : ''} required, but only ${length} found.`); + } +} + +class Request { + constructor(input, init = {}) { + argumentLengthCheck(arguments, 1, 'Request constructor'); + this.url = input; + this.init = init; + this.argc = arguments.length; + } +} + +class Headers { + constructor(init = undefined) { + this.argc = arguments.length; + this.list = Array.from(arguments); + } +} + +// A subclass that forwards `arguments` the way transpiled code does. +class StrictRequest extends Request { + constructor() { + super(...arguments); + this.forwarded = arguments.length; + } +} + +// `fn.apply(this, arguments)` inside a constructor. +function record(self, a, b) { + self.applied = [a, b]; +} +class Applier { + constructor(a, b) { + record.apply(null, [this].concat(Array.prototype.slice.call(arguments))); + this.argc = arguments.length; + } +} + +module.exports = { Request, Headers, StrictRequest, Applier, argumentLengthCheck }; diff --git a/test-files/test_gap_10484_class_constructor_arguments.ts b/test-files/test_gap_10484_class_constructor_arguments.ts new file mode 100644 index 0000000000..b3cf23e410 --- /dev/null +++ b/test-files/test_gap_10484_class_constructor_arguments.ts @@ -0,0 +1,291 @@ +// #10484: `arguments` inside a class CONSTRUCTOR must describe the call site. +// +// Two defects, one object: +// - a static `new C(x)` reported the DECLARED parameter count, because the +// call site was padded with `undefined` up to the declared arity before +// the arguments list was packed; +// - construction through a runtime value (`const K = C; new K(x)`, a class +// returned from a function, an imported or CommonJS class, +// `Reflect.construct`) reported an EMPTY list, because the dynamic +// construct path bound the synthesized `arguments` slot like a user rest +// parameter (only the arguments past the declared ones). +// +// undici 8.9.0's `new Request(url)` (webidl `argumentLengthCheck(arguments, 1)`) +// and whatwg-url's `new URL(href)` (a class declared inside `install()` that +// checks `arguments.length < 1`) both threw "1 argument required, but 0 found". +import cjs from "./fixtures/issue_10484_ctor_arguments/request.cjs"; +import * as esm from "./fixtures/issue_10484_ctor_arguments/classes.ts"; +import { ExpTwo, ExpDefault, fmtArgs } from "./fixtures/issue_10484_ctor_arguments/classes.ts"; + +function row(label: string, f: () => any): void { + try { + const o = f(); + console.log(label, "=>", o.r ?? o.n ?? o.argc ?? o.href ?? o.url); + } catch (e: any) { + console.log(label, "=> threw", e.constructor.name, e.message); + } +} + +// ── module-level classes ── +class Two { + r: string; + constructor(p?: any, q?: any) { + this.r = fmtArgs(arguments); + } +} +class NoParams { + r: string; + constructor() { + this.r = fmtArgs(arguments); + } +} +class WithDefault { + r: string; + constructor(p: any, q: any = "dq") { + this.r = fmtArgs(arguments) + " q=" + q; + } +} +class WithRest { + r: string; + constructor(first?: any, ...rest: any[]) { + this.r = fmtArgs(arguments) + " rest=" + JSON.stringify(rest); + } +} +class LengthOnly { + n: number; + constructor(p?: any, q?: any) { + this.n = arguments.length; + } +} +class Indexed { + r: string; + constructor(p?: any) { + this.r = [arguments[0], arguments[1], arguments[2]].map(String).join(","); + } +} +class WithField { + tag = "field"; + p: any; + r: string; + constructor(p?: any) { + this.p = p; + this.r = fmtArgs(arguments) + " " + this.tag + " p=" + this.p; + } +} + +console.log("-- static new --"); +row("Two()", () => new Two()); +row("Two(a)", () => new Two("a")); +row("Two(a,b)", () => new Two("a", "b")); +row("Two(a,b,c)", () => new Two("a", "b", "c")); +row("Two(undefined)", () => new Two(undefined)); +row("Two(a,undefined)", () => new Two("a", undefined)); +row("NoParams()", () => new NoParams()); +row("NoParams(a,b)", () => new NoParams("a", "b")); +row("WithDefault(a)", () => new WithDefault("a")); +row("WithDefault(a,b,c)", () => new WithDefault("a", "b", "c")); +row("WithRest()", () => new WithRest()); +row("WithRest(a)", () => new WithRest("a")); +row("WithRest(a,b,c)", () => new WithRest("a", "b", "c")); +row("LengthOnly()", () => new LengthOnly()); +row("LengthOnly(a)", () => new LengthOnly("a")); +row("LengthOnly(a,b,c)", () => new LengthOnly("a", "b", "c")); +row("Indexed(a)", () => new Indexed("a")); +row("Indexed(a,b,c)", () => new Indexed("a", "b", "c")); +row("WithField(a)", () => new WithField("a")); +row("WithField()", () => new WithField()); + +console.log("-- spread and Reflect.construct --"); +const none: any[] = []; +const one: any[] = ["s1"]; +const three: any[] = ["s1", "s2", "s3"]; +row("Two(...[])", () => new Two(...none)); +row("Two(...[1])", () => new Two(...one)); +row("Two(...[3])", () => new Two(...three)); +row("WithRest(...[3])", () => new WithRest(...three)); +row("Reflect.construct(Two,[])", () => Reflect.construct(Two, [])); +row("Reflect.construct(Two,[1])", () => Reflect.construct(Two, ["x"])); +row("Reflect.construct(Two,[3])", () => Reflect.construct(Two, ["x", "y", "z"])); +row("Reflect.construct(WithDefault,[1])", () => Reflect.construct(WithDefault, ["x"])); +row("Reflect.construct(LengthOnly,[1])", () => Reflect.construct(LengthOnly, ["x"])); + +console.log("-- class stored in a variable --"); +const V: any = Two; +const VD: any = WithDefault; +const VR: any = WithRest; +const VL: any = LengthOnly; +const VN: any = NoParams; +row("V()", () => new V()); +row("V(a)", () => new V("a")); +row("V(a,b,c)", () => new V("a", "b", "c")); +row("V(...[3])", () => new V(...three)); +row("VD(a)", () => new VD("a")); +row("VR(a,b,c)", () => new VR("a", "b", "c")); +row("VL(a)", () => new VL("a")); +row("VN(a,b)", () => new VN("a", "b")); +const table: Record = { Two, WithField }; +row("table.Two(a)", () => new table.Two("a")); +row("table[WithField](a)", () => new table["WithField"]("a")); + +console.log("-- class returned from / declared inside a function --"); +function makeInner() { + return class Inner { + r: string; + constructor(p?: any) { + this.r = fmtArgs(arguments); + } + }; +} +const Inner = makeInner(); +row("Inner()", () => new Inner()); +row("Inner(a)", () => new Inner("a")); +row("Inner(a,b)", () => new (Inner as any)("a", "b")); + +function makeCapturing(suffix: string) { + class Local { + r: string; + constructor(p?: any, q?: any) { + this.r = fmtArgs(arguments) + " " + suffix; + } + } + const direct = new Local("inside"); + console.log("Local(inside) static =>", direct.r); + return Local; +} +const Local: any = makeCapturing("captured"); +row("Local(a)", () => new Local("a")); +row("Local(a,b,c)", () => new Local("a", "b", "c")); + +// whatwg-url shape: class declared inside `install`, arity checked by hand. +function install(globalObject: any) { + const prefix = "Failed to construct 'URL': "; + class URL { + href: string; + constructor(url: any) { + if (arguments.length < 1) { + throw new TypeError(prefix + "1 argument required, but only " + arguments.length + " present."); + } + const args: string[] = []; + { + const curArg = arguments[0]; + args.push(String(curArg)); + } + { + const curArg = arguments[1]; + if (curArg !== undefined) args.push(String(curArg)); + } + this.href = args.join(" @ "); + } + } + globalObject.URL = URL; +} +const exportsObj: any = {}; +install(exportsObj); +row("URL(href)", () => new exportsObj.URL("http://a/")); +row("URL(href,base)", () => new exportsObj.URL("/p", "http://b/")); +row("URL()", () => new exportsObj.URL()); + +console.log("-- subclasses --"); +class SpreadArgs extends Two { + constructor(x?: any) { + super(...arguments); + } +} +class NoCtor extends Two {} +class Explicit extends Two { + d: string; + constructor(x?: any, y?: any, z?: any) { + super(x); + this.d = fmtArgs(arguments); + } +} +class RestForward extends Two { + constructor(...args: any[]) { + super(...args); + } +} +row("SpreadArgs(a)", () => new SpreadArgs("a")); +row("SpreadArgs(a,b,c)", () => new (SpreadArgs as any)("a", "b", "c")); +row("NoCtor()", () => new NoCtor()); +row("NoCtor(a)", () => new NoCtor("a")); +row("NoCtor(a,b,c)", () => new (NoCtor as any)("a", "b", "c")); +row("Explicit(a,b) base", () => new Explicit("a", "b")); +console.log("Explicit(a,b) own =>", new Explicit("a", "b").d); +row("RestForward(a)", () => new RestForward("a")); +row("RestForward(a,b,c)", () => new RestForward("a", "b", "c")); +const VS: any = SpreadArgs; +const VNC: any = NoCtor; +row("VS(a,b)", () => new VS("a", "b")); +row("VNC(a)", () => new VNC("a")); +row("VNC(a,b,c)", () => new VNC("a", "b", "c")); +const nt: any = Reflect.construct(Two, ["n1"], RestForward); +console.log("Reflect.construct(Two,[1],RestForward) =>", nt.r, nt instanceof RestForward); +class FromValue extends (V as any) { + constructor(a?: any) { + super(a, "extra"); + } +} +row("FromValue(a)", () => new FromValue("a")); + +console.log("-- imported ESM classes --"); +row("ExpTwo()", () => new ExpTwo()); +row("ExpTwo(a)", () => new ExpTwo("a")); +row("ExpTwo(a,b,c)", () => new (ExpTwo as any)("a", "b", "c")); +row("ExpDefault(a)", () => new ExpDefault("a")); +row("esm.ExpTwo(a)", () => new esm.ExpTwo("a")); +row("esm.ExpRest(a,b,c)", () => new esm.ExpRest("a", "b", "c")); +row("esm.ExpLength(a)", () => new esm.ExpLength("a")); +row("esm.ExpDerived(a)", () => new esm.ExpDerived("a")); +row("esm.ExpNoCtorDerived(a)", () => new esm.ExpNoCtorDerived("a")); +row("esm.ExpNoCtorDerived(a,b,c)", () => new (esm.ExpNoCtorDerived as any)("a", "b", "c")); +row("esm.ExpNoCtorRest(a,b,c)", () => new esm.ExpNoCtorRest("a", "b", "c")); +const ENCD: any = esm.ExpNoCtorDerived; +row("value ExpNoCtorDerived(a)", () => new ENCD("a")); +const Capturing: any = esm.makeCapturing(); +row("Capturing(a,b)", () => new Capturing("a", "b")); +const EV: any = esm.ExpTwo; +row("EV(a)", () => new EV("a")); + +console.log("-- CommonJS classes --"); +row("cjs.Request(url)", () => new cjs.Request("http://127.0.0.1:1/")); +row("cjs.Request(url,init)", () => new cjs.Request("http://127.0.0.1:1/", { method: "POST" })); +row("cjs.Request()", () => new cjs.Request()); +row("cjs.Headers()", () => new cjs.Headers()); +row("cjs.Headers(a,b)", () => new cjs.Headers("a", "b")); +row("cjs.StrictRequest(url)", () => new cjs.StrictRequest("http://x/")); +row("cjs.StrictRequest()", () => new cjs.StrictRequest()); +const applier = new cjs.Applier("p", "q"); +console.log("cjs.Applier(p,q) =>", applier.argc, JSON.stringify(applier.applied)); +const { Request: DestructuredRequest } = cjs; +row("DestructuredRequest(url)", () => new DestructuredRequest("http://d/")); + +// webidl shape declared in TypeScript. +function argumentLengthCheck({ length }: { length: number }, min: number, ctx: string) { + if (length < min) throw new TypeError(`${ctx}: ${min} argument required, but only ${length} found.`); +} +class WebRequest { + url: any; + constructor(input: any, init: any = {}) { + argumentLengthCheck(arguments, 1, "Request constructor"); + this.url = input; + } +} +row("WebRequest(url)", () => new WebRequest("http://w/")); +row("WebRequest() via value", () => new (WebRequest as any)()); + +console.log("-- function constructor controls --"); +function FnCtor(this: any, p?: any, q?: any) { + this.r = fmtArgs(arguments); +} +const FV: any = FnCtor; +row("FnCtor(a)", () => new (FnCtor as any)("a")); +row("FV()", () => new FV()); +row("FV(a,b,c)", () => new FV("a", "b", "c")); +row("Reflect.construct(FnCtor,[1])", () => Reflect.construct(FnCtor as any, ["x"])); + +console.log("-- hot loop --"); +let total = 0; +for (let i = 0; i < 1000; i++) { + total += new LengthOnly(i).n + new V(i, i).r.length + (i % 2 ? new VL() : new VL(i, i, i)).n; +} +console.log("total", total); From a27011bc4564c9f6b12e451a3cc0f469a413c31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 08:17:56 +0000 Subject: [PATCH 11/30] style: cargo fmt --- crates/perry-codegen/src/codegen/ctor_arity.rs | 5 ++++- crates/perry-codegen/src/codegen/string_pool.rs | 3 ++- crates/perry-codegen/src/lib.rs | 6 +++--- crates/perry-runtime/src/object/class_constructors.rs | 9 +++------ 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/perry-codegen/src/codegen/ctor_arity.rs b/crates/perry-codegen/src/codegen/ctor_arity.rs index f221453511..2a796a9634 100644 --- a/crates/perry-codegen/src/codegen/ctor_arity.rs +++ b/crates/perry-codegen/src/codegen/ctor_arity.rs @@ -199,7 +199,10 @@ pub(super) fn constructor_layout_params<'a>( } if let Some(ctor) = ancestor.constructor.as_ref() { let positional = ctor.params.len() == emitted_param_count as usize - && !ctor.params.iter().any(|p| p.name.starts_with("__perry_cap_")); + && !ctor + .params + .iter() + .any(|p| p.name.starts_with("__perry_cap_")); return positional.then_some(ctor.params.as_slice()); } if ancestor.extends_expr.is_some() { diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index ef7b16e68d..d6570e7a6b 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -959,7 +959,8 @@ pub(super) fn emit_string_pool( let shape_params = constructor_layout_params(class, classes, ctor_params); // #wall3: record the rest-param position (in USER params) so the runtime // bundles trailing args at the dynamic member-new dispatch path. - if let Some(rest_idx) = shape_params.and_then(|params| params.iter().position(|p| p.is_rest)) + if let Some(rest_idx) = + shape_params.and_then(|params| params.iter().position(|p| p.is_rest)) { ctor_rest_regs.push((ctor_symbol.clone(), rest_idx)); } diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 572f2c691c..1ed8982196 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -80,9 +80,9 @@ pub use codegen::{ namespace_member_class_key, namespace_member_func_key, namespace_member_var_key, resolve_target_triple, short_spread_method_capabilities, user_function_symbol, AppMetadata, CompileOptions, ConstructorContracts, CtorAbi, ExportedObjectLiteralCapability, FpContractMode, - ImportedClass, - ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, - ObjectLiteralMethodCandidate, ResolvedConstructorContracts, ShortSpreadMethodCandidate, + ImportedClass, ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, + NamespaceEntryKind, ObjectLiteralMethodCandidate, ResolvedConstructorContracts, + ShortSpreadMethodCandidate, }; pub use collectors::CjsPreambleCensus; // #9843: the segment-view for-of matcher's counter. Exported so the diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index f6d0ae2e4b..72f1bc3359 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -1465,8 +1465,7 @@ mod constructor_arg_slot_tests { crate::closure::js_register_closure_rest(ptr as *const u8, 2); let args = [11.0, 22.0, 33.0]; - let slots = - unsafe { constructor_user_arg_slots(ptr, cid, 3, args.as_ptr(), args.len()) }; + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 3, args.as_ptr(), args.len()) }; assert_eq!(slots.len(), 3); assert_eq!(slots[0], 11.0); assert_eq!(slots[1], 22.0); @@ -1502,8 +1501,7 @@ mod constructor_arg_slot_tests { crate::closure::js_register_closure_rest(ptr as *const u8, 1); let args = [11.0, 22.0, 33.0]; - let slots = - unsafe { constructor_user_arg_slots(ptr, cid, 3, args.as_ptr(), args.len()) }; + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 3, args.as_ptr(), args.len()) }; assert_eq!(slots.len(), 3); assert_eq!(slots[0], 11.0); assert_eq!(array_of(slots[1]).1, 2, "rest holds the tail only"); @@ -1518,8 +1516,7 @@ mod constructor_arg_slot_tests { crate::closure::js_register_closure_rest(ptr as *const u8, 1); let args = [11.0, 22.0, 33.0]; - let slots = - unsafe { constructor_user_arg_slots(ptr, cid, 2, args.as_ptr(), args.len()) }; + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 2, args.as_ptr(), args.len()) }; assert_eq!(slots.len(), 2); assert_eq!(slots[0], 11.0); assert_eq!(array_of(slots[1]).1, 2); From 720d1f1e152990ea60b73c16f7a69f6ea76763bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 10:52:23 +0000 Subject: [PATCH 12/30] docs(changelog): add fragment for #10612 --- changelog.d/10612-class-ctor-arguments.md | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 changelog.d/10612-class-ctor-arguments.md diff --git a/changelog.d/10612-class-ctor-arguments.md b/changelog.d/10612-class-ctor-arguments.md new file mode 100644 index 0000000000..05d22ec5ec --- /dev/null +++ b/changelog.d/10612-class-ctor-arguments.md @@ -0,0 +1,34 @@ +### Fixed: `arguments` inside a class constructor now reflects the call site (#10484) + +The `arguments` object inside a class constructor didn't match the call: constructing +through a runtime value (a class stored in a variable, returned from a function, or +imported — including every CommonJS class) saw an **empty** `arguments`, and a static +`new C(x)` reported the constructor's **declared** parameter count instead of the +number of arguments actually passed. undici's `new Request(url)` and whatwg-url's +`new URL(href)` both threw spurious "argument required" errors as a result. + +**Root cause**, three layers: HIR padded a `new`-site's argument list with `undefined` +up to the declared arity before packing `arguments`, making a caller-omitted argument +indistinguishable from a declared one; the four runtime dynamic-construct paths (super- +apply, flat-ctor replay, class-object/registered-class replay) packed the synthesized +`arguments` slot like a user `...rest` parameter (only the arguments past the declared +count, empty for the common case); and codegen read a constructor's trailing-array +layout off its last declared parameter only, which misses every capturing constructor +— i.e. every CommonJS class, since Perry adds capture params mechanically. + +**Fix.** HIR skips arity padding for a constructor that reads `arguments`. The four +runtime dynamic-construct sites now share one helper, `constructor_user_arg_slots`, +that packs `arguments` from every call argument. Codegen gains a `CtorAbi` (param +count, has-rest, has-synthetic-arguments) computed from the constructor's actual +layout instead of its last parameter, threaded through constructor-contract +resolution, imported-class metadata, and cross-module `new`-site argument marshaling. + +**Validation.** New gap test `test_gap_10484_class_constructor_arguments.ts` (with an +undici-`Request`-shaped CommonJS fixture) fails on the base commit and passes here, +byte-identical to Node. A 45-test constructor/ABI regression sweep and the full +`perry-runtime`/`perry-codegen` unit suites are clean. Static construction and +value-construction without `arguments` usage are unaffected in instruction-count +perf; value-construction with an `arguments`-reading constructor — the case the bug +was actually about — costs roughly 20% more instructions on that specific path, a +correctness-necessitated cost of materializing a real `arguments` array where the +buggy path previously built nothing. From ba0fa5f173b8765f8e68a63edd480f1c52bb5c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 08:20:25 +0000 Subject: [PATCH 13/30] fix(codegen,runtime): release a frame's variable-box cells at scope exit A let/var a closure captures and something reassigns lives in a malloc-side box cell, and every registered cell is a strong GC root (scan_box_roots_mut). Only the async-to-generator transform's terminal Stmt::ReleaseBoxes ever released one (#7933/#8208/#8303); an ordinary function, method, arrow, generator, or an async function with no await leaked one registered root per boxed binding per call, plus everything that binding last pointed at. Codegen registers every entry slot holding a cell this frame minted (stmt/boxed_frame_release.rs, new) and the existing return-site rewrite that already injects js_shadow_frame_pop now also emits js_box_scope_release before every ret; a declaration inside a loop releases the previous iteration's cell before minting the next. Runtime (box/scope_release.rs, new): a cell no closure captured publishes immediately; a captured cell is marked frame-released in its capture-edge record and published only when its last capture edge dies via the existing dead-owner pruning -- the same escape contract #8303 built for async activations. Two holders the runtime cannot count keep their cells instead of double-releasing: a sloppy-mode mapped arguments object, and a plain-async step closure's own activation cells; a step closure's capture of an enclosing frame's cell is now counted, since the activation token never covered it. Fixes a latent GC hole shared with #8303 in the same commit: a full trace that stops rooting a released cell must still keep it in BOX_YOUNG_ROOTS while young, because a minor walks only that log -- dropping it left the next minor with no root for a payload a live closure still reads. --- crates/perry-codegen/src/codegen/arguments.rs | 30 ++ crates/perry-codegen/src/codegen/closure.rs | 1 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 6 + .../src/codegen/method_static.rs | 6 + crates/perry-codegen/src/expr/closure.rs | 150 +++++--- crates/perry-codegen/src/function.rs | 50 ++- crates/perry-codegen/src/gc_call_effects.rs | 11 +- .../src/lower_call/new_ctor_args.rs | 17 +- .../src/runtime_decls/strings.rs | 4 + .../src/stmt/boxed_frame_release.rs | 65 ++++ .../src/stmt/boxed_frame_release_tests.rs | 293 +++++++++++++++ .../src/stmt/boxed_local_init.rs | 26 +- crates/perry-codegen/src/stmt/let_stmt.rs | 16 +- crates/perry-codegen/src/stmt/mod.rs | 62 ++-- crates/perry-runtime/src/box.rs | 92 +++-- crates/perry-runtime/src/box/scope_release.rs | 346 ++++++++++++++++++ .../perry-runtime/src/closure/box_captures.rs | 182 +++++++-- crates/perry-runtime/src/closure/mod.rs | 3 +- scripts/gc_root_dominance_check.py | 25 ++ test-files/test_gap_10464_box_cell_release.ts | 305 +++++++++++++++ 21 files changed, 1514 insertions(+), 177 deletions(-) create mode 100644 crates/perry-codegen/src/stmt/boxed_frame_release.rs create mode 100644 crates/perry-codegen/src/stmt/boxed_frame_release_tests.rs create mode 100644 crates/perry-runtime/src/box/scope_release.rs create mode 100644 test-files/test_gap_10464_box_cell_release.ts diff --git a/crates/perry-codegen/src/codegen/arguments.rs b/crates/perry-codegen/src/codegen/arguments.rs index 231a58fde4..ba72301e88 100644 --- a/crates/perry-codegen/src/codegen/arguments.rs +++ b/crates/perry-codegen/src/codegen/arguments.rs @@ -50,6 +50,34 @@ pub(crate) fn store_param_slot( slot } +/// #10464: a boxed parameter's cell is minted by this frame's entry block +/// (`store_param_slot`), so the frame releases it before every `ret`. +/// `materialize_arguments_object` withdraws a slot it maps into a sloppy-mode +/// `arguments` object, which holds the raw cell without a counted edge. +pub(crate) fn release_boxed_param_slots_at_exit( + lf: &mut crate::function::LlFunction, + params: &[Param], + boxed_vars: &HashSet, + slots: &std::collections::HashMap, +) { + for p in params { + if !boxed_vars.contains(&p.id) || p.arguments_object.is_some() { + continue; + } + if let Some(slot) = slots.get(&p.id) { + lf.add_pre_return_box_release(slot, "js_box_scope_release"); + } + } +} + +/// The parameter ids a synthesized `arguments` object aliases. +pub(crate) fn mapped_parameter_ids(params: &[Param]) -> HashSet { + mapped_arguments_params(params) + .into_iter() + .map(|(_, id)| id) + .collect() +} + pub(crate) fn materialize_arguments_object( ctx: &mut FnCtx<'_>, params: &[Param], @@ -110,6 +138,8 @@ pub(crate) fn materialize_arguments_object( ); for (arg_index, param_id) in mapped_arguments_params(params) { if let Some(param_slot) = ctx.locals.get(¶m_id).cloned() { + // #10464: the object aliases the cell for its own lifetime. + ctx.func.forget_pre_return_box_release(¶m_slot); let box_ptr = ctx.block().load(I64, ¶m_slot); ctx.block().call_void( "js_arguments_object_map_index", diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 2772017112..8ce9a39ab6 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -639,6 +639,7 @@ pub(super) fn compile_closure( } map }; + super::arguments::release_boxed_param_slots_at_exit(lf, params, &closure_boxed_vars, &locals); // Start with the closure's own params as local_types, then // merge in the module-wide map so captured-from-outer ids have diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index f4522072b9..cbc2d25c47 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -863,6 +863,7 @@ pub(super) fn compile_function( } map }; + super::arguments::release_boxed_param_slots_at_exit(lf, &f.params, &boxed_vars, &locals); // Param types feed local_types so type-aware dispatch (e.g. string // concat detection on a `: string` parameter) works inside the body. diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 17efc82af9..0e77806ec2 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -384,6 +384,12 @@ pub(super) fn compile_method( } (this_slot, map) }; + super::arguments::release_boxed_param_slots_at_exit( + lf, + &method.params, + &method_boxed_vars, + &locals, + ); let mut local_types: HashMap = module_global_types .iter() diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index f33abb28f3..eef81c0da4 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -122,6 +122,12 @@ pub(in crate::codegen) fn compile_static_method( } (this_slot, map) }; + crate::codegen::arguments::release_boxed_param_slots_at_exit( + lf, + &f.params, + &static_boxed_vars, + &locals, + ); // Seed with module-global declared types (mirrors compile_method / // compile_function): static-method bodies read module globals through diff --git a/crates/perry-codegen/src/expr/closure.rs b/crates/perry-codegen/src/expr/closure.rs index f53c588aef..7abb8bb0a8 100644 --- a/crates/perry-codegen/src/expr/closure.rs +++ b/crates/perry-codegen/src/expr/closure.rs @@ -12,57 +12,76 @@ use crate::types::{DOUBLE, I32, I64, PTR}; use super::{lower_expr, nanbox_pointer_inline, FnCtx}; -/// Whether this is the compiler-private step closure for a lowered plain -/// async activation. `ReleaseBoxes` is emitted only in that closure's -/// terminal arms; user-authored closures can never contain it. +/// The activation cells of the compiler-private step closure for a lowered +/// plain async activation: every id its terminal `ReleaseBoxes` arms name, or +/// `None` for any other closure. `ReleaseBoxes` is emitted only in that +/// closure's terminal arms; user-authored closures can never contain it. /// /// Queued and running instances of this closure are already covered by the -/// activation token's refcount. Counting its boxed capture slots as escaping +/// activation token's refcount. Counting those boxed capture slots as escaping /// GC-closure edges would make every cell in the complete activation frame -/// wait for a full collection, even when no user closure can observe it. -fn is_plain_async_step_body(stmts: &[Stmt]) -> bool { - stmts.iter().any(|stmt| match stmt { - Stmt::ReleaseBoxes(_) => true, - Stmt::If { - then_branch, - else_branch, - .. - } => { - is_plain_async_step_body(then_branch) - || else_branch.as_deref().is_some_and(is_plain_async_step_body) - } - Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => is_plain_async_step_body(body), - Stmt::For { init, body, .. } => { - init.as_deref() - .is_some_and(|stmt| is_plain_async_step_body(std::slice::from_ref(stmt))) - || is_plain_async_step_body(body) - } - Stmt::Try { - body, - catch, - finally, - } => { - is_plain_async_step_body(body) - || catch - .as_ref() - .is_some_and(|catch| is_plain_async_step_body(&catch.body)) - || finally.as_deref().is_some_and(is_plain_async_step_body) +/// wait for a full collection, even when no user closure can observe it. A +/// cell from an ENCLOSING scope is not covered by that token (#10464: its +/// owner frame now releases it at scope exit), so only these ids go uncounted. +fn plain_async_step_release_ids(stmts: &[Stmt]) -> Option> { + fn walk(stmts: &[Stmt], out: &mut Option>) { + for stmt in stmts { + match stmt { + Stmt::ReleaseBoxes(ids) => out + .get_or_insert_with(Default::default) + .extend(ids.iter().copied()), + Stmt::If { + then_branch, + else_branch, + .. + } => { + walk(then_branch, out); + if let Some(else_branch) = else_branch { + walk(else_branch, out); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => walk(body, out), + Stmt::For { init, body, .. } => { + if let Some(init) = init { + walk(std::slice::from_ref(init.as_ref()), out); + } + walk(body, out); + } + Stmt::Try { + body, + catch, + finally, + } => { + walk(body, out); + if let Some(catch) = catch { + walk(&catch.body, out); + } + if let Some(finally) = finally { + walk(finally, out); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + walk(&case.body, out); + } + } + Stmt::Labeled { body, .. } => walk(std::slice::from_ref(body.as_ref()), out), + Stmt::Let { .. } + | Stmt::Expr(_) + | Stmt::Return(_) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::Throw(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + } } - Stmt::Switch { cases, .. } => cases - .iter() - .any(|case| is_plain_async_step_body(&case.body)), - Stmt::Labeled { body, .. } => is_plain_async_step_body(std::slice::from_ref(body.as_ref())), - Stmt::Let { .. } - | Stmt::Expr(_) - | Stmt::Return(_) - | Stmt::Break - | Stmt::Continue - | Stmt::LabeledBreak(_) - | Stmt::LabeledContinue(_) - | Stmt::Throw(_) - | Stmt::PreallocateBoxes(_) - | Stmt::PreallocateTdzBoxes(_) => false, - }) + } + let mut out = None; + walk(stmts, &mut out); + out } pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { @@ -130,6 +149,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // closure body can deref it via js_box_get/set. Without // this, each closure would get a snapshot of the box's // current value. + let plain_async_step_cells = plain_async_step_release_ids(body); + let uncounted_box_capture = |cap_id: &u32| { + plain_async_step_cells + .as_ref() + .is_some_and(|cells| cells.contains(cap_id)) + }; let mut captured_value_bits: Vec = Vec::with_capacity(auto_captures.len()); for cap_id in &auto_captures { if ctx.boxed_vars.contains(cap_id) { @@ -156,6 +181,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if let Some(slot) = ctx.locals.get(cap_id).cloned() { // Enclosing function owns the box: slot holds // the raw box pointer as i64. + if uncounted_box_capture(cap_id) { + // #10464: the activation, not this frame, owns + // the cell's lifetime from here on. + ctx.func.forget_pre_return_box_release(&slot); + } let box_ptr = ctx.block().load(I64, &slot); captured_value_bits.push(box_ptr); } else if let Some(global_name) = ctx.module_globals.get(cap_id).cloned() { @@ -297,13 +327,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // // The singleton caches therefore only serve closures the // COMPILER synthesized: the async-activation step closures - // recognized by `is_plain_async_step_body` (their terminal + // recognized by `plain_async_step_release_ids` (their terminal // `ReleaseBoxes` arms cannot appear in user code, and their // identity never escapes the runtime's promise machinery). // Those are the closures the caches were built for — re-created // per resume with the same per-activation box captures. User // arrows and function expressions always mint fresh objects. - let is_plain_async_step = is_plain_async_step_body(body); + let is_plain_async_step = plain_async_step_cells.is_some(); let singleton_identity_safe = is_plain_async_step && (*is_arrow || captures_all_boxed); let no_capture_singleton = is_plain_async_step && *is_arrow && total_caps == 0; let captured_singleton = @@ -347,9 +377,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { && !captured_singleton && total_caps > 0 && !captured_value_bits.is_empty() - && auto_captures - .iter() - .all(|cap_id| is_plain_async_step || !ctx.boxed_vars.contains(cap_id)); + && auto_captures.iter().all(|cap_id| { + !ctx.boxed_vars.contains(cap_id) || uncounted_box_capture(cap_id) + }); let closure_handle = if no_capture_singleton { let blk = ctx.block(); blk.call(I64, "js_closure_alloc_singleton", &[(PTR, &func_ref)]) @@ -435,17 +465,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // their lifetime edges are declared; fresh closures need every // slot initialized here. The compiler-private plain-async step // closure is different: its activation refcount already covers - // every queued/running instance, so declaring its whole boxed - // frame as escaped would delay every terminal cell until a full - // GC. User closures nested inside it still take the dedicated - // setter and therefore preserve #8213's escaped-cell lifetime. - let boxed_capture_slots = auto_captures + // every queued/running instance of its OWN cells, so declaring its + // whole boxed frame as escaped would delay every terminal cell + // until a full GC. User closures nested inside it still take the + // dedicated setter and therefore preserve #8213's escaped-cell + // lifetime, and so does a step closure's capture of an enclosing + // scope's cell (#10464). + let tracked_box_capture_slots = auto_captures .iter() - .map(|cap_id| ctx.boxed_vars.contains(cap_id)) + .map(|cap_id| ctx.boxed_vars.contains(cap_id) && !uncounted_box_capture(cap_id)) .collect::>(); let blk = ctx.block(); for (idx, val_bits) in captured_value_bits.iter().enumerate() { - let track_box_capture = boxed_capture_slots[idx] && !is_plain_async_step; + let track_box_capture = tracked_box_capture_slots[idx]; if bulk_fresh_init { // Every slot was written by `js_closure_alloc_init`. continue; diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index dff3fb48e2..641036385a 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -178,6 +178,14 @@ pub struct LlFunction { /// Entry/module-init functions use this for process-level diagnostics /// that must run regardless of which block reaches the normal epilogue. pre_return_void_calls: Vec, + /// #10464: entry-alloca slots holding a variable-box cell this frame + /// minted, paired with the kind's `js_*box_scope_release`. Each `ret` + /// hands the slot's current cell back to the runtime (a no-op for a slot + /// still holding its TAG_UNDEFINED entry sentinel). + pre_return_box_releases: Vec<(String, &'static str)>, + /// Slots withdrawn by [`Self::forget_pre_return_box_release`]; a later + /// registration of the same slot stays withdrawn. + withheld_box_release_slots: Vec, } /// Render the frame-push instruction. Kept in one place so the eager @@ -282,6 +290,8 @@ impl LlFunction { stack_map_slot_count: 0, force_shadow_frame: false, pre_return_void_calls: Vec::new(), + pre_return_box_releases: Vec::new(), + withheld_box_release_slots: Vec::new(), } } @@ -497,6 +507,29 @@ impl LlFunction { self.pre_return_void_calls.push(func_name.into()); } + /// #10464: release the variable-box cell held by `slot` before every + /// `ret`. `slot` must be an entry-block alloca whose value is a box + /// pointer or TAG_UNDEFINED on every path. Idempotent per slot. + pub fn add_pre_return_box_release(&mut self, slot: &str, release_fn: &'static str) { + if !self.pre_return_box_releases.iter().any(|(s, _)| s == slot) + && !self.withheld_box_release_slots.iter().any(|s| s == slot) + { + self.pre_return_box_releases + .push((slot.to_string(), release_fn)); + } + } + + /// Withdraw a slot registered by [`Self::add_pre_return_box_release`] + /// because a holder the runtime does not count (a mapped `arguments` + /// object, a plain-async step closure) received its cell. Sticky: the + /// slot is never released by this frame afterwards. + pub fn forget_pre_return_box_release(&mut self, slot: &str) { + self.pre_return_box_releases.retain(|(s, _)| s != slot); + if !self.withheld_box_release_slots.iter().any(|s| s == slot) { + self.withheld_box_release_slots.push(slot.to_string()); + } + } + /// Invoke-EH (#7302): enter/leave a handler scope. While a scope is /// active, every potentially-throwing call any block of this function /// emits carries an unwind edge to the scope's landing-pad label. @@ -1092,8 +1125,9 @@ impl LlFunction { &self, sink: &mut dyn FnMut(FinalItem<'_>) -> Result<(), E>, ) -> Result<(), E> { - let rewrite_rets = - self.shadow_frame_slot.is_some() || !self.pre_return_void_calls.is_empty(); + let rewrite_rets = self.shadow_frame_slot.is_some() + || !self.pre_return_void_calls.is_empty() + || !self.pre_return_box_releases.is_empty(); let mut seq: u32 = 0; for (i, blk) in self.blocks.iter().enumerate() { if i > 0 { @@ -1203,6 +1237,18 @@ impl LlFunction { for func_name in &self.pre_return_void_calls { sink(FinalItem::Text(&format!(" call void @{}()", func_name)))?; } + for (slot, release_fn) in &self.pre_return_box_releases { + let load_reg = format!("%box_release_l_{}", seq); + *seq += 1; + sink(FinalItem::Text(&format!( + " {} = load i64, ptr {}", + load_reg, slot + )))?; + sink(FinalItem::Text(&format!( + " call void @{}(i64 {})", + release_fn, load_reg + )))?; + } if let Some(handle_slot) = &self.shadow_frame_slot { let load_reg = format!("%shadow_pop_l_{}", seq); *seq += 1; diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 265da914e7..6be9a6379a 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -258,7 +258,13 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { // collection trigger — the same audit as the accessors above. | "js_box_release" | "js_i32_box_release" - | "js_bool_box_release" => GcCallEffect::CannotCollect, + | "js_bool_box_release" + // #10464 scope-exit release: a registry probe, a capture-count + // lookup, then either the same publish (registry remove, cache evict, + // raw clear, TLS free-list push) or a TLS pending-map insert. + | "js_box_scope_release" + | "js_i32_box_scope_release" + | "js_bool_box_scope_release" => GcCallEffect::CannotCollect, // Audited allocate-but-never-reenter helpers (2026-07-31): each body // was checked for closure invocation, coercion (valueOf/toString), // and accessor dispatch — none present, and none takes a receiver @@ -835,6 +841,9 @@ mod tests { "js_box_release", "js_i32_box_release", "js_bool_box_release", + "js_box_scope_release", + "js_i32_box_scope_release", + "js_bool_box_scope_release", ] { assert_eq!( classify_direct_callee(name), diff --git a/crates/perry-codegen/src/lower_call/new_ctor_args.rs b/crates/perry-codegen/src/lower_call/new_ctor_args.rs index 24b72fbc5a..d50e73cf41 100644 --- a/crates/perry-codegen/src/lower_call/new_ctor_args.rs +++ b/crates/perry-codegen/src/lower_call/new_ctor_args.rs @@ -75,6 +75,7 @@ pub(crate) fn bind_inline_constructor_params( .collect(); crate::codegen::arguments::add_arguments_mapped_boxes(params, &mut ctx.boxed_vars); + let mapped_param_ids = crate::codegen::arguments::mapped_parameter_ids(params); let values = inline_constructor_param_values_with_class(ctx, params, lowered_args, capture_fill); for ((param, arg_val), proof) in params @@ -86,7 +87,21 @@ pub(crate) fn bind_inline_constructor_params( let slot = ctx .func .alloca_entry(if boxed_param { I64 } else { DOUBLE }); - if boxed_param { + if boxed_param && !mapped_param_ids.contains(¶m.id) { + // #10464: this frame mints the cell (again per iteration when the + // `new` sits in a loop), so it also releases it. + let arg_bits = ctx.block().bitcast_double_to_i64(arg_val); + ctx.func + .entry_allocas_push_store(I64, crate::nanbox::TAG_UNDEFINED_I64, &slot); + use crate::stmt::boxed_frame_release as frame_release; + frame_release::mint_frame_cell( + ctx, + &slot, + "js_box_alloc_bits", + &[(I64, &arg_bits)], + frame_release::JS_BOX_SCOPE_RELEASE, + ); + } else if boxed_param { let arg_bits = ctx.block().bitcast_double_to_i64(arg_val); let box_ptr = ctx .block() diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index a73cd55079..3988b944c4 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1117,6 +1117,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_box_release", VOID, &[I64]); module.declare_function("js_i32_box_release", VOID, &[I64]); module.declare_function("js_bool_box_release", VOID, &[I64]); + // #10464: frame-exit release of cells an ordinary frame minted. + module.declare_function("js_box_scope_release", VOID, &[I64]); + module.declare_function("js_i32_box_scope_release", VOID, &[I64]); + module.declare_function("js_bool_box_scope_release", VOID, &[I64]); module.declare_function("js_bool_box_alloc", I64, &[I32]); module.declare_function("js_bool_box_get", I32, &[I64]); module.declare_function("js_bool_box_set", VOID, &[I64, I32]); diff --git a/crates/perry-codegen/src/stmt/boxed_frame_release.rs b/crates/perry-codegen/src/stmt/boxed_frame_release.rs new file mode 100644 index 0000000000..f01aef30a0 --- /dev/null +++ b/crates/perry-codegen/src/stmt/boxed_frame_release.rs @@ -0,0 +1,65 @@ +//! #10464: hand a frame's variable-box cells back to the runtime when the +//! frame can no longer name them. +//! +//! A boxed local's entry alloca holds the cell this frame minted (or its +//! TAG_UNDEFINED entry sentinel). Two points end the frame's hold on that +//! cell: every `ret` (registered here, emitted by the `LlFunction` return-site +//! rewrite so no return path can be missed), and a declaration inside a loop +//! minting the next iteration's cell into the same slot. The runtime +//! (`perry_runtime::r#box::scope_release`) publishes a cell nothing else +//! captured and leaves a closure-captured cell to its closures' GC death. +//! +//! Only holders the runtime counts may keep a released cell alive. The two +//! compiler-emitted holders it does not count withdraw the slot instead: +//! a mapped sloppy-mode `arguments` object (`codegen/arguments.rs`) and a +//! plain-async step closure's own activation cells (`expr/closure.rs`). + +use crate::expr::FnCtx; +use crate::types::{LlvmType, I64}; + +pub(crate) const JS_BOX_SCOPE_RELEASE: &str = "js_box_scope_release"; +pub(crate) const I32_BOX_SCOPE_RELEASE: &str = "js_i32_box_scope_release"; +pub(crate) const BOOL_BOX_SCOPE_RELEASE: &str = "js_bool_box_scope_release"; + +/// Release `slot`'s cell before every `ret` of the current function. +pub(crate) fn release_at_frame_exit(ctx: &mut FnCtx<'_>, slot: &str, release_fn: &'static str) { + ctx.func.add_pre_return_box_release(slot, release_fn); +} + +/// A declaration about to mint a fresh cell into `slot` re-executes only in a +/// loop; the previous iteration's cell is then unnameable by this frame. +/// Outside a loop the slot still holds its entry sentinel, so nothing is +/// emitted. `switch` frames push an empty continue label and do not count. +pub(crate) fn release_previous_iteration_cell( + ctx: &mut FnCtx<'_>, + slot: &str, + release_fn: &'static str, +) { + let in_loop = ctx + .loop_targets + .iter() + .any(|(continue_label, _, _)| !continue_label.is_empty()); + if !in_loop { + return; + } + let previous = ctx.block().load(I64, slot); + ctx.block().call_void(release_fn, &[(I64, &previous)]); +} + +/// Store a freshly minted cell (`alloc_fn(args)`) into this frame's entry +/// `slot`: release the previous iteration's cell first (so an uncaptured one +/// is reused by this very allocation), and release the slot at frame exit. +/// Returns the new cell pointer. +pub(crate) fn mint_frame_cell( + ctx: &mut FnCtx<'_>, + slot: &str, + alloc_fn: &str, + args: &[(LlvmType, &str)], + release_fn: &'static str, +) -> String { + release_previous_iteration_cell(ctx, slot, release_fn); + let cell = ctx.block().call(I64, alloc_fn, args); + ctx.block().store(I64, &cell, slot); + release_at_frame_exit(ctx, slot, release_fn); + cell +} diff --git a/crates/perry-codegen/src/stmt/boxed_frame_release_tests.rs b/crates/perry-codegen/src/stmt/boxed_frame_release_tests.rs new file mode 100644 index 0000000000..08f10d66ba --- /dev/null +++ b/crates/perry-codegen/src/stmt/boxed_frame_release_tests.rs @@ -0,0 +1,293 @@ +//! #10464: an ordinary frame releases the box cells it minted. +//! +//! Each fixture pairs the released shape with the holder the runtime cannot +//! count, so the assertions discriminate in both directions: dropping the +//! frame release loses the positive assertions, releasing too much trips the +//! negative ones. + +use perry_hir::types::Type; +use perry_hir::{ArgumentsObjectMeta, Expr, Function, Module, Param, Stmt}; + +const RELEASE: &str = "call void @js_box_scope_release(i64 "; + +fn param(id: u32, name: &str) -> Param { + Param { + id, + name: name.into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn closure(func_id: u32, body: Vec, captures: Vec) -> Expr { + Expr::Closure { + func_id, + params: Vec::new(), + return_type: Type::Any, + body, + mutable_captures: captures.clone(), + captures, + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + } +} + +fn let_stmt(id: u32, init: Expr) -> Stmt { + Stmt::Let { + id, + name: format!("v{id}"), + ty: Type::Any, + mutable: true, + init: Some(init), + } +} + +fn function(name: &str, params: Vec, body: Vec) -> Function { + Function { + id: 1, + name: name.into(), + type_params: Vec::new(), + params, + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn function_ir(f: Function) -> String { + let mut module = Module::new("boxed_frame_release.ts"); + let needle = format!("__{}(", f.name); + module.functions.push(f); + let ir = String::from_utf8( + crate::compile_module(&module, super::prealloc_module_global_tests::ir_opts()).unwrap(), + ) + .unwrap(); + ir.split("\ndefine ") + .find(|block| block.lines().next().is_some_and(|l| l.contains(&needle))) + .unwrap_or_else(|| panic!("no define for {needle}:\n{ir}")) + .to_string() +} + +/// Every `ret` of the frame is preceded by one release per slot it names. +fn releases_before_each_ret(ir: &str) -> Vec { + let lines: Vec<&str> = ir.lines().collect(); + let mut counts = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if line.trim_start().starts_with("ret ") { + let mut n = 0; + for prev in lines[..i].iter().rev() { + let t = prev.trim_start(); + if t.starts_with("call void @js_shadow_frame_pop") || t.contains("= load i64, ptr") + { + continue; + } + if t.starts_with(RELEASE) { + n += 1; + continue; + } + break; + } + counts.push(n); + } + } + counts +} + +#[test] +fn captured_reassigned_let_is_released_at_every_return() { + // function counter(flag) { let n = 0; const inc = () => { n = 1 }; + // if (flag) return inc; return n; } + let body = vec![ + let_stmt(10, Expr::Integer(0)), + let_stmt( + 11, + closure( + 2, + vec![Stmt::Expr(Expr::LocalSet(10, Box::new(Expr::Integer(1))))], + vec![10], + ), + ), + Stmt::If { + condition: Expr::LocalGet(1), + then_branch: vec![Stmt::Return(Some(Expr::LocalGet(11)))], + else_branch: None, + }, + Stmt::Return(Some(Expr::LocalGet(10))), + ]; + let ir = function_ir(function("counter", vec![param(1, "flag")], body)); + assert!( + ir.contains("call i64 @js_box_alloc_bits("), + "premise: n is boxed\n{ir}" + ); + assert!( + ir.contains("js_closure_set_box_capture_ptr"), + "premise: counted edge\n{ir}" + ); + let per_ret = releases_before_each_ret(&ir); + assert!(per_ret.len() >= 2, "both returns must be lowered:\n{ir}"); + assert!( + per_ret.iter().all(|n| *n == 1), + "each return releases the single frame-owned cell ({per_ret:?}):\n{ir}" + ); + // Outside a loop the declaration runs once: no previous-iteration release. + let alloc = ir.find("call i64 @js_box_alloc_bits(").unwrap(); + assert!( + !ir[..alloc].contains(RELEASE), + "no release before a one-shot mint:\n{ir}" + ); +} + +#[test] +fn loop_declaration_releases_the_previous_iterations_cell_before_minting() { + // while (flag) { let x = 0; keep = () => { x = 1 }; } + let body = vec![ + Stmt::While { + condition: Expr::LocalGet(1), + body: vec![ + let_stmt(20, Expr::Integer(0)), + let_stmt( + 21, + closure( + 3, + vec![Stmt::Expr(Expr::LocalSet(20, Box::new(Expr::Integer(1))))], + vec![20], + ), + ), + ], + }, + Stmt::Return(Some(Expr::Undefined)), + ]; + let ir = function_ir(function("looped", vec![param(1, "flag")], body)); + let lines: Vec<&str> = ir.lines().map(str::trim_start).collect(); + let alloc = lines + .iter() + .position(|l| l.contains("call i64 @js_box_alloc_bits(")) + .expect("premise: x is boxed"); + let cell = lines[alloc].split(" = ").next().unwrap(); + let slot = lines[alloc..] + .iter() + .find_map(|l| l.strip_prefix(&format!("store i64 {cell}, ptr "))) + .expect("the minted cell is stored in its slot"); + let previous = lines[..alloc] + .iter() + .rev() + .take(4) + .find_map(|l| l.strip_suffix(&format!(" = load i64, ptr {slot}"))) + .unwrap_or_else(|| panic!("the slot's previous cell is loaded before minting:\n{ir}")); + assert!( + lines[..alloc] + .iter() + .rev() + .take(4) + .any(|l| l.starts_with(&format!("{RELEASE}{previous})"))), + "and released before the next iteration's cell is minted:\n{ir}" + ); + assert!( + releases_before_each_ret(&ir).iter().all(|n| *n == 1), + "{ir}" + ); +} + +#[test] +fn mapped_arguments_parameter_is_never_released_by_its_frame() { + // Sloppy `function f(a, b) { const g = () => { a = 1; b = 2 }; return arguments; }` + // with only `a` mapped: the Arguments object aliases a's cell raw. + let mut arguments = param(3, "arguments"); + arguments.arguments_object = Some(ArgumentsObjectMeta { + strict: false, + simple_parameters: true, + mapped_parameter_ids: vec![(0, 1)], + restricted_callee: false, + }); + let body = vec![ + let_stmt( + 30, + closure( + 4, + vec![ + Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Integer(1)))), + Stmt::Expr(Expr::LocalSet(2, Box::new(Expr::Integer(2)))), + ], + vec![1, 2], + ), + ), + Stmt::Return(Some(Expr::LocalGet(3))), + ]; + let mut f = function( + "sloppy", + vec![param(1, "a"), param(2, "b"), arguments], + body, + ); + f.is_strict = false; + let ir = function_ir(f); + assert!( + ir.contains("js_arguments_object_map_index"), + "premise: a is mapped\n{ir}" + ); + assert_eq!( + ir.matches("call i64 @js_box_alloc_bits(").count(), + 2, + "premise: both parameters are boxed:\n{ir}" + ); + let per_ret = releases_before_each_ret(&ir); + assert!( + !per_ret.is_empty() && per_ret.iter().all(|n| *n == 1), + "only the unmapped parameter is released ({per_ret:?}):\n{ir}" + ); +} + +#[test] +fn plain_async_step_counts_only_enclosing_cells_and_frame_keeps_its_own() { + // An activation frame: OWN is named by the step closure's terminal + // `ReleaseBoxes`, OUTER is an ordinary captured-and-reassigned local. + const OWN: u32 = 40; + const OUTER: u32 = 41; + let step = closure( + 5, + vec![ + Stmt::Expr(Expr::LocalSet(OUTER, Box::new(Expr::Integer(1)))), + Stmt::Expr(Expr::LocalSet(OWN, Box::new(Expr::Integer(2)))), + Stmt::ReleaseBoxes(vec![OWN]), + Stmt::Return(Some(Expr::Undefined)), + ], + vec![OWN, OUTER], + ); + let body = vec![ + Stmt::PreallocateBoxes(vec![OWN]), + let_stmt(OUTER, Expr::Integer(0)), + let_stmt(42, step), + Stmt::Return(Some(Expr::LocalGet(42))), + ]; + let ir = function_ir(function("activation", Vec::new(), body)); + assert_eq!( + ir.matches("call i64 @js_box_alloc_bits(").count(), + 2, + "premise: both cells are minted by this frame:\n{ir}" + ); + assert_eq!( + ir.matches("call void @js_closure_set_box_capture_ptr(") + .count(), + 1, + "the enclosing cell is a counted edge, the activation's own is not:\n{ir}" + ); + let per_ret = releases_before_each_ret(&ir); + assert!( + !per_ret.is_empty() && per_ret.iter().all(|n| *n == 1), + "the frame releases OUTER only; OWN belongs to the activation ({per_ret:?}):\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/stmt/boxed_local_init.rs b/crates/perry-codegen/src/stmt/boxed_local_init.rs index 418f7f2377..9fd2dc5d8d 100644 --- a/crates/perry-codegen/src/stmt/boxed_local_init.rs +++ b/crates/perry-codegen/src/stmt/boxed_local_init.rs @@ -24,18 +24,30 @@ pub(super) fn ensure_reused_box_is_initialized(ctx: &mut FnCtx<'_>, id: u32) { let ready_label = ctx.block_label(ready); ctx.block().cond_br(&missing, &allocate_label, &ready_label); ctx.current_block = allocate; - let cell = if crate::expr::is_compiler_private_async_i32_control_local(ctx, id) { - ctx.block().call(I64, "js_i32_box_alloc", &[(I32, "0")]) + use super::boxed_frame_release as frame_release; + let (cell, release_fn) = if crate::expr::is_compiler_private_async_i32_control_local(ctx, id) { + ( + ctx.block().call(I64, "js_i32_box_alloc", &[(I32, "0")]), + frame_release::I32_BOX_SCOPE_RELEASE, + ) } else if crate::expr::is_compiler_private_async_i1_control_local(ctx, id) { - ctx.block().call(I64, "js_bool_box_alloc", &[(I32, "0")]) + ( + ctx.block().call(I64, "js_bool_box_alloc", &[(I32, "0")]), + frame_release::BOOL_BOX_SCOPE_RELEASE, + ) } else { - ctx.block().call( - I64, - "js_box_alloc_bits", - &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], + ( + ctx.block().call( + I64, + "js_box_alloc_bits", + &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], + ), + frame_release::JS_BOX_SCOPE_RELEASE, ) }; ctx.block().store(I64, &cell, &slot); + // #10464: the cell is this frame's (a withdrawn slot stays withdrawn). + frame_release::release_at_frame_exit(ctx, &slot, release_fn); super::record_boxed_slot_js_value_bits(ctx, id, &cell, "boxed_let.reused_missing_box"); ctx.block().br(&ready_label); ctx.current_block = ready; diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 251f4bbd8a..f360f0a353 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1242,13 +1242,6 @@ pub(crate) fn lower_let( } return Ok(()); } - // Step 1: allocate box with undefined sentinel bits. - let blk = ctx.block(); - let box_ptr = blk.call( - crate::types::I64, - "js_box_alloc_bits", - &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], - ); // Slot must live in the entry block — closures from sibling // branches may capture this id later, and an alloca placed // here would not dominate those branches' loads. @@ -1267,7 +1260,14 @@ pub(crate) fn lower_let( // deterministically. let undef_bits = crate::nanbox::TAG_UNDEFINED_I64.to_string(); ctx.func.entry_allocas_push_store(I64, &undef_bits, &slot); - ctx.block().store(I64, &box_ptr, &slot); + // Step 1: allocate the box (#10464: released by this frame). + let box_ptr = super::boxed_frame_release::mint_frame_cell( + ctx, + &slot, + "js_box_alloc_bits", + &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], + super::boxed_frame_release::JS_BOX_SCOPE_RELEASE, + ); super::record_boxed_slot_js_value_bits(ctx, id, &box_ptr, "boxed_let.box_ptr_slot"); // Step 2: register BEFORE lowering init. ctx.locals.insert(id, slot); diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index a6ca584e6a..4846931d95 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -13,6 +13,9 @@ use crate::types::DOUBLE; #[cfg(test)] mod boxed_continuation_tests; +pub(crate) mod boxed_frame_release; +#[cfg(test)] +mod boxed_frame_release_tests; mod boxed_local_init; #[cfg(test)] mod boxed_slot_no_root_tests; @@ -665,6 +668,14 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { } fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result<()> { + // #10464: a generator/async activation frame's list names its + // compiler-private control cells. That list runs once per frame, and a + // plain-async step closure holds its cells without a counted capture edge, + // so it never releases a "previous iteration" cell. + let activation_frame = ids.iter().any(|id| { + ctx.compiler_private_async_i32_control_locals.contains(id) + || ctx.compiler_private_async_i1_control_locals.contains(id) + }); for id in ids { // #7521: a module-level binding promoted to `@perry_global___` // ALREADY has the shared, forward-visible, GC-rooted cell a prealloc box @@ -701,41 +712,35 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result } let is_i32_control = crate::expr::is_compiler_private_async_i32_control_local(ctx, *id); let is_i1_control = crate::expr::is_compiler_private_async_i1_control_local(ctx, *id); - let blk = ctx.block(); - let (box_ptr, cell_note) = if is_i32_control { + // Seed the JSValue box with TAG_TDZ (Temporal Dead Zone) when + // requested -- a read before the declaration runs throws a spec + // ReferenceError via the runtime `js_box_get_bits` choke point. + // Compiler-private i32/i1 control cells are never TDZ. + let seed_bits = if tdz { + crate::nanbox::TAG_TDZ_I64.to_string() + } else { + crate::nanbox::TAG_UNDEFINED_I64.to_string() + }; + use boxed_frame_release as frame_release; + let (alloc_fn, alloc_arg, release_fn, cell_note) = if is_i32_control { ( - blk.call( - crate::types::I64, - "js_i32_box_alloc", - &[(crate::types::I32, "0")], - ), + "js_i32_box_alloc", + (crate::types::I32, "0"), + frame_release::I32_BOX_SCOPE_RELEASE, "primitive_i32_control_cell", ) } else if is_i1_control { ( - blk.call( - crate::types::I64, - "js_bool_box_alloc", - &[(crate::types::I32, "0")], - ), + "js_bool_box_alloc", + (crate::types::I32, "0"), + frame_release::BOOL_BOX_SCOPE_RELEASE, "primitive_i1_control_cell", ) } else { - // Seed the JSValue box with TAG_TDZ (Temporal Dead Zone) when - // requested -- a read before the declaration runs throws a spec - // ReferenceError via the runtime `js_box_get_bits` choke point. - // Compiler-private i32/i1 control cells are never TDZ. - let seed_bits = if tdz { - crate::nanbox::TAG_TDZ_I64.to_string() - } else { - crate::nanbox::TAG_UNDEFINED_I64.to_string() - }; ( - blk.call( - crate::types::I64, - "js_box_alloc_bits", - &[(crate::types::I64, &seed_bits)], - ), + "js_box_alloc_bits", + (crate::types::I64, seed_bits.as_str()), + frame_release::JS_BOX_SCOPE_RELEASE, "jsvalue_box_cell", ) }; @@ -762,7 +767,12 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result .entry_allocas_push_store(crate::types::I64, &undef_bits, &slot); slot }; + if !activation_frame { + frame_release::release_previous_iteration_cell(ctx, &slot, release_fn); + } + let box_ptr = ctx.block().call(crate::types::I64, alloc_fn, &[alloc_arg]); ctx.block().store(crate::types::I64, &box_ptr, &slot); + frame_release::release_at_frame_exit(ctx, &slot, release_fn); record_boxed_slot_js_value_bits(ctx, *id, &box_ptr, "preallocate_boxes.box_ptr_slot"); if cell_note != "jsvalue_box_cell" { let lowered = LoweredValue::js_value_bits(&box_ptr); diff --git a/crates/perry-runtime/src/box.rs b/crates/perry-runtime/src/box.rs index 4eae4b605b..77e7ebf71b 100644 --- a/crates/perry-runtime/src/box.rs +++ b/crates/perry-runtime/src/box.rs @@ -457,27 +457,29 @@ fn push_free_cell(addr: usize, head: &'static crate::tls_hot::HotKey { - BOX_REGISTRY.with(|r| { - r.borrow_mut().remove(&addr); - }); + if !BOX_REGISTRY.with(|r| r.borrow_mut().remove(&addr)) { + return; + } box_ptr_cache_evict(box_ptr_cache(), addr); unsafe { (*(addr as *mut Box)).value = crate::value::TAG_UNDEFINED }; push_free_cell(addr, &BOX_FREE_HEAD); } ASYNC_RELEASE_I32 => { - I32_BOX_REGISTRY.with(|r| { - r.borrow_mut().remove(&addr); - }); + if !I32_BOX_REGISTRY.with(|r| r.borrow_mut().remove(&addr)) { + return; + } box_ptr_cache_evict(i32_box_ptr_cache(), addr); unsafe { (*(addr as *mut I32Box)).value = -1 }; push_free_cell(addr, &I32_BOX_FREE_HEAD); } ASYNC_RELEASE_BOOL => { - BOOL_BOX_REGISTRY.with(|r| { - r.borrow_mut().remove(&addr); - }); + if !BOOL_BOX_REGISTRY.with(|r| r.borrow_mut().remove(&addr)) { + return; + } box_ptr_cache_evict(bool_box_ptr_cache(), addr); unsafe { (*(addr as *mut BoolBox)).value = true }; push_free_cell(addr, &BOOL_BOX_FREE_HEAD); @@ -485,7 +487,10 @@ fn publish_box_cell(addr: usize, tag: usize) { _ => unreachable!("invalid async released-cell tag"), } ASYNC_PENDING_RELEASES.with(|pending| { - pending.borrow_mut().remove(&addr); + let mut pending = pending.borrow_mut(); + if !pending.is_empty() { + pending.remove(&addr); + } }); BOX_FLUSH_PUBLISHED.fetch_add(1, Ordering::Relaxed); } @@ -499,21 +504,22 @@ pub(crate) fn box_capture_count_reached_zero(addr: usize) { } } -/// Expose a drained, closure-owned JS box's payload to the closure tracer. -/// The exact-capture table may also contain i32/bool box addresses; requiring -/// the pending JS tag is the authoritative type discriminator before the -/// pointer is dereferenced as [`Box`]. +/// Expose a released, closure-owned JS box's payload to the closure tracer — +/// a drained async activation cell, or one its ordinary frame released +/// (#10464). The exact-capture table may also contain i32/bool box addresses; +/// requiring the JS tag on the release record is the authoritative type +/// discriminator before the pointer is dereferenced as [`Box`]. pub(crate) fn visit_pending_captured_js_box_payload_slot( addr: usize, visit: &mut dyn FnMut(*mut u64), ) { - let is_pending_js = ASYNC_PENDING_RELEASES.with(|pending| { + let is_released_js = ASYNC_PENDING_RELEASES.with(|pending| { pending .borrow() .get(&addr) .is_some_and(|tag| *tag == (ASYNC_RELEASE_JS | ASYNC_RELEASE_DRAINED)) - }); - if is_pending_js && BOX_REGISTRY.with(|registry| registry.borrow().contains(&addr)) { + }) || crate::closure::frame_released_js_cell(addr, ASYNC_RELEASE_JS); + if is_released_js && BOX_REGISTRY.with(|registry| registry.borrow().contains(&addr)) { let ptr = addr as *mut Box; unsafe { visit(&raw mut (*ptr).value) }; } @@ -965,19 +971,6 @@ pub fn scan_box_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { BOX_REGISTRY.with(|r| { let r = r.borrow(); for &addr in r.iter() { - // A drained box is retained only by exact closure-capture - // metadata. During a full trace its payload is reached from - // each live closure instead. Rooting it here as well would - // make `box -> closure -> same box` an uncollectable native - // cycle. Minors retain the old strong-root rule because they - // cannot adjudicate old-closure liveness. - if full_trace - && pending - .get(&addr) - .is_some_and(|tag| *tag == (ASYNC_RELEASE_JS | ASYNC_RELEASE_DRAINED)) - { - continue; - } let ptr = addr as *mut Box; // Defensive: the registry should only contain valid live // pointers, but if a stale entry slipped through we'd @@ -985,15 +978,40 @@ pub fn scan_box_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { // address (alloc gives 8-aligned pointers in user space) // matches `is_plausible_box_ptr` to keep this a no-op for // any pathological entry. - if addr >= 0x1000 && (addr as u64) < 0x0001_0000_0000_0000 && addr % 8 == 0 { + if !(addr >= 0x1000 && (addr as u64) < 0x0001_0000_0000_0000 && addr % 8 == 0) { + continue; + } + // A released box (a drained async cell, or a cell its + // ordinary frame released — #10464) is retained only by exact + // closure-capture metadata. During a full trace its payload is + // reached from each live closure instead. Rooting it here as + // well would make `box -> closure -> same box` an uncollectable + // native cycle. Minors retain the old strong-root rule because + // they cannot adjudicate old-closure liveness — and a minor + // walks ONLY the log below, so a young payload must stay logged + // even when this trace does not visit it. Dropping it here left + // the next minor with no root for a live payload + // (`gc-fromspace-protect` catches the stale use immediately). + if full_trace + && (pending + .get(&addr) + .is_some_and(|tag| *tag == (ASYNC_RELEASE_JS | ASYNC_RELEASE_DRAINED)) + || crate::closure::frame_released_js_cell(addr, ASYNC_RELEASE_JS)) + { unsafe { - visitor.visit_nanbox_u64_raw_slot(&raw mut (*ptr).value); if crate::gc::young_log::bits_are_minor_relevant((*ptr).value) { kept.push(addr); } } - visited += 1; + continue; } + unsafe { + visitor.visit_nanbox_u64_raw_slot(&raw mut (*ptr).value); + if crate::gc::young_log::bits_are_minor_relevant((*ptr).value) { + kept.push(addr); + } + } + visited += 1; } }); }); @@ -1905,3 +1923,11 @@ mod tests { #[cfg(test)] #[path = "box/release_tests.rs"] mod release_tests; + +// #10464: scope-exit release for frame-owned cells. +#[path = "box/scope_release.rs"] +mod scope_release; +pub(crate) use scope_release::publish_frame_released_cell; +pub use scope_release::{ + js_bool_box_scope_release, js_box_scope_release, js_i32_box_scope_release, +}; diff --git a/crates/perry-runtime/src/box/scope_release.rs b/crates/perry-runtime/src/box/scope_release.rs new file mode 100644 index 0000000000..b9274fc5ef --- /dev/null +++ b/crates/perry-runtime/src/box/scope_release.rs @@ -0,0 +1,346 @@ +//! #10464: release the box cells an ordinary frame minted once that frame can +//! no longer name them. +//! +//! A variable box is minted per execution of the declaration of a captured and +//! reassigned binding, and every registered cell is a strong GC root +//! (`scan_box_roots_mut`). Before #10464 only a lowered plain-async activation +//! ever released its cells (#7933/#8208), so a synchronous function, method, +//! arrow, generator, or an `async` function without `await` kept one registered +//! root per boxed binding per call alive for the life of the thread, together +//! with everything that binding last pointed at. +//! +//! Codegen now names each frame-owned cell before every `ret` of the frame and +//! before a declaration inside a loop mints the next iteration's cell. The +//! frame is the holder that disappears at those points; every other holder is +//! a compiler-declared closure capture edge (`js_closure_set_box_capture_ptr`), +//! which is exactly the edge set #8303 counts. So: +//! +//! - a cell with no capture edge publishes immediately: de-registered, +//! positive-cache-evicted, cleared, and pushed on its kind's free list; +//! - a captured cell stays registered and readable, and its capture-edge record +//! is marked frame-released. It publishes when authoritative GC death +//! pruning removes its last capture edge. Like a drained async terminal cell, +//! a full trace reaches its payload only through live capturing closures (the +//! ephemeron half), so a payload that references its own closure does not +//! keep either alive. +//! +//! A running closure keeps its own edges: every capturing closure body spills +//! `%this_closure` into a frame-long root slot (#7055), so a cell a closure body +//! cached at entry cannot be published while that body is still executing. +//! +//! These entries are deliberately separate from `js_*box_release`, which parks +//! into the AMBIENT async activation: a synchronous callee running inside an +//! async step must not append its own cells to the caller activation's +//! contiguous terminal release range. + +use super::*; + +#[inline] +fn release_scope_cell(addr: usize, tag: usize, registered: bool) { + // Not registered: a TAG_UNDEFINED slot whose declaration never ran, an + // already-published cell, or a foreign address. All are no-ops, which is + // what makes a repeated release unable to push one cell twice. + if !registered { + return; + } + // An async activation's terminal cell: its activation already decided. + let async_pending = ASYNC_PENDING_RELEASES.with(|pending| { + let pending = pending.borrow(); + !pending.is_empty() && pending.contains_key(&addr) + }); + if async_pending { + return; + } + match crate::closure::note_frame_released_cell(addr, tag) { + crate::closure::FrameRelease::Uncaptured => { + BOX_RELEASE_COUNT.fetch_add(1, Ordering::Relaxed); + publish_box_cell(addr, tag); + } + crate::closure::FrameRelease::Deferred => { + BOX_RELEASE_COUNT.fetch_add(1, Ordering::Relaxed); + } + crate::closure::FrameRelease::AlreadyReleased => {} + } +} + +/// The last capture edge of a frame-released cell disappeared (GC death +/// pruning): publish it for reuse. +pub(crate) fn publish_frame_released_cell(addr: usize, tag: usize) { + publish_box_cell(addr, tag); +} + +/// Release a JSValue box cell owned by the exiting (or re-entering) frame. +#[no_mangle] +pub extern "C" fn js_box_scope_release(ptr: *mut Box) { + release_scope_cell(ptr as usize, ASYNC_RELEASE_JS, is_registered_box_ptr(ptr)); +} + +/// [`js_box_scope_release`] for the compiler-private i32 control cells of a +/// generator frame. +#[no_mangle] +pub extern "C" fn js_i32_box_scope_release(ptr: *mut I32Box) { + release_scope_cell( + ptr as usize, + ASYNC_RELEASE_I32, + is_registered_i32_box_ptr(ptr), + ); +} + +/// [`js_box_scope_release`] for the compiler-private boolean control cells of +/// a generator frame. +#[no_mangle] +pub extern "C" fn js_bool_box_scope_release(ptr: *mut BoolBox) { + release_scope_cell( + ptr as usize, + ASYNC_RELEASE_BOOL, + is_registered_bool_box_ptr(ptr), + ); +} + +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_BOX_SCOPE_RELEASE: extern "C" fn(*mut Box) = js_box_scope_release; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_I32_BOX_SCOPE_RELEASE: extern "C" fn(*mut I32Box) = js_i32_box_scope_release; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_BOOL_BOX_SCOPE_RELEASE: extern "C" fn(*mut BoolBox) = js_bool_box_scope_release; + +#[cfg(test)] +mod tests { + use super::*; + + fn int_bits(value: i32) -> i64 { + crate::value::JSValue::int32(value).bits() as i64 + } + + fn free_list_contains(addr: usize) -> bool { + let mut cursor = BOX_FREE_HEAD.with(std::cell::Cell::get); + let mut steps = 0; + while cursor != 0 && steps < 1 << 20 { + if cursor == addr { + return true; + } + cursor = unsafe { (cursor as *const usize).read() }; + steps += 1; + } + false + } + + /// The common case: the frame was the cell's only holder. The cell is + /// inert at once and the next allocation reuses it; repeating the release + /// (a second `ret` path, a loop that exits right after re-entering) must + /// not push it onto the free list a second time. + #[test] + fn uncaptured_cell_publishes_at_scope_exit_exactly_once() { + test_clear_box_registry(); + let cell = js_box_alloc_bits(int_bits(7)); + js_box_scope_release(cell); + assert!(!is_registered_box_ptr(cell), "published cell de-registers"); + assert_eq!(js_box_get_bits(cell) as u64, crate::value::TAG_UNDEFINED); + js_box_scope_release(cell); + js_box_scope_release(cell); + + let first = js_box_alloc_bits(int_bits(1)); + let second = js_box_alloc_bits(int_bits(2)); + assert_eq!(first, cell, "the published cell is reused immediately"); + assert_ne!( + first, second, + "a repeated release must not alias two live bindings onto one cell" + ); + assert_eq!(js_box_get_bits(first), int_bits(1)); + assert_eq!(js_box_get_bits(second), int_bits(2)); + } + + /// A slot whose declaration never ran still holds TAG_UNDEFINED; codegen + /// releases it unconditionally at `ret`. Foreign pointers are rejected + /// by the same registry gate as every other box entry point. + #[test] + fn unminted_slot_values_and_foreign_pointers_are_no_ops() { + test_clear_box_registry(); + let live = js_box_alloc_bits(int_bits(3)); + js_box_scope_release(crate::value::TAG_UNDEFINED as usize as *mut Box); + js_box_scope_release(std::ptr::null_mut()); + static RODATA: [u64; 1] = [0xDEAD_BEEF]; + js_box_scope_release((&RODATA[0] as *const u64) as *mut Box); + assert_eq!(RODATA[0], 0xDEAD_BEEF); + assert!(is_registered_box_ptr(live)); + assert_eq!(js_box_get_bits(live), int_bits(3)); + assert!(!free_list_contains(live as usize)); + } + + /// `function counter() { let n = 0; return () => ++n; }`: the returned + /// closure outlives the frame. Its cell stays readable and writable after + /// scope exit, a closure created later from that closure adds its own + /// edge, and only the last closure's death publishes the cell. + #[test] + fn escaped_closure_keeps_its_cell_until_gc_death() { + test_clear_box_registry(); + let cell = js_box_alloc_bits(int_bits(0)); + let counter = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(counter, 0, cell as i64); + js_box_scope_release(cell); + + assert!(is_registered_box_ptr(cell), "an escaped closure owns it"); + js_box_set_bits(cell, int_bits(1)); + assert_eq!(js_box_get_bits(cell), int_bits(1)); + assert!(!free_list_contains(cell as usize)); + js_box_scope_release(cell); + assert!( + crate::closure::frame_released_js_cell(cell as usize, ASYNC_RELEASE_JS), + "a repeated scope release leaves the released state unchanged" + ); + assert_eq!(crate::closure::box_capture_count(cell as usize), 1); + + let child = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(child, 0, cell as i64); + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == counter as usize); + assert!(is_registered_box_ptr(cell), "the child closure is live"); + assert_eq!(js_box_get_bits(cell), int_bits(1)); + + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == child as usize); + assert!(!is_registered_box_ptr(cell)); + assert!(free_list_contains(cell as usize)); + let reused = js_box_alloc_bits(int_bits(9)); + assert_eq!(reused, cell, "last closure death publishes the cell"); + } + + /// A scope-released captured cell must follow the #8303 full-trace rule: + /// not a global root (else a payload that references its own closure is + /// immortal), but traced through a closure the mark set proved live. + #[test] + fn scope_released_captured_cell_is_an_ephemeron_edge_in_a_full_trace() { + test_clear_box_registry(); + let cell = js_box_alloc_bits(crate::value::TAG_UNDEFINED as i64); + let closure = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(closure, 0, cell as i64); + let closure_bits = crate::value::js_nanbox_pointer(closure as i64).to_bits(); + js_box_set_bits(cell, closure_bits as i64); + + let mut minor_roots = Vec::new(); + scan_box_roots(&mut |value| minor_roots.push(value.to_bits())); + assert!( + minor_roots.contains(&closure_bits), + "sabotage check: before release the registry roots the payload" + ); + js_box_scope_release(cell); + + crate::gc::begin_full_trace(); + let mut rooted = Vec::new(); + scan_box_roots(&mut |value| rooted.push(value.to_bits())); + assert!( + !rooted.contains(&closure_bits), + "a scope-released cell must not root its own closure in a full trace" + ); + let header = unsafe { + (closure as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader + }; + let saved_flags = unsafe { (*header).gc_flags }; + unsafe { (*header).gc_flags |= crate::gc::GC_FLAG_MARKED }; + let slots = crate::gc::test_gc_rewrite_slot_addresses(closure as usize) + .expect("closure rewrite descriptor"); + unsafe { (*header).gc_flags = saved_flags }; + crate::gc::finish_full_trace(); + assert!( + slots.contains(&(cell as usize)), + "a marked closure must trace the scope-released cell's payload" + ); + + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == closure as usize); + assert!(!is_registered_box_ptr(cell)); + } + + /// A minor walks ONLY the box young log. A full trace that stops rooting a + /// released cell must therefore still LOG it while its payload is young, + /// or the next minor has no root for a payload a live closure still reads. + /// `PERRY_GC_PROTECT_FROMSPACE` caught exactly this as a stale from-space + /// closure call; this test is its cheap, deterministic twin. + #[test] + fn a_full_trace_keeps_a_released_cell_in_the_minor_remembered_set() { + test_clear_box_registry(); + let cell = js_box_alloc_bits(crate::value::TAG_UNDEFINED as i64); + let closure = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(closure, 0, cell as i64); + let payload = crate::value::js_nanbox_pointer(closure as i64).to_bits(); + js_box_set_bits(cell, payload as i64); + assert!( + crate::gc::young_log::bits_are_minor_relevant(payload), + "premise: the payload is a young object a minor must rewrite" + ); + js_box_scope_release(cell); + + crate::gc::begin_full_trace(); + scan_box_roots(&mut |_| {}); + crate::gc::finish_full_trace(); + + BOX_YOUNG_ROOTS.with(|log| { + log.borrow() + .debug_assert_logged(BOX_YOUNG_LOG_NAME, &relevant_box_roots()) + }); + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == closure as usize); + } + + /// A synchronous callee running inside an async step must not park its + /// cells into the caller's activation range (that is what + /// `js_box_release` would do): the scope release is independent of the + /// ambient activation, and the activation's own terminal release still + /// works afterwards. + #[test] + fn scope_release_ignores_the_ambient_async_activation() { + test_clear_box_registry(); + let activation = new_async_box_activation(); + retain_async_box_activation(activation); + let previous = crate::promise::INLINE_TRAP.with(|trap| { + trap.replace(crate::promise::InlineTrap { + trap_next: std::ptr::null_mut(), + current_step: 0, + box_activation: activation, + }) + }); + let callee_cell = js_box_alloc_bits(int_bits(4)); + js_box_scope_release(callee_cell); + assert!(!is_registered_box_ptr(callee_cell)); + assert_eq!( + unsafe { (*activation).release_start.get() }, + NO_RELEASE_RANGE, + "the callee's cell must not enter the caller's release range" + ); + + let frame_cell = js_box_alloc_bits(int_bits(5)); + js_box_release(frame_cell); + assert!(is_registered_box_ptr(frame_cell), "a step still owns it"); + release_async_box_activation(activation); + assert!(!is_registered_box_ptr(frame_cell)); + crate::promise::INLINE_TRAP.with(|trap| trap.set(previous)); + } + + /// Generator frames also own compiler-private i32/bool control cells. + /// Each kind publishes through its own registry, and a cell of one kind + /// is never accepted by another kind's release entry. + #[test] + fn primitive_control_cells_release_through_their_own_registries() { + test_clear_box_registry(); + let state = js_i32_box_alloc(3); + let done = js_bool_box_alloc(1); + let ordinary = js_box_alloc_bits(int_bits(6)); + + js_i32_box_scope_release(ordinary.cast::()); + js_bool_box_scope_release(ordinary.cast::()); + js_box_scope_release(state.cast::()); + assert!(is_registered_box_ptr(ordinary)); + assert!(is_registered_i32_box_ptr(state)); + + let closure = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(closure, 0, done as i64); + js_i32_box_scope_release(state); + js_bool_box_scope_release(done); + assert!(!is_registered_i32_box_ptr(state)); + assert!(is_registered_bool_box_ptr(done), "a live closure reads it"); + assert_eq!(js_bool_box_get(done), 1); + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == closure as usize); + assert!(!is_registered_bool_box_ptr(done)); + assert_eq!(js_i32_box_alloc(8), state, "i32 free list reuses it"); + assert_eq!(js_bool_box_alloc(0), done, "bool free list reuses it"); + } +} diff --git a/crates/perry-runtime/src/closure/box_captures.rs b/crates/perry-runtime/src/closure/box_captures.rs index 2cc7fd7b4a..bdc7b3fda4 100644 --- a/crates/perry-runtime/src/closure/box_captures.rs +++ b/crates/perry-runtime/src/closure/box_captures.rs @@ -6,56 +6,162 @@ //! pointer-shaped bits. Once its async activation drains, the box runtime //! publishes an unobserved cell immediately and leaves only a captured cell //! pending. Closure moves rekey the per-closure index; authoritative GC death -//! pruning drops the corresponding per-cell counts. +//! pruning drops the corresponding per-cell counts. A cell released by the +//! ordinary frame that minted it (#10464) follows the same edges. use super::ClosureHeader; use std::cell::RefCell; -type BoxCaptureSlots = Vec<(u32, usize)>; +/// One closure's `(capture index, box address)` edges. Nearly every closure +/// declares one or two, and one is stored inline: a closure capturing a +/// reassigned local then costs no heap allocation for its lifetime record +/// (#10464 made those records a per-call cost of ordinary frames). +#[derive(Clone, Default)] +struct BoxCaptureSlots { + first: Option<(u32, usize)>, + rest: Vec<(u32, usize)>, +} + +impl BoxCaptureSlots { + fn take(&mut self, index: u32) -> Option { + if self.first.is_some_and(|(slot, _)| slot == index) { + let cell = self.first.take().map(|(_, cell)| cell); + self.first = self.rest.pop(); + return cell; + } + let pos = self.rest.iter().position(|(slot, _)| *slot == index)?; + Some(self.rest.swap_remove(pos).1) + } + + fn push(&mut self, index: u32, cell: usize) { + if self.first.is_none() { + self.first = Some((index, cell)); + } else { + self.rest.push((index, cell)); + } + } + + /// `rest` is non-empty only while `first` is occupied. + fn is_empty(&self) -> bool { + self.first.is_none() + } + + fn cells(&self) -> impl Iterator + '_ { + self.first + .iter() + .chain(self.rest.iter()) + .map(|(_, cell)| *cell) + } +} crate::perry_thread_local! { /// Closure address -> compiler-declared `(capture index, box address)` edges. static CLOSURE_BOX_CELLS: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); - /// Box address -> total number of capture slots naming it. + /// Box address -> packed edge record: the number of capture slots naming + /// it (`EDGE_COUNT_MASK`) plus, once the frame that minted the cell has + /// released it (#10464), `FRAME_RELEASED` and the cell-kind tag. Keeping + /// the release state in the record the capture edges already maintain + /// makes a frame exit one probe, and lets the final edge's removal publish + /// the cell without a second table. static BOX_CAPTURE_COUNTS: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); } +const FRAME_RELEASED: usize = 1 << 63; +const FRAME_RELEASE_TAG_SHIFT: u32 = 60; +const FRAME_RELEASE_TAG_MASK: usize = 0b11 << FRAME_RELEASE_TAG_SHIFT; +const EDGE_COUNT_MASK: usize = (1 << FRAME_RELEASE_TAG_SHIFT) - 1; + fn increment_cell_capture_count(cell: usize, amount: usize) { BOX_CAPTURE_COUNTS.with(|counts| { let mut counts = counts.borrow_mut(); - let count = counts.entry(cell).or_default(); - *count = count + let record = counts.entry(cell).or_default(); + let count = (*record & EDGE_COUNT_MASK) .checked_add(amount) + .filter(|count| *count <= EDGE_COUNT_MASK) .expect("box capture count overflow"); + *record = (*record & !EDGE_COUNT_MASK) | count; }); } fn decrement_cell_capture_count(cell: usize, amount: usize) { + // `Some(record)` when the final edge disappeared. let reached_zero = BOX_CAPTURE_COUNTS.with(|counts| { let mut counts = counts.borrow_mut(); - let Some(count) = counts.get_mut(&cell) else { - return false; - }; - debug_assert!(*count >= amount); - *count -= amount; - if *count == 0 { - counts.remove(&cell); - true + let record = counts.get_mut(&cell)?; + debug_assert!(*record & EDGE_COUNT_MASK >= amount); + *record -= amount; + if *record & EDGE_COUNT_MASK == 0 { + counts.remove(&cell) } else { - false + None } }); - if reached_zero { - crate::r#box::box_capture_count_reached_zero(cell); + match reached_zero { + Some(record) if record & FRAME_RELEASED != 0 => crate::r#box::publish_frame_released_cell( + cell, + (record & FRAME_RELEASE_TAG_MASK) >> FRAME_RELEASE_TAG_SHIFT, + ), + Some(_) => crate::r#box::box_capture_count_reached_zero(cell), + None => {} } } pub(crate) fn box_capture_count(cell: usize) -> usize { BOX_CAPTURE_COUNTS - .with(|counts| counts.borrow().get(&cell).copied()) - .unwrap_or(0) + .with(|counts| { + let counts = counts.borrow(); + if counts.is_empty() { + None + } else { + counts.get(&cell).copied() + } + }) + .map_or(0, |record| record & EDGE_COUNT_MASK) +} + +/// Outcome of [`note_frame_released_cell`]. +pub(crate) enum FrameRelease { + /// No closure edge names the cell: the frame was its only holder. + Uncaptured, + /// Captured; the final edge's removal now publishes it. + Deferred, + /// Captured and already released by its frame. + AlreadyReleased, +} + +/// #10464: the frame that minted `cell` (of kind `tag`, 1..=3) can no longer +/// name it. A captured cell is marked so its last capture edge publishes it. +pub(crate) fn note_frame_released_cell(cell: usize, tag: usize) -> FrameRelease { + debug_assert!((1..=3).contains(&tag)); + BOX_CAPTURE_COUNTS.with(|counts| { + let mut counts = counts.borrow_mut(); + if counts.is_empty() { + return FrameRelease::Uncaptured; + } + match counts.get_mut(&cell) { + None => FrameRelease::Uncaptured, + Some(record) if *record & FRAME_RELEASED != 0 => FrameRelease::AlreadyReleased, + Some(record) => { + *record |= FRAME_RELEASED | (tag << FRAME_RELEASE_TAG_SHIFT); + FrameRelease::Deferred + } + } + }) +} + +/// Whether `cell` is a frame-released, still-captured JSValue box: during a +/// full trace such a cell is reached only through its live closures. +pub(crate) fn frame_released_js_cell(cell: usize, js_tag: usize) -> bool { + BOX_CAPTURE_COUNTS.with(|counts| { + let counts = counts.borrow(); + !counts.is_empty() + && counts.get(&cell).is_some_and(|record| { + *record & FRAME_RELEASED != 0 + && (*record & FRAME_RELEASE_TAG_MASK) >> FRAME_RELEASE_TAG_SHIFT == js_tag + }) + }) } /// Visit the JSValue payload slots reached through one live closure's exact @@ -72,7 +178,7 @@ pub(crate) fn visit_closure_box_payload_slots_mut(closure: usize, mut visit: imp let Some(cells) = captures.get(&closure) else { return; }; - for &(_, cell) in cells { + for cell in cells.cells() { crate::r#box::visit_pending_captured_js_box_payload_slot(cell, &mut visit); } }); @@ -91,12 +197,9 @@ pub(super) fn set_closure_box_capture( let previous = CLOSURE_BOX_CELLS.with(|captures| { let mut captures = captures.borrow_mut(); let slots = captures.entry(closure).or_default(); - let previous = slots - .iter() - .position(|(slot, _)| *slot == index) - .map(|pos| slots.swap_remove(pos).1); + let previous = slots.take(index); if let Some(cell) = cell { - slots.push((index, cell)); + slots.push(index, cell); } if slots.is_empty() { captures.remove(&closure); @@ -133,7 +236,7 @@ pub(crate) fn clone_closure_box_captures( } copied }); - for (_, cell) in copied { + for cell in copied.cells() { increment_cell_capture_count(cell, 1); } } @@ -152,21 +255,22 @@ pub(crate) fn closure_box_captures_owner_moved(old_owner: usize, new_owner: usiz } pub(crate) fn prune_dead_closure_box_capture_owners(is_dead_closure: &dyn Fn(usize) -> bool) { - let dead_keys = CLOSURE_BOX_CELLS.with(|captures| { - captures - .borrow() - .keys() - .copied() - .filter(|owner| is_dead_closure(*owner)) - .collect::>() + // One pass: dropping a dead owner and collecting its edges together avoids + // a second probe per dead closure. Counts (and any publication they + // trigger) are settled after the table borrow ends. + let mut dead_cells = Vec::new(); + CLOSURE_BOX_CELLS.with(|captures| { + captures.borrow_mut().retain(|owner, slots| { + if is_dead_closure(*owner) { + dead_cells.extend(slots.cells()); + false + } else { + true + } + }); }); - for closure in dead_keys { - let cells = CLOSURE_BOX_CELLS - .with(|captures| captures.borrow_mut().remove(&closure)) - .unwrap_or_default(); - for (_, cell) in cells { - decrement_cell_capture_count(cell, 1); - } + for cell in dead_cells { + decrement_cell_capture_count(cell, 1); } } diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 57d7404170..9930d058b8 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -73,7 +73,8 @@ pub use unbox::{js_closure_unbox_callee_checked, js_closure_unbox_callee_checked pub(crate) use box_captures::test_clear_closure_box_capture_indexes; pub(crate) use box_captures::{ box_capture_count, clone_closure_box_captures, closure_box_captures_owner_moved, - prune_dead_closure_box_capture_owners, visit_closure_box_payload_slots_mut, + frame_released_js_cell, note_frame_released_cell, prune_dead_closure_box_capture_owners, + visit_closure_box_payload_slots_mut, FrameRelease, }; #[cfg(feature = "wasm-host")] pub(crate) use dynamic_props::register_wasm_funcref_external; diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index 015a5f9590..c7d6e82707 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -522,6 +522,10 @@ def build_cfg(f): # (#7510: letting these two lists drift one-sided printed 358 spurious # violations once the corpus widened). "js_box_release", "js_i32_box_release", "js_bool_box_release", + # #10464 scope-exit release (box/scope_release.rs): the same publish, or a + # TLS pending-map insert for a closure-captured cell. + "js_box_scope_release", "js_i32_box_scope_release", + "js_bool_box_scope_release", "js_write_barrier", # gc/barrier.rs:930 "js_tdz_suppress_begin", "js_tdz_suppress_end", # box.rs:242/248 counter "js_array_note_numeric_write", # array/header.rs:1443 @@ -2742,6 +2746,11 @@ def _probe_boxes_outside_the_gc_heap(): still resume, which is a use-after-release aliasing hazard that this exemption would otherwise silently suppress. The old global quarantine is retained only as a conservative fallback for untracked callers. + + #10464 adds the scope-exit release of an ordinary frame's cells + (`box/scope_release.rs`). It may publish directly only because it is gated + on the cell's closure capture count; a captured cell must take the same + drained-pending path closure death pruning publishes from. """ try: with open("crates/perry-runtime/src/box.rs", @@ -2785,6 +2794,22 @@ def _probe_boxes_outside_the_gc_heap(): "park until its activation reaches zero references. " "Publishing overwrites the terminal value a stray " "resume still writes through.") + scope_path = "crates/perry-runtime/src/box/scope_release.rs" + scope = rust_fn_body(scope_path, "release_scope_cell") + if scope is None: + return (False, "release_scope_cell not found in box/scope_release.rs; " + "the #10464 scope-exit release changed shape") + if "note_frame_released_cell" not in scope \ + or "FrameRelease::Deferred" not in scope: + return (False, "scope-exit release no longer defers a closure-captured " + "cell to closure death") + try: + with open(scope_path, encoding="utf-8", errors="replace") as fh: + scope_src = fh.read() + except OSError: + return (False, f"{scope_path} not readable") + if re.search(r"\bdealloc\s*\(|arena_alloc\w*\s*\(", scope_src): + return (False, "box/scope_release.rs frees or arena-allocates cells") release_ref = rust_fn_body("crates/perry-runtime/src/box.rs", "release_async_box_activation") if release_ref is None or "if new == 0" not in release_ref \ diff --git a/test-files/test_gap_10464_box_cell_release.ts b/test-files/test_gap_10464_box_cell_release.ts new file mode 100644 index 0000000000..58b0f24268 --- /dev/null +++ b/test-files/test_gap_10464_box_cell_release.ts @@ -0,0 +1,305 @@ +// #10464: a `let`/`var` captured by a closure and reassigned lives in a box +// cell. Outside a lowered async state machine those cells were never released, +// so each call leaked a registered GC root plus everything the binding last +// referenced. This test checks both halves of the fix: memory stays bounded +// relative to an equal-allocation control, and cells that escape through +// closures (returned, stored, nested, generators, async) keep their values +// across many collections. + +const maybeGc = (globalThis as any).gc as (() => void) | undefined; + +function churn(rounds: number): number { + let total = 0; + for (let r = 0; r < rounds; r++) { + const junk: Array<{ i: number; s: string; a: number[] }> = []; + for (let i = 0; i < 2000; i++) junk.push({ i, s: "x" + i, a: [i, i + 1] }); + total += junk.length; + } + if (typeof maybeGc === "function") maybeGc(); + return total; +} + +// ---- memory: the issue's repro, compared in-process against its control ---- + +function payload(i: number): number { + let buf: number[] = new Array(512).fill(i); + const swap = () => { + buf = new Array(512).fill(i + 1); + }; + swap(); + return buf.length; +} + +function noBox(i: number): number { + const holder = { buf: new Array(512).fill(i) }; + const swap = () => { + holder.buf = new Array(512).fill(i + 1); + }; + swap(); + return holder.buf.length; +} + +function counterCell(i: number): number { + let x = i; + const bump = () => { + x += 1; + }; + bump(); + return x; +} + +const rssMb = () => process.memoryUsage().rss / 1048576; + +function growthMb(run: (i: number) => number, calls: number): number { + const before = rssMb(); + let acc = 0; + for (let i = 1; i <= calls; i++) acc += run(i); + if (acc <= 0) throw new Error("no work done"); + return rssMb() - before; +} + +// Warm both shapes so allocator and heap high-water marks settle first; the +// control runs before the boxed shape so RSS reuse can only favor the control. +growthMb(noBox, 5000); +growthMb(payload, 5000); +const CALLS = 60000; +const controlGrowth = growthMb(noBox, CALLS); +const payloadGrowth = growthMb(payload, CALLS); +// Leaking every call's 512-element array costs ~5 KB * 60k = ~300 MB. +console.log("payload growth bounded by control:", payloadGrowth < Math.max(controlGrowth, 0) + 96); + +let cellAcc = 0; +for (let i = 1; i <= 200000; i++) cellAcc += counterCell(i); +console.log("counter cells:", cellAcc); + +// ---- escaping closures keep their cells ---- + +function makeCounter(start: number) { + let n = start; + return { + inc: () => ++n, + add: (k: number) => { + n += k; + return n; + }, + get: () => n, + }; +} + +const counters: Array> = []; +for (let i = 0; i < 3000; i++) counters.push(makeCounter(i)); +churn(20); +let counterSum = 0; +for (let i = 0; i < counters.length; i++) { + counters[i].inc(); + counterSum += counters[i].add(i); +} +churn(20); +for (let i = 0; i < counters.length; i += 7) counterSum += counters[i].get(); +console.log("returned counters:", counterSum); + +function makeAdders(): Map number> { + const map = new Map number>(); + for (let i = 0; i < 500; i++) { + let base = i; + map.set("k" + i, (v: number) => (base += v)); + } + return map; +} +const adders = makeAdders(); +churn(15); +let adderSum = 0; +for (const [, fn] of adders) adderSum += fn(1); +churn(15); +for (const [, fn] of adders) adderSum += fn(2); +console.log("per-iteration map closures:", adderSum); + +// A loop whose iterations sometimes capture and sometimes do not: released +// uncaptured cells are reused by the next iteration, captured ones must not be. +function mixedLoop(): number[] { + const kept: Array<() => number> = []; + for (let i = 0; i < 400; i++) { + let value = i * 3; + const touch = () => (value += 1); + if (i % 3 === 0) kept.push(touch); + else touch(); + value += 0; + } + churn(10); + return kept.map((f) => f()).slice(0, 8); +} +console.log("mixed loop:", mixedLoop().join(",")); + +// Nested closure created after the outer frame returned. +function outerFactory() { + let state = "a"; + const middle = () => { + state += "b"; + return () => { + state += "c"; + return state; + }; + }; + return middle; +} +const middles: Array<() => () => string> = []; +for (let i = 0; i < 200; i++) middles.push(outerFactory()); +churn(10); +const inners = middles.map((m) => m()); +churn(10); +console.log("nested after return:", inners[0](), inners[199](), middles[5]()()); + +// Self-referencing payload: the cell's value references its own closure. +function selfCycle(i: number) { + let self: any = null; + const getSelf = () => self; + self = { i, getSelf }; + return getSelf; +} +const cycles: Array<() => any> = []; +for (let i = 0; i < 20000; i++) { + const g = selfCycle(i); + if (i % 1000 === 0) cycles.push(g); +} +churn(15); +console.log("self cycles:", cycles.map((g) => g().getSelf().i).join(",")); + +// Generators capture boxed state across suspensions; abandoned ones are freed. +function* running(limit: number) { + let total = 0; + const add = (v: number) => { + total += v; + }; + for (let i = 1; i <= limit; i++) { + add(i); + yield total; + } + return total; +} +const gens: Array> = []; +for (let i = 0; i < 50; i++) gens.push(running(5)); +let genSum = 0; +for (let step = 0; step < 6; step++) { + churn(3); + for (const g of gens) { + const r = g.next(); + genSum += r.value ?? 0; + } +} +for (let i = 0; i < 5000; i++) running(3).next(); +console.log("generators:", genSum); + +// Class method frames and closures stored on the instance. +class Account { + report: () => string = () => ""; + owner: string; + constructor(owner: string) { + this.owner = owner; + } + open(deposit: number) { + let balance = deposit; + let history = [deposit]; + this.report = () => `${this.owner}:${balance}:${history.length}`; + return (amount: number) => { + balance += amount; + history = history.concat([amount]); + return balance; + }; + } +} +const accounts: Account[] = []; +const deposits: Array<(n: number) => number> = []; +for (let i = 0; i < 300; i++) { + const acct = new Account("u" + i); + accounts.push(acct); + deposits.push(acct.open(i)); +} +churn(10); +for (let i = 0; i < deposits.length; i++) deposits[i](10); +churn(10); +console.log("class frames:", accounts[0].report(), accounts[299].report()); + +// Early returns, throws and finally on frames that own cells. +function exits(mode: number): number { + let seen = mode; + const mark = () => (seen += 100); + try { + if (mode === 0) return mark(); + if (mode === 1) throw new Error("boom" + seen); + mark(); + } finally { + seen += 1; + } + return seen; +} +let exitSum = 0; +for (let i = 0; i < 3000; i++) { + try { + exitSum += exits(i % 3); + } catch (e) { + exitSum += (e as Error).message.length; + } +} +console.log("exits:", exitSum); + +// Recursion: every frame owns its own cell. +function depth(n: number): number { + let local = n; + const bump = () => (local += 1); + if (n > 0) local += depth(n - 1); + bump(); + return local; +} +console.log("recursion:", depth(200)); + +// Parameters captured and reassigned are boxed too. +function paramCell(a: number, b: string) { + const again = () => { + a += 1; + b = b + a; + }; + again(); + return () => b + ":" + a; +} +const paramFns: Array<() => string> = []; +for (let i = 0; i < 1000; i++) paramFns.push(paramCell(i, "p")); +churn(10); +console.log("params:", paramFns[0](), paramFns[999]()); + +// async without await, and an await-ing async closure that captures a cell +// owned by an enclosing synchronous frame which returns before it resumes. +async function noAwait(i: number) { + let y = i; + const f = () => { + y++; + }; + f(); + return y; +} + +function syncOwner(seed: number) { + let shared = seed; + const read = () => shared; + void (async () => { + await null; + churn(2); + shared += 1000; + })(); + shared += 1; + return read; +} + +async function main() { + let asyncSum = 0; + for (let i = 0; i < 2000; i++) asyncSum += await noAwait(i); + console.log("async no await:", asyncSum); + + const readers: Array<() => number> = []; + for (let i = 0; i < 20; i++) readers.push(syncOwner(i)); + churn(10); + await new Promise((resolve) => setTimeout(resolve, 10)); + churn(10); + console.log("async capture of outer cell:", readers.map((r) => r()).join(",")); +} + +main().then(() => console.log("done")); From cdf43eb0403bb1593cf3b307bd8d0d06c26fa990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 10:52:25 +0000 Subject: [PATCH 14/30] docs(changelog): add fragment for #10613 --- changelog.d/10613-box-cell-release.md | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 changelog.d/10613-box-cell-release.md diff --git a/changelog.d/10613-box-cell-release.md b/changelog.d/10613-box-cell-release.md new file mode 100644 index 0000000000..55e6c61ee5 --- /dev/null +++ b/changelog.d/10613-box-cell-release.md @@ -0,0 +1,52 @@ +### Fixed: an ordinary frame now releases the box cells it minted (#10464) + +A `let`/`var` that a closure captures and something reassigns is stored in a +malloc-side **box cell**, and every registered cell is a strong GC root +(`scan_box_roots_mut`). The only release Perry emitted was the async-to-generator +transform's terminal `Stmt::ReleaseBoxes` (#7933/#8208/#8303), so a synchronous +function, method, arrow, generator — or an `async` function with no `await` — +leaked one registered root per boxed binding per call, plus everything that +binding last pointed at. `PERRY_GC_DIAG=1` reported `releases=0` for every +non-async workload; the issue's repro reached 601 MB RSS at 100k calls where the +equal-allocation control stayed at 69 MB, and real packages accumulated cells by +the hundred thousand (qs 792k, dayjs 434k). + +**Root cause.** `js_box_alloc_bits` registers each cell for the life of the +thread, and only `perry-transform`'s async step lowering produced the +`Stmt::ReleaseBoxes` that `emit_release_boxes` lowers. Nothing named the cells of +an ordinary frame, so nothing could ever reclaim them. + +**Fix.** Codegen registers every entry slot that holds a cell *this* frame minted +(`stmt/boxed_frame_release.rs`); the return-site rewrite that already injects +`js_shadow_frame_pop` now also emits `js_box_scope_release` for each of them +before every `ret`, and a declaration inside a loop releases the previous +iteration's cell before minting the next. The runtime publishes a cell no closure +captured (de-register, cache-evict, clear, free-list push) and marks a captured +one frame-released in its capture-edge record, so the last capture edge's GC +death publishes it — the same escape contract #8303 built for async activations, +including the full-trace ephemeron rule that keeps `box -> closure -> same box` +collectable. Two holders the runtime cannot count keep their cells: a sloppy-mode +mapped `arguments` object, and a plain-async step closure's own activation cells. +A step closure's capture of an *enclosing* frame's cell is now counted, because +the activation token never covered it and that frame does release its cells. + +Fixing this exposed a latent GC hole shared with #8303: a full trace that stops +rooting a released cell must still keep it in the box young log, because a minor +walks only that log. Without it the next minor had no root for a payload a live +closure still reads — `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 +PERRY_GC_PROTECT_FROMSPACE=1` faults on it immediately, and a unit test now +re-derives the rule. + +**Validation.** New gap test `test_gap_10464_box_cell_release.ts` (the repro's +memory shape compared against its own control in-process, plus escaping-closure, +per-iteration-binding, generator, class, async and self-cycle cases) differs from +Node on the parent commit and matches it here. GC stress over seeds 1-7 at +`PERRY_GC_SCHEDULE_RATE=1` with the from-space quarantine confirmed armed +(`[gc-fromspace-protect] retired_set=#0`, 141,635 hits over the run) held +byte-identical to Node on every deterministic output line, and +`PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1` (panics on any stale +forwarded-pointer read) passed clean. Instructions *improve* slightly on a hot +captured-variable loop (20M calls, -3.6%) and on 1M closure creations (-2.6%), +from reduced GC root-scanning pressure; the issue's `payload` repro reproduces +the RSS claim independently: 608 MB -> 152 MB peak RSS here (originally recorded +616 MB -> 163 MB on the destroyed build host). From 6952b79b64b46ddadcad87af260611e528c2c46b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 08:07:44 +0000 Subject: [PATCH 15/30] fix(runtime): link a native-base subclass prototype to the real builtin prototype class Sub extends EventEmitter {} left Sub.prototype's [[Prototype]] on Object.prototype instead of EventEmitter.prototype. class_decl_prototype_value resolves a registered parent class id by recursing into itself, which bails for a RESERVED native-builtin parent id (builtin_parent_reserved_class_id in perry-codegen wires this edge for a native base with no declared-class registration), silently falling through to the Object.prototype default. Resolve EventEmitter/EventEmitterAsyncResource's real, closure-identity-keyed prototype object through the same js_function_prototype_value_for_read path the existing runtime-function-valued-parent branch already uses, so Object.getPrototypeOf(Sub.prototype) === EventEmitter.prototype holds by identity. Fixes #10599. --- .../src/object/class_registry/state.rs | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index c8b5a74ff9..9b73174956 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -953,6 +953,45 @@ fn class_parent_prototype_bits(value: f64) -> Option { (unsafe { crate::symbol::js_is_symbol(value) } == 0).then_some(bits) } +/// #10599: resolve the real `.prototype` object for a RESERVED native-builtin +/// parent class id -- one `builtin_parent_reserved_class_id` (perry-codegen) +/// wires as a class-registry parent edge for a native base that has no +/// declared-class registration of its own (`class Sub extends EventEmitter +/// {}` has no `js_register_class_name` call for `EventEmitter`). Without this, +/// `class_decl_prototype_value` bails immediately for such an id +/// (`class_name_for_id` returns `None`), so `Sub.prototype`'s `[[Prototype]]` +/// silently fell through to `Object.prototype` instead of +/// `EventEmitter.prototype` -- `Object.getPrototypeOf(Sub.prototype) !== +/// EventEmitter.prototype`, even though `new Sub() instanceof EventEmitter` +/// (a different mechanism -- the class-chain walk in `js_instanceof`) already +/// worked. +/// +/// Scoped to the ids whose only registered subclassing surface is this +/// generic declared-class-prototype path: EventEmitter and its +/// AsyncResource variant, both bound as ordinary native-module callable +/// exports (`bound_native_callable_export_value`) whose own `.prototype` is +/// the same lazily-materialized, closure-identity-keyed object any bound +/// function's `.prototype` read produces +/// (`js_function_prototype_value_for_read`). Resolving through that exact +/// helper -- the same one the dynamic-parent branch below already uses for a +/// runtime function-valued superclass -- is what makes +/// `Object.getPrototypeOf(Sub.prototype) === EventEmitter.prototype` hold by +/// identity, not merely by shape. Array/Map/Set/Error/typed-array subclasses +/// have their own dedicated instance/prototype modeling and don't reach this +/// fallback the same way. +fn reserved_native_parent_prototype_bits(parent_id: u32) -> Option { + const CLASS_ID_EVENT_EMITTER: u32 = 0xFFFF0076; + const CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE: u32 = 0xFFFF0077; + let (module, symbol) = match parent_id { + CLASS_ID_EVENT_EMITTER => ("events", "EventEmitter"), + CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE => ("events", "EventEmitterAsyncResource"), + _ => return None, + }; + let func_value = super::super::native_module::bound_native_callable_export_value(module, symbol); + let parent_proto = super::function_prototype::js_function_prototype_value_for_read(func_value); + class_parent_prototype_bits(parent_proto) +} + pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { // #7757: a specialization answers with its generic's prototype. let class_id = decl_prototype_identity_id(class_id); @@ -1046,7 +1085,22 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { .and_then(|parent_id| { let parent_proto = class_decl_prototype_value(parent_id); let parent_bits = parent_proto.to_bits(); - ((parent_bits >> 48) == 0x7FFD).then_some(parent_bits) + if (parent_bits >> 48) == 0x7FFD { + return Some(parent_bits); + } + // #10599: `parent_id` may be a RESERVED native-builtin class id + // rather than a declared class -- `builtin_parent_reserved_class_id` + // in perry-codegen wires this edge for `class Sub extends + // EventEmitter {}`, which has no `js_register_class_name` + // registration of its own. `class_decl_prototype_value` bails + // immediately for such an id (`class_name_for_id` is `None`), so + // without this fallback the lookup above always misses and + // execution falls through to the runtime-function-valued branch + // below, which also misses (there is no dynamic-parent VALUE for + // a statically-resolved reserved id) -- landing `Sub.prototype`'s + // `[[Prototype]]` on `Object.prototype` instead of + // `EventEmitter.prototype`. + reserved_native_parent_prototype_bits(parent_id) }); if registered_parent_proto.is_some() { registered_parent_proto From de91aa64639fa047bf1645f4ba93eabf7704b50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 10:52:48 +0000 Subject: [PATCH 16/30] fix(codegen): register EventEmitterAsyncResource as a reserved extends-parent id builtin_parent_reserved_class_id (perry-codegen) already gained an "EventEmitter" => 0xFFFF0076 entry (#10592), but not its AsyncResource variant: class Sub extends EventEmitterAsyncResource {} left get_parent_class_id(Sub) unresolved entirely (no parent-edge call is ever emitted), so the perry-runtime getPrototypeOf-identity fallback for reserved native-builtin parents (#10599) never runs for it -- the same gap #10592 closed for plain EventEmitter, one id over. --- crates/perry-codegen/src/expr/instance_misc1.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index b09695285d..536058101e 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -130,6 +130,14 @@ pub(crate) fn builtin_parent_reserved_class_id(name: &str) -> Option { // which don't recognize a genuine subclass ObjectHeader. Keep in // sync with `CLASS_ID_EVENT_EMITTER` there. "EventEmitter" => 0xFFFF0076, + // #10599: `class Sub extends EventEmitterAsyncResource {}` needs the + // same parent edge as plain EventEmitter above -- without it, + // `get_parent_class_id` never resolves for this id, and the + // getPrototypeOf-identity fallback in + // perry-runtime/src/object/class_registry/state.rs + // (`reserved_native_parent_prototype_bits`) never runs. Keep in sync + // with `CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE` there. + "EventEmitterAsyncResource" => 0xFFFF0077, _ => return None, }) } From 14bec5483fec8760a6ffa211e0941ab06deb2de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 10:52:48 +0000 Subject: [PATCH 17/30] test(gap): cover #10599's EventEmitter subclass prototype identity Covers: direct subclass with field+ctor, fieldless no-ctor subclass, two-level (indirect) subclass, unnamed and named-via-indirection class expressions, EventEmitterAsyncResource, in/for-in walking the same chain, own-enumeration non-regression, dispatch still works, an instanceof-still-holds control (guards #10592), and a class-extends-Array control (dedicated ArrayHeader path, unaffected by this fix). Verified: fails on the runtime fix alone (state.rs reverted, standalone) with every getPrototypeOf/instanceof/`in` assertion false where Node says true; passes with both the runtime fix and both codegen table entries. Two pre-existing, unrelated gaps hit while writing this were deliberately left uncovered (documented inline, not fixed here): EventEmitterAsyncResource.prototype's own [[Prototype]] does not chain to EventEmitter.prototype (a native-to-native link, not a user `extends` subclass), and Object.keys(new Sub()) leaks EventEmitter's prototype methods as literal own enumerable instance properties instead of Node's real _events/_eventsCount/_maxListeners own fields (CLAUDE.md "Native base-class subclassing -- a native base's surface is installed at super() time"). Both reproduce identically with the fix reverted, so neither is caused by it. --- ...p_10599_eventemitter_prototype_identity.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 test-files/test_gap_10599_eventemitter_prototype_identity.ts diff --git a/test-files/test_gap_10599_eventemitter_prototype_identity.ts b/test-files/test_gap_10599_eventemitter_prototype_identity.ts new file mode 100644 index 0000000000..f3af58e1f6 --- /dev/null +++ b/test-files/test_gap_10599_eventemitter_prototype_identity.ts @@ -0,0 +1,170 @@ +// #10599: `class Sub extends EventEmitter {}` left +// `Object.getPrototypeOf(Sub.prototype) !== EventEmitter.prototype`. Split out +// from #10556 (PR #10592 fixed `new Sub() instanceof EventEmitter`, a +// different mechanism — the class-chain parent edge — that does not depend on +// prototype-OBJECT identity). Root cause: `class_decl_prototype_value` +// recurses on a registered parent class id via itself, which bails for a +// RESERVED native-builtin id (no `js_register_class_name` registration), so +// the link silently fell through to `Object.prototype`. +import { EventEmitter, EventEmitterAsyncResource } from "node:events"; + +// --- direct subclass, with a field + constructor ----------------------------------- +class Sub extends EventEmitter { + tag = "sub"; + constructor() { + super(); + this.tag = "sub-ctor"; + } +} + +console.log( + "getPrototypeOf(Sub.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(Sub.prototype) === EventEmitter.prototype, +); +console.log("typeof EventEmitter.prototype:", typeof EventEmitter.prototype); +console.log("Sub.prototype instanceof EventEmitter:", Sub.prototype instanceof EventEmitter); +console.log("new Sub() instanceof EventEmitter:", new Sub() instanceof EventEmitter); +console.log( + "getPrototypeOf(EventEmitter.prototype) === Object.prototype:", + Object.getPrototypeOf(EventEmitter.prototype) === Object.prototype, +); +console.log( + "getPrototypeOf(new Sub()) === Sub.prototype:", + Object.getPrototypeOf(new Sub()) === Sub.prototype, +); + +// --- fieldless subclass, no constructor -------------------------------------------- +class Fieldless extends EventEmitter {} +console.log( + "fieldless getPrototypeOf(Fieldless.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(Fieldless.prototype) === EventEmitter.prototype, +); +console.log("new Fieldless() instanceof EventEmitter:", new Fieldless() instanceof EventEmitter); + +// --- two-level subclass -------------------------------------------------------------- +class Mid extends EventEmitter { + mid = true; +} +class Grandchild extends Mid { + gc = true; +} +console.log( + "getPrototypeOf(Mid.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(Mid.prototype) === EventEmitter.prototype, +); +console.log( + "getPrototypeOf(Grandchild.prototype) === Mid.prototype:", + Object.getPrototypeOf(Grandchild.prototype) === Mid.prototype, +); +console.log( + "getPrototypeOf(getPrototypeOf(Grandchild.prototype)) === EventEmitter.prototype:", + Object.getPrototypeOf(Object.getPrototypeOf(Grandchild.prototype)) === EventEmitter.prototype, +); +const gc = new Grandchild(); +console.log("grandchild instanceof EventEmitter:", gc instanceof EventEmitter); +console.log("grandchild instanceof Mid:", gc instanceof Mid); +console.log( + "getPrototypeOf(new Grandchild()) === Grandchild.prototype:", + Object.getPrototypeOf(new Grandchild()) === Grandchild.prototype, +); + +// --- class expression ----------------------------------------------------------------- +const ExprSub = class extends EventEmitter { + expr = true; +}; +console.log( + "expr getPrototypeOf(ExprSub.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(ExprSub.prototype) === EventEmitter.prototype, +); +console.log("new ExprSub() instanceof EventEmitter:", new ExprSub() instanceof EventEmitter); + +// An UNNAMED, fieldless class expression — the exact minimal shape (no +// literal top-level `class Foo extends EventEmitter {}` declaration to key +// off of, no fields, no constructor). +const Sub3 = class extends EventEmitter {}; +console.log( + "unnamed fieldless getPrototypeOf(Sub3.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(Sub3.prototype) === EventEmitter.prototype, +); +console.log("new Sub3() instanceof EventEmitter:", new Sub3() instanceof EventEmitter); + +// A named class expression, assigned through an extra indirection (so codegen +// cannot special-case a literal top-level `class Foo extends EventEmitter {}` +// declaration shape). +function makeSubclass() { + return class NamedExpr extends EventEmitter {}; +} +const IndirectSub = makeSubclass(); +console.log( + "indirect getPrototypeOf(IndirectSub.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(IndirectSub.prototype) === EventEmitter.prototype, +); + +// --- EventEmitterAsyncResource variant -------------------------------------------------- +class SubAsync extends EventEmitterAsyncResource {} +console.log( + "getPrototypeOf(SubAsync.prototype) === EventEmitterAsyncResource.prototype:", + Object.getPrototypeOf(SubAsync.prototype) === EventEmitterAsyncResource.prototype, +); +// NOT covered here: `Object.getPrototypeOf(EventEmitterAsyncResource.prototype) +// === EventEmitter.prototype` -- that is EventEmitterAsyncResource's OWN +// internal chain (a native-builtin-to-native-builtin link set up wherever its +// `.prototype` is first materialized), not a user `extends` subclass. It is a +// separate, pre-existing gap (Perry answers `false`, Node `true`) outside +// this fix's scope -- `class_decl_prototype_value` never runs for it at all, +// since EventEmitterAsyncResource itself has no declared-class registration. +console.log( + "SubAsync.prototype instanceof EventEmitterAsyncResource:", + SubAsync.prototype instanceof EventEmitterAsyncResource, +); + +// --- control: a builtin whose subclass instance/prototype modeling is NOT this +// fallback (Array has its own dedicated ArrayHeader-based path) — guards the +// "Array/Map/Set/Error/typed-array subclasses don't reach this fallback" +// claim in the fix's own reasoning so a future change that breaks it fails +// loudly here instead of silently. +class ArraySub extends Array {} +console.log( + "getPrototypeOf(ArraySub.prototype) === Array.prototype:", + Object.getPrototypeOf(ArraySub.prototype) === Array.prototype, +); + +// --- `in` / `for...in` — the two-prototype-path weakness CLAUDE.md calls out ---------- +// (CLASS_PROTOTYPE_OBJECTS vs CLASS_DECL_PROTOTYPE_OBJECTS disagreeing about +// the same chain). `in` and `for...in` walk the exact same +// `[[Prototype]]` link `Object.getPrototypeOf` does, so fixing the identity +// above must also fix these without a separate code path. +console.log("'on' in Sub.prototype:", "on" in Sub.prototype); +console.log("'on' in new Sub():", "on" in new Sub()); +console.log("'emit' in new Sub():", "emit" in new Sub()); +console.log("'addListener' in new Sub():", "addListener" in new Sub()); + +const forInKeys: string[] = []; +for (const k in new Sub()) forInKeys.push(k); +console.log("for...in includes 'on':", forInKeys.includes("on")); +console.log("for...in includes 'emit':", forInKeys.includes("emit")); +console.log("for...in includes own field 'tag':", forInKeys.includes("tag")); + +// NOT covered here: `Object.keys(new Sub())`. It diverges from Node +// regardless of this fix (verified identically wrong with the fix reverted): +// Perry's native-base `super()` handling installs EventEmitter's methods +// (`on`, `emit`, ...) as literal OWN enumerable properties on the instance +// (CLAUDE.md "Known-weak areas: Native base-class subclassing -- a native +// base's surface is installed at super() time"), and never sets the +// `_events`/`_eventsCount`/`_maxListeners` own fields Node's real +// EventEmitter constructor does. That is an own-property-enumeration defect, +// orthogonal to the [[Prototype]] CHAIN identity this fix corrects. + +// --- the emitter still works (identity link must not disturb dispatch) --------------- +const s = new Sub(); +let fired = 0; +s.on("ping", (n: number) => (fired += n)); +s.emit("ping", 4); +console.log("sub fired:", fired, "listeners:", s.listenerCount("ping")); +console.log("sub.tag:", s.tag); + +// --- own-key enumeration on EventEmitter.prototype survives identity too ------------- +console.log( + "EventEmitter.prototype.constructor === EventEmitter:", + (EventEmitter.prototype as any).constructor === EventEmitter, +); From 55413129a2ad3d2c15cb54c99646f8ae9c0ce359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 10:54:12 +0000 Subject: [PATCH 18/30] docs(changelog): note the EventEmitter subclass prototype-identity fix (#10614) --- .../10614-eventemitter-prototype-identity.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 changelog.d/10614-eventemitter-prototype-identity.md diff --git a/changelog.d/10614-eventemitter-prototype-identity.md b/changelog.d/10614-eventemitter-prototype-identity.md new file mode 100644 index 0000000000..54b7d07d4b --- /dev/null +++ b/changelog.d/10614-eventemitter-prototype-identity.md @@ -0,0 +1,25 @@ +### Fixed + +- **`class Sub extends EventEmitter {}` now links `Sub.prototype`'s `[[Prototype]]` to the real + `EventEmitter.prototype` object (#10599).** `Object.getPrototypeOf(Sub.prototype) === EventEmitter.prototype` + read `false`: `class_decl_prototype_value` resolves a registered parent class id by recursing into itself, + which bails for a RESERVED native-builtin parent id (`EventEmitter` has no `js_register_class_name` + registration of its own), so the link silently fell through to `Object.prototype`. `instanceof` and the + class-chain walk (#10592) already worked — this is specifically the `[[Prototype]]` object identity, which is + a separate mechanism. + + The fix resolves the real, closure-identity-keyed prototype through `js_function_prototype_value_for_read` — + the same helper the existing runtime-function-valued-parent branch already used — so the identity comparison + holds, not merely the shape. It only takes effect together with a codegen-side parent-edge registration + (`builtin_parent_reserved_class_id`): #10592 added that entry for plain `EventEmitter`; this PR adds the + missing `EventEmitterAsyncResource` counterpart, without which `get_parent_class_id` never resolves and the + runtime fix is unreachable. + + Verified with the runtime fix reverted (twice, independently) that every `getPrototypeOf`/`instanceof`/`in` + assertion in the new gap test reads `false` where Node reads `true`, and with it restored the test is + byte-identical to Node 26.5.1. Two pre-existing, unrelated gaps surfaced while writing the test and were left + out of it: `EventEmitterAsyncResource.prototype`'s own chain to `EventEmitter.prototype` (a + native-to-native link, not a user subclass), and `Object.keys(new Sub())` leaking `EventEmitter.prototype`'s + methods as own enumerable instance properties instead of Node's real `_events`/`_eventsCount`/`_maxListeners` + fields (CLAUDE.md's documented "native base's surface is installed at `super()` time" weak area) — both + reproduce identically with the fix reverted, so neither is caused by it. From 9b88ef87d03b9c62225c5ebdbb5af9ab42c0b48a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 11:33:37 +0000 Subject: [PATCH 19/30] fix(hir): capture class member enclosing vars by reference, not snapshot Class members nested in a function or CommonJS module body captured enclosing var/let bindings by value snapshot instead of by reference: a method saw the value the var had at class-declaration time, and writes from constructors/static methods to a captured var were silently lost. The desugar_shared_mutable_captures pass (#5951's box-sharing machinery) decided whether a captured id was safe to box by counting Let declarations per LocalId and requiring exactly one; a var is declared twice in HIR (a body-entry predefine slot, then the declaration statement itself), so every var capture was rejected as ambiguous and fell back to the stale value-snapshot path. Two functions that each declared a same-named var plus a same-named class could also collide. Replace the declaration counter with a DeclCensus/CensusWalker that walks a region in execution order and asks whether an id denotes ONE binding (a single declaration, or several redeclarations in the same closure scope under the same name where the first dominates) rather than requiring literally one declaration. Redeclarations of an already-captured id are demoted so the box, not a fresh local, is written. Captured cells now propagate to nested classes explicitly. Fixes #10485 Fixes #10489 --- .../perry-hir/src/destructuring/var_decl.rs | 32 + crates/perry-hir/src/lower/context.rs | 2 +- crates/perry-hir/src/lower/expr_assign.rs | 7 +- crates/perry-hir/src/lower/expr_member.rs | 7 +- crates/perry-hir/src/lower/expr_new.rs | 24 +- .../src/lower/lower_expr/arm_class.rs | 1 + .../perry-hir/src/lower/lowering_context.rs | 61 +- .../src/lower/shared_mutable_capture.rs | 696 +++++++++++++++--- crates/perry-hir/src/lower/stmt.rs | 2 + crates/perry-hir/src/lower/tests.rs | 1 + .../lower/tests/class_member_var_captures.rs | 213 ++++++ .../class_member_var_captures_10485.cjs | 27 + ...est_gap_10485_class_member_var_captures.ts | 179 +++++ 13 files changed, 1148 insertions(+), 104 deletions(-) create mode 100644 crates/perry-hir/src/lower/tests/class_member_var_captures.rs create mode 100644 test-files/_helpers/class_member_var_captures_10485.cjs create mode 100644 test-files/test_gap_10485_class_member_var_captures.ts diff --git a/crates/perry-hir/src/destructuring/var_decl.rs b/crates/perry-hir/src/destructuring/var_decl.rs index 920a424882..21a52f018e 100644 --- a/crates/perry-hir/src/destructuring/var_decl.rs +++ b/crates/perry-hir/src/destructuring/var_decl.rs @@ -257,6 +257,19 @@ pub(crate) fn lower_var_decl_with_destructuring( // Alias / prototype / static-method tracking for the freshly- // bound identifier (extracted to `alias_tracking`). track_decl_aliases(ctx, decl, &name, id, &init); + // #10489: remember WHICH class this binding holds, so a `new ()` + // resolves to it even when another binding claimed the same name. + if let Some(key) = init.as_ref().and_then(class_expr_value_key) { + if decl + .init + .as_deref() + .is_some_and(crate::lower::expr_assign::rhs_accepts_assignment_name) + && ctx.inferred_class_bindings.contains(&name) + { + ctx.inferred_class_bindings + .record_binding(id, key.to_string()); + } + } // `with (o) { var foo = v; }` — the binding `foo` is hoisted to // the enclosing var scope, but the *initialisation* is a normal // PutValue under the with environment: when `o` has a `foo` @@ -528,3 +541,22 @@ pub(crate) fn lower_var_decl_with_destructuring( Ok(result) } + +/// The registration key of the class a lowered class-expression initializer +/// yields: a bare `ClassRef`/`ClassExprFresh`, or the tail of the sequence +/// `lower_class_expr` wraps it in (parent registration, computed names, the +/// #6654 capture-owner `LocalSet(owner, fresh), LocalGet(owner)` pair). +fn class_expr_value_key(expr: &Expr) -> Option<&str> { + match expr { + Expr::ClassRef(key) => Some(key), + Expr::ClassExprFresh { template, .. } => Some(template), + Expr::Sequence(items) => match items.as_slice() { + [.., Expr::LocalSet(owner, value), Expr::LocalGet(read)] if owner == read => { + class_expr_value_key(value) + } + [.., last] => class_expr_value_key(last), + [] => None, + }, + _ => None, + } +} diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 3d4e00a380..6d1da1665b 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -127,7 +127,7 @@ impl LoweringContext { class_display_names: HashMap::new(), gen_param_prologue_len: HashMap::new(), assignment_inferred_name: None, - inferred_class_bindings: std::collections::HashSet::new(), + inferred_class_bindings: Default::default(), closure_source_text: HashMap::new(), class_source_text: HashMap::new(), func_return_native_instances: Vec::new(), diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index a947b6cdf9..83bda62ad3 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -829,9 +829,10 @@ fn lower_assignment_target( && ctx.lookup_func(&cls_name).is_none() { None - } else if ctx.lookup_local(&cls_name).is_some() - && !ctx.inferred_class_bindings.contains(cls_name.as_str()) - { + } else if ctx.lookup_local(&cls_name).is_some_and(|local| { + ctx.inferred_class_bindings.class_key_for(local, &cls_name) + != Some(cls_name.as_str()) + }) { // A lexical local shadows any same-named // module-scope class for this write too // (wall 7's disease, 4th surface): the diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index d6f5b3f391..08bc5cfc1b 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -1064,8 +1064,11 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re // so reading through the shared template's `StaticFieldGet` loses both // its value and its property-presence semantics. This mirrors the // static-call guard in `expr_call/static_and_instance.rs`. - let local_shadows_class = ctx.lookup_local(source_name).is_some() - && !ctx.inferred_class_bindings.contains(source_name); + let local_shadows_class = ctx.lookup_local(source_name).is_some_and(|local| { + ctx.inferred_class_bindings + .class_key_for(local, source_name) + != Some(source_name) + }); let obj_name = ctx.resolve_class_name(source_name); if !local_shadows_class && ctx.lookup_class(&obj_name).is_some() { if let ast::MemberProp::Ident(prop_ident) = &member.prop { diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 573bcf30b4..0d1b42f541 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -1543,15 +1543,21 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // attach), which the generic dynamic construct does not. // A shadowing local over an UNRELATED same-named class // declaration has no alias entry, so wall 7's reroute keeps - // firing. - let local_is_class_alias = - ctx.inferred_class_bindings.contains(class_name.as_str()); - if !local_is_class_alias { - return Ok(Expr::NewDynamic { - callee: Box::new(Expr::LocalGet(local_id)), - args, - byte_offset: new_byte_offset, - }); + // firing. When two class expressions claimed this name, the + // binding's OWN class is used (#10489), never the first + // claimant's. + match ctx + .inferred_class_bindings + .class_key_for(local_id, &class_name) + { + Some(key) => class_name = key.to_string(), + None => { + return Ok(Expr::NewDynamic { + callee: Box::new(Expr::LocalGet(local_id)), + args, + byte_offset: new_byte_offset, + }); + } } } // Issue #838 followup (b): when `` is NOT a real diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index 273e1f1d7f..4bd9822d1d 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -92,6 +92,7 @@ pub(crate) fn lower_class_expr( // for this same expression. Record the BINDING name so // `new ()` keeps the static construct path. ctx.inferred_class_bindings.insert(name.clone()); + ctx.inferred_class_bindings.mark_contested(&name); display_override = Some(name.clone()); format!("{}__anon_dup_{}", name, ctx.fresh_class()) } diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index d00b76034f..aac869aceb 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -12,6 +12,61 @@ use std::collections::{HashMap, HashSet}; use crate::ir::*; use crate::ClassAccessorNames; +/// Binding names whose class registration was created by the binding's own +/// class-expression initializer (see `LoweringContext::inferred_class_bindings`). +/// +/// The name set alone cannot tell two same-named bindings apart. When a second +/// class expression infers an already-claimed name (the #5592 `__anon_dup_` +/// arm: `function a() { const K = class {…} }` next to `function b() { const +/// K = class {…} }`), `new K()` inside `b` resolved `K` to `a`'s class and +/// appended `a`'s capture ids, so `b`'s constructor ran against another +/// function's locals (#10489). Such CONTESTED names resolve per binding local: +/// a declaration records which class its local holds, and an unrecorded local +/// constructs its runtime value. +#[derive(Debug, Default)] +pub(crate) struct InferredClassBindings { + names: HashSet, + contested: HashSet, + by_local: HashMap, +} + +impl InferredClassBindings { + pub(crate) fn contains(&self, name: &str) -> bool { + self.names.contains(name) + } + + pub(crate) fn insert(&mut self, name: String) -> bool { + self.names.insert(name) + } + + pub(crate) fn remove(&mut self, name: &str) -> bool { + self.names.remove(name) + } + + /// A second class expression claimed `name` under a disambiguated key. + pub(crate) fn mark_contested(&mut self, name: &str) { + self.contested.insert(name.to_string()); + } + + /// The declaration of binding `local` evaluated the class registered as + /// `class_key`. + pub(crate) fn record_binding(&mut self, local: LocalId, class_key: String) { + self.by_local.insert(local, class_key); + } + + /// The registration key of the class the in-scope local `local` (a binding + /// named `name`) provably holds, or `None` when it must be treated as an + /// arbitrary runtime value. + pub(crate) fn class_key_for(&self, local: LocalId, name: &str) -> Option<&str> { + if let Some(key) = self.by_local.get(&local) { + return Some(key.as_str()); + } + (self.names.contains(name) && !self.contested.contains(name)) + .then(|| self.names.get(name).map(String::as_str)) + .flatten() + } +} + #[derive(Debug, Clone, Copy)] pub(crate) struct WithEnvFrame { pub(crate) local_id: LocalId, @@ -361,8 +416,10 @@ pub struct LoweringContext { /// bind name too). At a `new ()` site, such a name's local provably /// holds that same class, so the static construct path (with its exact /// builtin-parent handling) is correct; any OTHER in-scope local shadows - /// whatever same-named class exists and must construct dynamically. - pub(crate) inferred_class_bindings: std::collections::HashSet, + /// whatever same-named class exists and must construct dynamically. Names + /// claimed by more than one class expression resolve by binding identity + /// instead (see [`InferredClassBindings::class_key_for`]). + pub(crate) inferred_class_bindings: InferredClassBindings, /// #4101: original source text keyed by FuncId, captured by slicing the /// module source against each function's AST span at lowering time. /// Flushed into `Module.closure_source_text` alongside `pending_functions`. diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index 144cb4d05a..de84c599cd 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -75,6 +75,7 @@ fn is_cap_name_of(name: &str, ids: &HashSet) -> bool { struct BodySharedCaptures { ids: HashSet, by_class: HashMap>, + census: DeclCensus, } pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { @@ -112,27 +113,19 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { let fn_shared: Vec = module .functions .iter() - .map(|f| detect_shared_in_body(&f.body, &classes)) + .map(|f| detect_shared_in_body(&f.params, &f.body, &classes)) .collect(); - let init_shared = detect_shared_in_body(&module.init, &classes); + let init_shared = detect_shared_in_body(&[], &module.init, &classes); (fn_shared, init_shared) }; - // Keep only ids that are UNAMBIGUOUS within their body (declared exactly - // once across deep `Let`s + nested closure params — see - // `retain_unambiguous`). Nested closures restart their id spaces, so a - // numeric rewrite over the whole body is only sound for unique ids. - for (f, shared) in module.functions.iter().zip(fn_shared.iter_mut()) { + // Keep only ids that denote ONE binding within their body (see + // `DeclCensus::is_one_binding`). Nested closures restart their id spaces, + // so a numeric rewrite over the whole body is only sound for those. + for shared in fn_shared.iter_mut() { if shared.ids.is_empty() { continue; } - let mut counts: HashMap = HashMap::new(); - for p in &f.params { - *counts.entry(p.id).or_default() += 1; - } - for st in &f.body { - collect_declared_counts_stmt(st, &mut counts); - } - retain_unambiguous(&mut shared.ids, &counts); + retain_unambiguous(&mut shared.ids, &shared.census); let retained = &shared.ids; for ids in shared.by_class.values_mut() { ids.retain(|id| retained.contains(id)); @@ -140,11 +133,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { shared.by_class.retain(|_, ids| !ids.is_empty()); } if !init_shared.ids.is_empty() { - let mut counts: HashMap = HashMap::new(); - for st in &module.init { - collect_declared_counts_stmt(st, &mut counts); - } - retain_unambiguous(&mut init_shared.ids, &counts); + retain_unambiguous(&mut init_shared.ids, &init_shared.census); let retained = &init_shared.ids; for ids in init_shared.by_class.values_mut() { ids.retain(|id| retained.contains(id)); @@ -167,6 +156,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { .extend(ids.iter().copied()); } } + propagate_cells_to_nested_classes(module, &mut shared_by_class); // ---- declaring bodies: rewrite with ONLY the ids detected in them ------- for (f, shared) in module.functions.iter_mut().zip(fn_shared.iter()) { @@ -192,6 +182,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { } }) .collect(); + demote_var_redeclarations(&f.params, &mut f.body, ids); rewrite_stmts(&mut f.body, ids, ids); for id in shared_params.into_iter().rev() { f.body.insert( @@ -205,6 +196,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { } } if !init_shared.ids.is_empty() { + demote_var_redeclarations(&[], &mut module.init, &init_shared.ids); rewrite_stmts(&mut module.init, &init_shared.ids, &init_shared.ids); } @@ -257,24 +249,22 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { } if !ctor_ids.is_empty() { // Uniqueness over the whole ctor+fields region (one scope). - let mut counts: HashMap = HashMap::new(); + let mut census = DeclCensus::default(); + let mut walker = CensusWalker::new(&mut census); + let scope = walker.open_scope(); if let Some(ctor) = &c.constructor { - for p in &ctor.params { - *counts.entry(p.id).or_default() += 1; - } - for st in &ctor.body { - collect_declared_counts_stmt(st, &mut counts); - } + walker.params(&ctor.params, scope); + walker.stmts(&ctor.body, scope, 0, true); } for f in &c.fields { if let Some(init) = &f.init { - collect_declared_counts_expr(init, &mut counts); + walker.expr(init, scope, 0); } if let Some(key) = &f.key_expr { - collect_declared_counts_expr(key, &mut counts); + walker.expr(key, scope, 0); } } - retain_unambiguous(&mut ctor_ids, &counts); + retain_unambiguous(&mut ctor_ids, &census); } if !ctor_ids.is_empty() { if let Some(ctor) = &mut c.constructor { @@ -338,8 +328,8 @@ fn collect_fn_target_ids(f: &Function, targets: &HashSet, out: &mut Has } /// Rewrite one lifted member body with ONLY its own rebind ids — and only -/// those that are UNAMBIGUOUS within the member (declared exactly once across -/// its params and deep `Let`s/closure params; see `retain_unambiguous`). +/// those that are UNAMBIGUOUS within the member (one binding across its params +/// and deep `Let`s/closure params; see `retain_unambiguous`). fn rewrite_member_scoped( f: &mut Function, targets: &HashSet, @@ -350,20 +340,14 @@ fn rewrite_member_scoped( if ids.is_empty() { return; } - let mut counts: HashMap = HashMap::new(); - for p in &f.params { - *counts.entry(p.id).or_default() += 1; - } - for s in &f.body { - collect_declared_counts_stmt(s, &mut counts); - } - retain_unambiguous(&mut ids, &counts); + let census = DeclCensus::of_body(&f.params, &f.body); + retain_unambiguous(&mut ids, &census); if !ids.is_empty() { rewrite_stmts(&mut f.body, no_shared, &ids); } } -/// Drop every id that is declared more than once in the rewritten region. +/// Drop every id that does not denote exactly ONE binding in the region. /// /// LocalIds restart per closure scope (#5143 family): inside a CJS module /// wrapper the whole module body is ONE function whose nested closures reuse @@ -374,40 +358,366 @@ fn rewrite_member_scoped( /// every route of the Next.js standalone server (#6089). An ambiguous id is /// skipped: its capture stays a split cell (the lesser, pre-#6054 behavior) /// instead of corrupting unrelated code. -fn retain_unambiguous(ids: &mut HashSet, counts: &HashMap) { +fn retain_unambiguous(ids: &mut HashSet, census: &DeclCensus) { if std::env::var("PERRY_5951_TRACE").as_deref() == Ok("1") { let dropped: Vec = ids .iter() .copied() - .filter(|id| counts.get(id).copied().unwrap_or(0) != 1) + .filter(|id| !census.is_one_binding(*id)) .collect(); if !dropped.is_empty() { eprintln!("[5951] skipped ambiguous ids {dropped:?}"); } } - ids.retain(|id| counts.get(id).copied().unwrap_or(0) == 1); + ids.retain(|id| census.is_one_binding(*id)); } -/// Count declarations per id across a region: `Let`s plus nested closure -/// PARAMS (which `collect_let_names_*` ignores), descending into closures. -fn collect_declared_counts_stmt(stmt: &Stmt, out: &mut HashMap) { - if let Stmt::Let { id, .. } = stmt { - *out.entry(*id).or_default() += 1; - } - for_each_child_stmt(stmt, &mut |s| collect_declared_counts_stmt(s, out)); - for_each_top_expr(stmt, &mut |e| collect_declared_counts_expr(e, out)); +/// One declaration of a `LocalId` inside a detection region. +#[derive(Debug)] +struct DeclSite { + /// Closure scope the declaration lives in (0 = the region's own body). + scope: u32, + name: String, + /// A parameter, or a `Let` that is a direct statement of its scope's body + /// outside any loop: it runs once, before every later site of that scope. + dominating: bool, } -fn collect_declared_counts_expr(expr: &Expr, out: &mut HashMap) { - if let Expr::Closure { params, body, .. } = expr { +/// Every declaration of every id in a region (params, `Let`s, nested closure +/// params and bodies), in execution order, plus the `var` re-declarations that +/// are real writes to a class-captured binding. +#[derive(Default, Debug)] +struct DeclCensus { + sites: HashMap>, + /// Ids re-declared (`var x = v` after the body-entry `var` slot) at a point + /// where a class has ALREADY captured the binding, or inside a loop that + /// can re-run the declaration after a capture. Those declarations are + /// assignments to a captured binding, exactly like `x = v`. + late_redeclared: HashSet, +} + +impl DeclCensus { + fn of_body(params: &[Param], body: &[Stmt]) -> Self { + let mut census = DeclCensus::default(); + let mut walker = CensusWalker::new(&mut census); + let scope = walker.open_scope(); + walker.params(params, scope); + walker.stmts(body, scope, 0, true); + census + } + + /// Is `id` exactly one binding in this region? + /// + /// A single declaration trivially is. Several are one binding only when + /// they all sit in the SAME closure scope under the SAME name and the first + /// dominates the rest. That is the shape of a `var`: lowering declares it at + /// body entry (`predefine_var_bindings_in_function_body`) or reuses a + /// same-named parameter, and every `var x = v` statement re-declares the + /// same id (#10485/#10489). Treating those as distinct bindings left every + /// `var` captured by a class on the value-snapshot path, so class members + /// never saw later writes and their own writes were lost. Declarations of + /// one id in different closure scopes stay ambiguous (#6089). + fn is_one_binding(&self, id: LocalId) -> bool { + match self.sites.get(&id).map(Vec::as_slice) { + None | Some([]) => false, + Some([_]) => true, + Some([first, rest @ ..]) => { + first.dominating + && rest + .iter() + .all(|site| site.scope == first.scope && site.name == first.name) + } + } + } +} + +/// Execution-order walk that fills a [`DeclCensus`]. Closure bodies open a new +/// scope with a fresh loop depth: each closure invocation gets its own +/// bindings, so an enclosing loop does not re-run a closure-local declaration. +struct CensusWalker<'a> { + census: &'a mut DeclCensus, + next_scope: u32, + /// Ids captured by a class registration seen so far. + captured: HashSet, +} + +impl<'a> CensusWalker<'a> { + fn new(census: &'a mut DeclCensus) -> Self { + CensusWalker { + census, + next_scope: 0, + captured: HashSet::new(), + } + } + + fn open_scope(&mut self) -> u32 { + let scope = self.next_scope; + self.next_scope += 1; + scope + } + + fn declare(&mut self, id: LocalId, name: &str, scope: u32, dominating: bool) { + self.census.sites.entry(id).or_default().push(DeclSite { + scope, + name: name.to_string(), + dominating, + }); + } + + fn params(&mut self, params: &[Param], scope: u32) { for p in params { - *out.entry(p.id).or_default() += 1; + if let Some(default) = &p.default { + self.expr(default, scope, 0); + } + self.declare(p.id, &p.name, scope, true); } - for s in body { - collect_declared_counts_stmt(s, out); + } + + fn stmts(&mut self, stmts: &[Stmt], scope: u32, loop_depth: u32, top: bool) { + for s in stmts { + self.stmt(s, scope, loop_depth, top); + } + } + + fn stmt(&mut self, stmt: &Stmt, scope: u32, loop_depth: u32, top: bool) { + match stmt { + Stmt::Let { id, name, init, .. } => { + if let Some(e) = init { + self.expr(e, scope, loop_depth); + } + let redeclaration = self.census.sites.contains_key(id); + if redeclaration && init.is_some() && (loop_depth > 0 || self.captured.contains(id)) + { + self.census.late_redeclared.insert(*id); + } + self.declare(*id, name, scope, top && loop_depth == 0); + } + Stmt::Expr(e) | Stmt::Throw(e) | Stmt::Return(Some(e)) => { + self.expr(e, scope, loop_depth) + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.expr(condition, scope, loop_depth); + self.stmts(then_branch, scope, loop_depth, false); + if let Some(e) = else_branch { + self.stmts(e, scope, loop_depth, false); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + self.expr(condition, scope, loop_depth + 1); + self.stmts(body, scope, loop_depth + 1, false); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + self.stmt(i, scope, loop_depth, false); + } + if let Some(c) = condition { + self.expr(c, scope, loop_depth + 1); + } + self.stmts(body, scope, loop_depth + 1, false); + if let Some(u) = update { + self.expr(u, scope, loop_depth + 1); + } + } + Stmt::Labeled { body, .. } => self.stmt(body, scope, loop_depth, false), + Stmt::Try { + body, + catch, + finally, + } => { + self.stmts(body, scope, loop_depth, false); + if let Some(c) = catch { + self.stmts(&c.body, scope, loop_depth, false); + } + if let Some(fin) = finally { + self.stmts(fin, scope, loop_depth, false); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + self.expr(discriminant, scope, loop_depth); + for case in cases { + if let Some(t) = &case.test { + self.expr(t, scope, loop_depth); + } + self.stmts(&case.body, scope, loop_depth, false); + } + } + Stmt::Return(None) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } + + fn expr(&mut self, expr: &Expr, scope: u32, loop_depth: u32) { + match expr { + Expr::Closure { params, body, .. } => { + let inner = self.open_scope(); + self.params(params, inner); + self.stmts(body, inner, 0, true); + return; + } + Expr::RegisterClassCaptures { captures, .. } + | Expr::ClassExprFresh { + captured_args: captures, + .. + } => { + for capture in captures { + if let Expr::LocalGet(id) = capture { + self.captured.insert(*id); + } + } + } + _ => {} } + walk_expr_children(expr, &mut |e| self.expr(e, scope, loop_depth)); + } +} + +/// Turn every re-declaration of a shared id into a plain assignment, so the +/// rewrite below writes the binding's EXISTING cell (`x[0] = v`) instead of +/// minting a new one that classes captured earlier would never see. A `var x;` +/// re-declaration without an initializer does not touch the binding at all. +/// Only ids `DeclCensus::is_one_binding` accepted reach here, so the first +/// site seen in execution order is the dominating declaration. +fn demote_var_redeclarations(params: &[Param], body: &mut [Stmt], ids: &HashSet) { + let mut seen: HashSet = params + .iter() + .map(|p| p.id) + .filter(|id| ids.contains(id)) + .collect(); + for s in body.iter_mut() { + demote_redeclarations_stmt(s, ids, &mut seen); } - walk_expr_children(expr, &mut |e| collect_declared_counts_expr(e, out)); +} + +fn demote_redeclarations_stmt( + stmt: &mut Stmt, + ids: &HashSet, + seen: &mut HashSet, +) { + if let Stmt::Let { id, init, .. } = stmt { + if let Some(e) = init { + demote_redeclarations_expr(e, ids, seen); + } + if ids.contains(id) && !seen.insert(*id) { + *stmt = Stmt::Expr(match init.take() { + Some(value) => Expr::LocalSet(*id, Box::new(value)), + None => Expr::Undefined, + }); + } + return; + } + let stmts = |body: &mut [Stmt], seen: &mut HashSet| { + for s in body.iter_mut() { + demote_redeclarations_stmt(s, ids, seen); + } + }; + match stmt { + Stmt::Expr(e) | Stmt::Throw(e) | Stmt::Return(Some(e)) => { + demote_redeclarations_expr(e, ids, seen) + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + demote_redeclarations_expr(condition, ids, seen); + stmts(then_branch, seen); + if let Some(e) = else_branch { + stmts(e, seen); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + demote_redeclarations_expr(condition, ids, seen); + stmts(body, seen); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + demote_redeclarations_stmt(i, ids, seen); + } + if let Some(c) = condition { + demote_redeclarations_expr(c, ids, seen); + } + stmts(body, seen); + if let Some(u) = update { + demote_redeclarations_expr(u, ids, seen); + } + } + Stmt::Labeled { body, .. } => demote_redeclarations_stmt(body, ids, seen), + Stmt::Try { + body, + catch, + finally, + } => { + stmts(body, seen); + if let Some(c) = catch { + stmts(&mut c.body, seen); + } + if let Some(fin) = finally { + stmts(fin, seen); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + demote_redeclarations_expr(discriminant, ids, seen); + for case in cases { + if let Some(t) = &mut case.test { + demote_redeclarations_expr(t, ids, seen); + } + stmts(&mut case.body, seen); + } + } + Stmt::Let { .. } + | Stmt::Return(None) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } +} + +fn demote_redeclarations_expr( + expr: &mut Expr, + ids: &HashSet, + seen: &mut HashSet, +) { + if let Expr::Closure { params, body, .. } = expr { + for p in params.iter() { + if ids.contains(&p.id) { + seen.insert(p.id); + } + } + for s in body.iter_mut() { + demote_redeclarations_stmt(s, ids, seen); + } + } + walk_expr_children_mut(expr, &mut |e| demote_redeclarations_expr(e, ids, seen)); } fn retype_capture_holders( @@ -522,7 +832,11 @@ fn retype_lets_in_expr(expr: &mut Expr, targets: &HashSet) { /// Detect the shared-mutable capture ids declared in ONE body. The returned /// ids are meaningful only within that body's scope — callers must not apply /// them to other functions (LocalIds repeat across scopes; see #6089). -fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> BodySharedCaptures { +fn detect_shared_in_body( + params: &[Param], + body: &[Stmt], + classes: &HashMap<&str, &Class>, +) -> BodySharedCaptures { let mut shared = BodySharedCaptures::default(); let mut regs = Vec::new(); for s in body { @@ -531,10 +845,14 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Body if regs.is_empty() { return shared; } + shared.census = DeclCensus::of_body(params, body); let mut assigned: HashSet = HashSet::new(); for s in body { collect_assigned_deep_stmt(s, &mut assigned); } + // A `var x = v` re-declaration that runs after a class captured `x` writes + // the captured binding just like `x = v` does. + assigned.extend(shared.census.late_redeclared.iter().copied()); for (class_name, ids) in ®s { for id in ids { // Declaring-function-side mutation (`c = 99` after `new T()`). @@ -544,7 +862,7 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Body } // Class-side mutation: a member assigns rebind local `__perry_cap_`. if let Some(c) = classes.get(class_name.as_str()) { - if class_mutates_capture(c, *id) { + if class_mutates_capture(classes, c, *id, 0) { shared.ids.insert(*id); } } @@ -566,16 +884,209 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Body shared } -fn class_mutates_capture(c: &Class, id: LocalId) -> bool { +/// Does class `c` (or a class NESTED in one of its member bodies) assign the +/// capture of `id`? +/// +/// A member's own write lands on its rebind local `__perry_cap_`. A class +/// declared inside that member body (`class Outer { make() { return class Inner +/// { constructor() { n++ } } } }`, the emscripten/`FS` shape) captures the +/// REBIND local instead, and its write lands one level deeper — invisible to a +/// walk of `c` alone, because a nested class's members live in their own +/// `module.classes` entry, not inside the method body (#10489). +fn class_mutates_capture( + classes: &HashMap<&str, &Class>, + c: &Class, + id: LocalId, + depth: u32, +) -> bool { let id_name = collect_class_names(c); let assigned = collect_class_assigned(c); - assigned.iter().any(|aid| { + let names_id = |aid: &LocalId| { id_name .get(aid) .is_some_and(|n| crate::cap_fields::cap_field_outer_id(n) == Some(id)) + }; + if assigned.iter().any(names_id) { + return true; + } + // Bounded: each level is one class nesting, and the chain is finite. + if depth >= MAX_NESTED_CLASS_DEPTH { + return false; + } + for_each_nested_capture(c, &HashSet::from([id]), |nested_name, outer_id| { + classes + .get(nested_name) + .is_some_and(|nested| class_mutates_capture(classes, nested, outer_id, depth + 1)) }) } +/// How far the nested-class walks descend (`class` inside a member body, whose +/// member body declares another class, …). Deep enough for real code, bounded +/// so a cyclic registration cannot loop. +const MAX_NESTED_CLASS_DEPTH: u32 = 8; + +/// Call `visit(nested_class_name, outer_id)` for every class registered inside +/// a member body of `c` that captures that member's rebind local for `outer_id` +/// (an id in `targets`). Returns true as soon as `visit` does. +/// +/// The nested class was lowered BEFORE `synthesize_class_captures` renamed the +/// enclosing member's references, so its own rebind holders are still named for +/// the ORIGINAL outer id — which is what the reported id must be for the +/// name-keyed matching in `rewrite_member_scoped` / `retype_capture_holders`. +fn for_each_nested_capture( + c: &Class, + targets: &HashSet, + mut visit: impl FnMut(&str, LocalId) -> bool, +) -> bool { + for f in class_member_fns(c) { + let mut rebinds = member_rebind_targets(f, targets); + let mut regs = Vec::new(); + for s in &f.body { + find_regs_stmt(s, &mut regs); + find_cap_arg_news_stmt(s, &mut regs); + } + // A field initializer shares the constructor's scope (it is lowered + // into the ctor body), so a class declared in one holds the ctor's + // rebind params. + if c.constructor.as_ref().is_some_and(|ctor| ctor.id == f.id) { + for field in &c.fields { + for expr in field.init.iter().chain(field.key_expr.iter()) { + let mut names: HashMap = HashMap::new(); + collect_let_names_expr(expr, &mut names); + for (id, name) in names { + if let Some(outer) = crate::cap_fields::cap_field_outer_id(&name) { + if targets.contains(&outer) { + rebinds.insert(id, outer); + } + } + } + find_regs_expr(expr, &mut regs); + find_cap_arg_news_expr(expr, &mut regs); + } + } + } + if rebinds.is_empty() { + continue; + } + for (nested_name, ids) in regs { + for id in ids { + if let Some(outer_id) = rebinds.get(&id) { + if visit(&nested_name, *outer_id) { + return true; + } + } + } + } + } + false +} + +/// Class constructions whose trailing `cap_args_appended` arguments forward +/// capture handles. An IMMEDIATELY constructed class expression (`new (class { +/// … })()`) has neither a `RegisterClassCaptures` nor a `ClassExprFresh` node — +/// `lower_new` lowers it straight to `Expr::New` — so `find_regs_stmt` alone +/// misses it, and its members kept reading the raw cell (#10485's nested-class +/// row printed `[[2],["set"]]` instead of the values). +fn find_cap_arg_news_stmt(stmt: &Stmt, out: &mut Vec<(String, Vec)>) { + for_each_child_stmt(stmt, &mut |s| find_cap_arg_news_stmt(s, out)); + for_each_top_expr(stmt, &mut |e| find_cap_arg_news_expr(e, out)); +} + +fn find_cap_arg_news_expr(expr: &Expr, out: &mut Vec<(String, Vec)>) { + if let Expr::New { + class_name, + args, + cap_args_appended, + .. + } = expr + { + let appended = *cap_args_appended as usize; + if appended > 0 && args.len() >= appended { + let ids: Vec = args[args.len() - appended..] + .iter() + .filter_map(|a| match a { + Expr::LocalGet(id) => Some(*id), + _ => None, + }) + .collect(); + if !ids.is_empty() { + out.push((class_name.clone(), ids)); + } + } + } + if let Expr::Closure { body, .. } = expr { + for s in body { + find_cap_arg_news_stmt(s, out); + } + } + walk_expr_children(expr, &mut |e| find_cap_arg_news_expr(e, out)); +} + +/// The member's capture holders (`__perry_cap_` params and `Let`s), +/// mapped back to the outer id each one rebinds. +fn member_rebind_targets(f: &Function, targets: &HashSet) -> HashMap { + let mut rebinds: HashMap = HashMap::new(); + let record = |id: LocalId, name: &str, out: &mut HashMap| { + if let Some(outer) = crate::cap_fields::cap_field_outer_id(name) { + if targets.contains(&outer) { + out.insert(id, outer); + } + } + }; + for p in &f.params { + record(p.id, &p.name, &mut rebinds); + } + let mut names: HashMap = HashMap::new(); + for s in &f.body { + collect_let_names_stmt(s, &mut names); + } + for (id, n) in names { + record(id, &n, &mut rebinds); + } + rebinds +} + +/// A class nested in a member body holds its captures through the member's +/// REBIND locals, which by then carry the shared cell — so ITS members must +/// index through `[0]` too. Walk the nesting chain and mark those classes. +fn propagate_cells_to_nested_classes( + module: &Module, + shared_by_class: &mut HashMap>, +) { + for _ in 0..MAX_NESTED_CLASS_DEPTH { + let mut discovered: Vec<(String, LocalId)> = Vec::new(); + for c in &module.classes { + let Some(targets) = shared_by_class.get(&c.name) else { + continue; + }; + for_each_nested_capture(c, targets, |nested_name, outer_id| { + discovered.push((nested_name.to_string(), outer_id)); + false + }); + } + let mut added = false; + for (class_name, id) in discovered { + added |= shared_by_class.entry(class_name).or_default().insert(id); + } + if !added { + break; + } + } +} + +/// Every member function of a class: methods, accessors, statics, computed +/// members and the constructor (whose scope the field initializers share). +fn class_member_fns(c: &Class) -> Vec<&Function> { + let mut v: Vec<&Function> = Vec::new(); + v.extend(c.methods.iter()); + v.extend(c.getters.iter().map(|(_, g)| g)); + v.extend(c.setters.iter().map(|(_, s)| s)); + v.extend(c.static_methods.iter()); + v.extend(c.computed_members.iter().map(|m| &m.function)); + v.extend(c.constructor.iter()); + v +} + /// id -> name across a class: every member function's PARAMS (a field-init /// closure captures the constructor param `__perry_cap_`, id-only in the /// closure) plus every `Let` name in member bodies and field initializers. @@ -622,8 +1133,8 @@ fn collect_class_names(c: &Class) -> HashMap { /// descending into closures). fn collect_class_assigned(c: &Class) -> HashSet { let mut assigned = HashSet::new(); - for body in class_member_bodies(c) { - for s in body { + for f in class_member_fns(c) { + for s in &f.body { collect_assigned_deep_stmt(s, &mut assigned); } } @@ -638,29 +1149,6 @@ fn collect_class_assigned(c: &Class) -> HashSet { assigned } -fn class_member_bodies(c: &Class) -> Vec<&Vec> { - let mut v: Vec<&Vec> = Vec::new(); - for m in &c.methods { - v.push(&m.body); - } - for (_, g) in &c.getters { - v.push(&g.body); - } - for (_, s) in &c.setters { - v.push(&s.body); - } - for sm in &c.static_methods { - v.push(&sm.body); - } - for member in &c.computed_members { - v.push(&member.function.body); - } - if let Some(ctor) = &c.constructor { - v.push(&ctor.body); - } - v -} - // ---- read-only walkers (exhaustive over Stmt; exprs recurse into closures) -- fn collect_let_names_stmt(stmt: &Stmt, out: &mut HashMap) { @@ -807,6 +1295,27 @@ fn for_each_top_expr(stmt: &Stmt, f: &mut dyn FnMut(&Expr)) { // Rewrite (mutable, exhaustive over Stmt) // --------------------------------------------------------------------------- +/// `Sequence([LocalSet(id, _) | Update { id }, this.__perry_cap_N = LocalGet(id)])` +/// for a cell id — see the `Expr::Sequence` arm of [`rewrite_expr`]. +fn is_redundant_cell_propagation(items: &[Expr], index_uses: &HashSet) -> bool { + let [write, Expr::PropertySet { + object, + property, + value, + }] = items + else { + return false; + }; + let written = match write { + Expr::LocalSet(id, _) | Expr::Update { id, .. } => *id, + _ => return false, + }; + index_uses.contains(&written) + && matches!(object.as_ref(), Expr::This) + && property.starts_with("__perry_cap_") + && matches!(value.as_ref(), Expr::LocalGet(id) if *id == written) +} + fn rewrite_stmts(stmts: &mut [Stmt], shared: &HashSet, index_uses: &HashSet) { for s in stmts.iter_mut() { rewrite_stmt(s, shared, index_uses); @@ -947,6 +1456,19 @@ fn rewrite_stmt(stmt: &mut Stmt, shared: &HashSet, index_uses: &HashSet fn rewrite_expr(expr: &mut Expr, shared: &HashSet, index_uses: &HashSet) { match expr { + // A member's write to a captured local arrives wrapped by the field + // propagation of `synthesize_class_captures`: + // `Sequence([write, this.__perry_cap_N = LocalGet(rebind)])`, which + // keeps a value SNAPSHOT field in step with the member's local. A + // shared cell needs no propagation — the field already holds the same + // cell — and keeping it makes the sequence yield the cell handle instead + // of the write's value (`return n++` returned `[3]`, not 2; #10489). + Expr::Sequence(items) if is_redundant_cell_propagation(items, index_uses) => { + let write = items.swap_remove(0); + *expr = write; + rewrite_expr(expr, shared, index_uses); + return; + } // A value read of a boxed id -> `id[0]`. The synthesized `LocalGet` is // the ARRAY handle and is not re-rewritten. Expr::LocalGet(id) if index_uses.contains(id) => { diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 4e6bd026fa..bc94fe146c 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -100,6 +100,8 @@ fn emit_class_expression_value_binding( // The binding's local holds ITS OWN class — `new ()` must keep // the static construct path (see `inferred_class_bindings`). ctx.inferred_class_bindings.insert(bind_name.to_string()); + ctx.inferred_class_bindings + .record_binding(id, bind_name.to_string()); module.init.push(Stmt::Let { id, name: bind_name.to_string(), diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index afa26dba29..54aa8f471c 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1989,6 +1989,7 @@ mod unresolved_new_global; mod global_this_new_shadowed; mod capture_stash; +mod class_member_var_captures; mod function_ctor_runtime_routing; mod mixin_parent_chain; mod native_module_sync; diff --git a/crates/perry-hir/src/lower/tests/class_member_var_captures.rs b/crates/perry-hir/src/lower/tests/class_member_var_captures.rs new file mode 100644 index 0000000000..caf348f965 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/class_member_var_captures.rs @@ -0,0 +1,213 @@ +//! #10485/#10489: class members share the enclosing bindings they capture. +//! +//! Two lowering properties are asserted here, both invisible to a runtime +//! probe until the miscompiled program prints a stale value: +//! +//! 1. a `var` captured and mutated across the class boundary becomes ONE +//! shared cell (`crate::lower::shared_mutable_capture`), even though a +//! `var` is declared twice in HIR (body-entry slot + declaration), and the +//! re-declaration writes that cell instead of minting a second one; +//! 2. a `new K()` resolves to the class the BINDING holds, not to whatever +//! class first claimed the name `K` in the module. + +use crate::ir::{Expr, Stmt}; + +fn lower(source: &str) -> crate::ir::Module { + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + super::super::lower_module(&module, "t", "t.ts").expect("source lowers") +} + +fn function<'a>(module: &'a crate::ir::Module, name: &str) -> &'a crate::ir::Function { + module + .functions + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("fixture declares function {name}")) +} + +/// Statements of `body` (recursing into nested statement bodies) that declare +/// `name`, rendered compactly. +fn declarations_of<'a>(body: &'a [Stmt], name: &str) -> Vec<&'a Stmt> { + let mut out = Vec::new(); + for stmt in body { + if matches!(stmt, Stmt::Let { name: n, .. } if n == name) { + out.push(stmt); + } + if let Stmt::For { body, .. } | Stmt::While { body, .. } = stmt { + out.extend(declarations_of(body, name)); + } + } + out +} + +fn compact(value: &impl std::fmt::Debug) -> String { + format!("{value:?}") + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect() +} + +/// A `var` mutated from a constructor is the same binding on both sides: it +/// lowers to a one-element cell, and every use — the declaring function's read +/// and the member's write — goes through it. Before #10489 the two HIR `Let`s a +/// `var` produces read as two bindings, so the desugar skipped the id and the +/// class kept a private copy (`declCtor` returned 0, not 3). +#[test] +fn var_mutated_from_a_constructor_becomes_one_shared_cell() { + let module = lower( + r#" + function declCtor() { + var a = 0; + class A { constructor() { a++; } } + new A(); new A(); + return a; + } + declCtor(); + "#, + ); + let decls = declarations_of(&function(&module, "declCtor").body, "a"); + assert_eq!( + decls.len(), + 1, + "the `var` re-declaration must write the cell, not re-declare it: {decls:#?}" + ); + assert!( + matches!(decls[0], Stmt::Let { init: Some(Expr::Array(items)), .. } if items.len() == 1), + "the captured `var` must lower to a one-element cell: {:#?}", + decls[0] + ); + let body = compact(&function(&module, "declCtor").body); + assert!( + body.contains("IndexSet{object:LocalGet"), + "the `var a = 0` re-declaration must write the existing cell: {body}" + ); + let class = module + .classes + .iter() + .find(|c| c.name == "A") + .expect("fixture declares class A"); + let ctor = compact(&class.constructor.as_ref().expect("class A has a ctor").body); + assert!( + ctor.contains("IndexUpdate{"), + "the constructor's `a++` must update the shared cell: {ctor}" + ); +} + +/// The control: a capture nobody mutates keeps the cheap value snapshot, so +/// the cell rewrite cannot quietly become universal (it costs an indirection +/// on every read). +#[test] +fn an_unmutated_var_capture_keeps_its_value_snapshot() { + let module = lower( + r#" + function readOnly() { + var a = 0; + class A { value() { return a; } } + return new A().value(); + } + readOnly(); + "#, + ); + let body = compact(&function(&module, "readOnly").body); + assert!( + !body.contains("Array([Undefined])") && !body.contains("IndexSet{object:LocalGet"), + "an unmutated capture must not be boxed into a cell: {body}" + ); +} + +/// A class nested in a member body captures the member's rebind local, which +/// holds the cell — so its own members must index through it too. The +/// immediately-constructed form has no `RegisterClassCaptures` node at all +/// (`lower_new` emits a bare `Expr::New`), which is how it was missed. +#[test] +fn a_class_nested_in_a_member_shares_the_same_cell() { + let module = lower( + r#" + function outerFn() { + var made = 0; + class Outer { + make() { return class Inner { constructor() { made++; } }; } + immediate() { return new (class { read() { return made; } })().read(); } + } + const Inner = new Outer().make(); + new Inner(); + return [made, new Outer().immediate()]; + } + outerFn(); + "#, + ); + // A named class EXPRESSION registers under a disambiguated key, so match + // the source name as a prefix. + let inner = module + .classes + .iter() + .find(|c| c.name.starts_with("Inner")) + .expect("fixture declares the nested class Inner"); + let inner_ctor = compact(&inner.constructor.as_ref().expect("Inner has a ctor").body); + assert!( + inner_ctor.contains("IndexUpdate{"), + "a nested class's write must reach the shared cell: {inner_ctor}" + ); + let anon = module + .classes + .iter() + .find(|c| c.name.starts_with("__anon_class_")) + .expect("fixture declares an immediately-constructed anonymous class"); + let read = compact( + &anon + .methods + .first() + .expect("the anon class has read()") + .body, + ); + assert!( + read.contains("IndexGet{"), + "an immediately-constructed nested class must read the cell's value: {read}" + ); +} + +/// Two sibling functions that each bind `const K = class {…}`: the second +/// binding holds its OWN class (registered under a disambiguated key), so +/// `new K()` inside it must not construct the first function's class with the +/// first function's capture ids (#10489's `twinTwo` returned 0). +#[test] +fn sibling_same_named_class_bindings_construct_their_own_class() { + let module = lower( + r#" + function twinOne() { var n = 0; const K = class { constructor() { n++; } }; new K(); return n; } + function twinTwo() { var n = 0; const K = class { constructor() { n += 10; } }; new K(); return n; } + twinOne(); twinTwo(); + "#, + ); + let one = compact(&function(&module, "twinOne").body); + let two = compact(&function(&module, "twinTwo").body); + let key_of = |body: &str| -> String { + let at = body + .find("New{class_name:\"") + .expect("the fixture constructs its class statically"); + let rest = &body[at + "New{class_name:\"".len()..]; + rest[..rest.find('"').expect("class name is terminated")].to_string() + }; + let (one_key, two_key) = (key_of(&one), key_of(&two)); + assert_ne!( + one_key, two_key, + "sibling functions must construct distinct classes, both built {one_key}" + ); + for (key, body) in [(&one_key, &one), (&two_key, &two)] { + let class = module + .classes + .iter() + .find(|c| &c.name == key) + .unwrap_or_else(|| panic!("the constructed class {key} exists")); + let ctor = compact(&class.constructor.as_ref().expect("has a ctor").body); + assert!( + ctor.contains("IndexUpdate{") || ctor.contains("IndexSet{object:LocalGet"), + "{key}'s constructor must write its own function's cell: {ctor}" + ); + assert!( + body.contains("init:Some(Array([Undefined]))") + && body.contains("IndexSet{object:LocalGet"), + "each function declares its own counter cell and initializes it: {body}" + ); + } +} diff --git a/test-files/_helpers/class_member_var_captures_10485.cjs b/test-files/_helpers/class_member_var_captures_10485.cjs new file mode 100644 index 0000000000..cb5e2c5627 --- /dev/null +++ b/test-files/_helpers/class_member_var_captures_10485.cjs @@ -0,0 +1,27 @@ +"use strict"; +// Helper for test_gap_10485_class_member_var_captures.ts: the shape TypeScript +// emits for a class whose static members refer to the class itself +// (`var _a; class X { … _a … } _a = X;`), as in @redis/client 6.1.0's +// dist/lib/client/index.js. A CommonJS module body is a function scope, so +// every read of `_a` below is a class-member capture of a module-body `var`. +var _a; +var hits = 0; +function attachConfig({ BaseClass, tag }) { + return class extends BaseClass { + tagged() { return tag + ":" + this.describe(); } + }; +} +class Client { + constructor(name) { this.name = name; hits++; } + static create(name) { return _a.factory(name); } + static factory(name) { + const Commanded = attachConfig({ BaseClass: _a, tag: "cmd" }); + return new Commanded(name); + } + static hits() { return hits; } + describe() { return "client(" + this.name + ") hits=" + _a.hits(); } +} +_a = Client; +exports.Client = Client; +exports.bumpHits = (n) => { hits += n; }; +exports.readHits = () => hits; diff --git a/test-files/test_gap_10485_class_member_var_captures.ts b/test-files/test_gap_10485_class_member_var_captures.ts new file mode 100644 index 0000000000..45cf4c4caf --- /dev/null +++ b/test-files/test_gap_10485_class_member_var_captures.ts @@ -0,0 +1,179 @@ +// #10485 / #10489: class members nested in a function scope (a function body, +// or a CommonJS module body) must share the enclosing bindings they capture, +// exactly like closures do: they see writes made after the class was declared, +// and their own writes are visible outside and to every other member/instance. +// +// Perry lifts class members out of their function and hands them captures as +// value snapshots; a mutable capture is shared through a one-element cell +// (#5951). A `var` is declared twice in HIR (the body-entry slot, then the +// declaration statement), which the cell rewrite read as two bindings and +// skipped, so every `var` capture stayed a stale copy. Separately, two +// functions that each bound `const K = class {…}` constructed the FIRST +// function's class from inside the second one. +// +// Validated byte-for-byte against `node --experimental-strip-types`. +import cjs from "./_helpers/class_member_var_captures_10485.cjs"; + +const show = (label: string, value: unknown) => console.log(label, JSON.stringify(value)); + +// ── 1. TypeScript `var _a; class X {…} _a = X;` emit in a CommonJS module ── +{ + const lib = cjs as any; + const client = lib.Client.create("a"); + show("cjs static via _a:", client.tagged()); + show("cjs extends _a:", client instanceof lib.Client); + lib.bumpHits(10); + show("cjs hits (member write + outer write):", [lib.Client.hits(), lib.readHits()]); +} + +// ── 2. reads after a later assignment, every member kind ── +function readsAfterAssignment(param: number) { + var withInit = 1; + var noInit: any; + let lexical = 1; + class K { + snapshot = [withInit, noInit, lexical, param]; + live = () => [withInit, noInit, lexical, param]; + static s() { return [withInit, noInit, lexical, param]; } + i() { return [withInit, noInit, lexical, param]; } + get g() { return [withInit, noInit, lexical, param]; } + static get sg() { return [withInit, noInit, lexical, param]; } + nested() { return new (class { r() { return [withInit, noInit, lexical, param]; } })().r(); } + } + withInit = 2; noInit = "set"; lexical = 2; param = 20; + const k = new K(); + show("after 1st: static", K.s()); + show("after 1st: static getter", K.sg); + show("after 1st: instance", k.i()); + show("after 1st: getter", k.g); + show("after 1st: field init", k.snapshot); + show("after 1st: field arrow", k.live()); + show("after 1st: nested class", k.nested()); + withInit = 3; noInit = "again"; lexical = 3; param = 30; + show("after 2nd: static", K.s()); + show("after 2nd: old instance", [k.i(), k.snapshot, k.live()]); + // a `var` re-declaration with an initializer writes the SAME binding + var withInit = 4; + show("after var redeclaration:", [K.s(), k.i()]); +} +readsAfterAssignment(10); + +// a `var` declared after the class (a `let`/`const` declared after a class that +// reads it is a separate, still-open gap — it stays undefined in Perry today) +function declaredAfterClass() { + class K { + static v() { return late; } + } + var late = "late var"; + return [K.v()]; +} +show("declared after class:", declaredAfterClass()); + +// ── 3. writes from class members ── +function ctorCounter() { + var count = 0; + class A { id: number; constructor() { this.id = ++count; } } + const ids = [new A().id, new A().id, new A().id]; + return [ids, count]; +} +show("ctor counter:", ctorCounter()); + +function staticAndInstanceWrites() { + var total = 0; + var log = ""; + class B { + static add(n: number) { total += n; } + push(s: string) { log = log + s; return log.length; } + set value(v: number) { total = v; } + get value() { return total; } + } + B.add(1); B.add(2); + const b1 = new B(), b2 = new B(); + b1.push("x"); b2.push("y"); + const afterStatic = total; + b1.value = 100; + return [afterStatic, total, b2.value, log]; +} +show("static/instance/setter writes:", staticAndInstanceWrites()); + +function fieldInitWrite() { + var next = 1; + class F { id = next++; } + new F(); new F(); + return [new F().id, next]; +} +show("field initializer write:", fieldInitWrite()); + +function postfixValue() { + var n = 0; + let m = 0; + class P { a() { return n++; } b() { return m++; } } + const p = new P(); + p.a(); p.a(); p.b(); p.b(); + return [p.a(), n, p.b(), m]; +} +show("postfix result:", postfixValue()); + +function nestedClassWrites() { + var made = 0; + class Outer { + make() { return class Inner { constructor() { made++; } }; } + } + const Inner = new Outer().make(); + new Inner(); new Inner(); + return made; +} +show("nested class write:", nestedClassWrites()); + +function fieldInitNestedClass() { + var seen = 0; + class Holder { + Tracked = class { constructor() { seen++; } }; + count() { return seen; } + } + const h = new Holder(); + new h.Tracked(); new h.Tracked(); + return [h.count(), seen]; +} +show("field-init nested class:", fieldInitNestedClass()); + +// a `var` in a loop body is ONE function-scoped binding +function loopVar() { + const classes: any[] = []; + for (let i = 0; i < 3; i++) { + var shared = i * 10; + class C { static get() { return shared; } } + classes.push(C); + } + return classes.map((c) => c.get()); +} +show("var in loop body:", loopVar()); + +// ── 4. emscripten FS shape: hoisted function constructs a later class expression ── +function hoistedFactory() { + function createNode() { return new FSNode(); } + var nextInode = 1, FSNode = class { id: number; constructor() { this.id = nextInode++; } }; + createNode(); createNode(); + return createNode().id + "/" + nextInode; +} +show("hoisted factory:", hoistedFactory()); + +// ── 5. same-named bindings and classes in sibling functions ── +function twinOne() { var n = 0; const K = class { static tag = "one"; constructor() { n++; } }; new K(); new K(); new K(); return [n, K.tag, new K() instanceof K]; } +function twinTwo() { var n = 0; const K = class { static tag = "two"; constructor() { n += 10; } }; new K(); new K(); new K(); return [n, K.tag, new K() instanceof K]; } +function twinDeclOne() { var n = 0; class D { constructor() { n++; } } new D(); new D(); return n; } +function twinDeclTwo() { var n = 0; class D { constructor() { n += 100; } } new D(); new D(); return n; } +function notAClass() { + var K: any = function (this: any) { this.kind = "function"; }; + return new K().kind; +} +show("twin expr one:", twinOne()); +show("twin expr two:", twinTwo()); +show("twin decl:", [twinDeclOne(), twinDeclTwo()]); +show("same-named non-class local:", notAClass()); + +// ── 6. module top level (always worked; must keep working) ── +var _top: any; +class Top { static viaAlias() { return _top === Top; } } +_top = Top; +show("esm top-level alias:", Top.viaAlias()); From 3f70fd0f90bab499c424c44f6fc6c5589bd584c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 11:40:57 +0000 Subject: [PATCH 20/30] docs(changelog): add fragment for #10615 --- changelog.d/10615-class-member-var-captures.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/10615-class-member-var-captures.md diff --git a/changelog.d/10615-class-member-var-captures.md b/changelog.d/10615-class-member-var-captures.md new file mode 100644 index 0000000000..ec85da4424 --- /dev/null +++ b/changelog.d/10615-class-member-var-captures.md @@ -0,0 +1,5 @@ +Fix class members nested in a function or CommonJS module body capturing enclosing `var`/`let` bindings by value snapshot instead of by reference: a method saw the value the binding had at class-declaration time, and writes from constructors/static methods to a captured `var` were silently lost. This blocked `@redis/client` (TypeScript's `var _a; class X {…} _a = X;` static-self-reference emit) and `sql.js`/emscripten (the `FS.nextInode` counter that never advanced, corrupting every `FSNode.id`). + +Root cause: `desugar_shared_mutable_captures` decides whether a captured local is safe to promote to a shared reference cell by counting how many times its `LocalId` is declared and requiring exactly one — but a `var` is declared twice in HIR (a body-entry predefine slot, then the declaration statement), so every `var` capture failed that check and fell back to the stale value-snapshot path. The counter is replaced with a `DeclCensus`/`CensusWalker` that walks a region in execution order and accepts an id as one binding when either it has a single declaration, or all its declarations sit in the same closure scope under the same name with the first dominating (the shape of a `var`) — declarations in different closure scopes stay ambiguous, preserving the earlier #6089 fix. A `var` redeclaration that runs after the class has already captured the id, or inside a loop, is demoted into a write to the shared cell instead of being treated as a fresh binding. + +Added `test-files/test_gap_10485_class_member_var_captures.ts` (byte-for-byte against Node 26.5.1) covering every member kind, constructor/static/setter writes, `var` redeclaration, the emscripten hoisted-factory shape, and same-named `var`+class pairs in sibling functions, plus 4 new HIR-level unit tests in `crates/perry-hir/src/lower/tests/class_member_var_captures.rs`. Proven to fail on a pristine pre-fix tree and pass on this change. From 15e00b7e61223446b9624636779bd6665f126029 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 21/30] 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 f6c47ec5aa..9d391d6ba2 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 3988b944c4..6a813563e6 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 08a749be824c5fccae16bcfb0e6bb0fa4f6ad0ea 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 22/30] 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 9d391d6ba2..02637d22a7 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 b398bfca7e2c23406f76ad65119e83c7b3a6a365 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 23/30] 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()`. From 7516b69f7fc7a6d665d7d3bd47e7ab27b2c54274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 11:08:24 +0000 Subject: [PATCH 24/30] chore: remove Tier A native bindings (fetch alias, tursodb, iroh) Removes the bare-name node-fetch alias binding and the vestigial in-tree accounting for the tursodb/iroh native bindings, whose actual implementations already moved to @perryts/tursodb and @perryts/iroh in v0.5.557. See changelog fragment for details. --- crates/perry-api-manifest/src/entries.rs | 3 -- .../perry-api-manifest/src/entries/part_1.rs | 29 ------------------- .../tests/unimplemented_api_check.rs | 12 -------- .../compile/optimized_libs/freshness.rs | 1 - crates/perry/src/commands/stdlib_features.rs | 10 ------- crates/perry/well_known_bindings.toml | 8 ----- 6 files changed, 63 deletions(-) diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 98d0061f8a..182a94665e 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -54,8 +54,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "mongodb", // MongoDB driver "better-sqlite3", // synchronous SQLite (replaces the N-API addon) "sqlite", // node:sqlite builtin surface - "tursodb", // Turso/libSQL client (legacy in-tree; now @perryts/tursodb) - "iroh", // iroh p2p (legacy in-tree; now @perryts/iroh) // #6562: Bun FFI (C-ABI). The `bun:` prefix is part of the specifier // (unlike `node:`, which is stripped) — `import { dlopen } from "bun:ffi"`. "bun:ffi", @@ -158,7 +156,6 @@ pub const NATIVE_MODULES: &[&str] = &[ // ── More third-party npm packages ── "redis", // npm `redis` client (aliases ioredis) "rate-limiter-flexible", // rate limiting - "fetch", // bare-name alias for the node-fetch surface // `undici` (#466) — served by perry's native fetch stack via the // bundled perry-ext-undici wrapper (ProxyAgent / Agent / // setGlobalDispatcher / getGlobalDispatcher / fetch subset). diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index 8ccae06fe6..aa51556739 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -303,35 +303,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("sqlite", "setAllowUnknownNamedParameters", true, None), method("sqlite", "sourceSQL", true, None), method("sqlite", "expandedSQL", true, None), - // tursodb (#424). open / exec / execBatch / close / - // lastInsertRowid / isAutocommit shipped in v0.5.543; queryAll / - // queryOne shipped in v0.5.553 (close the row-as-object gap by - // building shapes inside spawn_blocking and resolving with - // POINTER_TAG'd JsValues). - method("tursodb", "open", false, None), - method("tursodb", "exec", true, None), - method("tursodb", "execBatch", true, None), - method("tursodb", "queryAll", true, None), - method("tursodb", "queryOne", true, None), - method("tursodb", "close", true, None), - method("tursodb", "lastInsertRowid", true, None), - method("tursodb", "isAutocommit", true, None), - // iroh (#425). bind / nodeId / close shipped in v0.5.544; the - // peer connection + stream surface (connect / acceptOne / - // openBi / acceptBi / streamWrite / streamFinish / - // streamReadToEnd / connClose) shipped in v0.5.554. ALPN is - // hardcoded to `b"perry-iroh/0"` for v0. - method("iroh", "bind", false, None), - method("iroh", "nodeId", true, None), - method("iroh", "close", true, None), - method("iroh", "connect", true, None), - method("iroh", "acceptOne", true, None), - method("iroh", "openBi", true, None), - method("iroh", "acceptBi", true, None), - method("iroh", "streamWrite", true, None), - method("iroh", "streamFinish", true, None), - method("iroh", "streamReadToEnd", true, None), - method("iroh", "connClose", true, None), property("sea", "default"), method("sea", "isSea", false, None), method("sea", "getAsset", false, None), diff --git a/crates/perry-hir/tests/unimplemented_api_check.rs b/crates/perry-hir/tests/unimplemented_api_check.rs index a9120201d7..14fa823fbc 100644 --- a/crates/perry-hir/tests/unimplemented_api_check.rs +++ b/crates/perry-hir/tests/unimplemented_api_check.rs @@ -364,20 +364,11 @@ fn perry_native_namespace_rejects_unknown_call_in_strict_mode() { /// /// Side-effect-only sub-paths (`dotenv/config`) are skipped — they have /// no value binding to read properties off, so the gate doesn't apply. -/// `tursodb` and `iroh` are external bindings (live in standalone -/// `@perryts/*` repos as of v0.5.557) — their manifest entries exist -/// but the in-tree resolver doesn't recognise them as a `NativeModuleRef` -/// without `node_modules//package.json` declaring `perry.nativeLibrary`, -/// so the gate's prerequisite shape never triggers in this isolated -/// HIR test. #[test] fn every_supported_module_rejects_bogus_member() { const SKIP: &[&str] = &[ // Side-effect-only — no value binding to access. "dotenv/config", - // External (non-bundled) bindings — out-of-tree as of v0.5.557. - "tursodb", - "iroh", ]; let mut failures: Vec = Vec::new(); @@ -455,9 +446,6 @@ fn every_supported_module_rejects_bogus_call() { const SKIP: &[&str] = &[ // Side-effect-only — no value binding to access. "dotenv/config", - // External (non-bundled) bindings — out-of-tree as of v0.5.557. - "tursodb", - "iroh", ]; let mut failures: Vec = Vec::new(); diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index b2e25ccde3..8aab6c74c7 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -749,7 +749,6 @@ pub(crate) fn binding_needs_shared_tokio(module: &str) -> bool { // HTTP clients (reqwest, hyper) | "axios" | "node-fetch" - | "fetch" // undici — glue over the native fetch stack (network I/O family). // The wrapper itself has no tokio dep today, but it rides the // shared build so the driver auto-builds its archive alongside diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index bc51e18251..a7f4b0561b 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -81,16 +81,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // `database-sqlite` feature with better-sqlite3 — DatabaseSync / // StatementSync route to the same `js_sqlite_*` runtime. "sqlite" | "bun:sqlite" => &["database-sqlite"], - // tursodb (#424) lives in the external - // `PerryTS/tursodb-bindings` repo (`bun add @perryts/tursodb`) - // since v0.5.557 — perry's package.json `perry.nativeLibrary` - // resolution path picks it up from `node_modules/`. No - // perry-stdlib feature gate to manage. - "tursodb" => &[], - // iroh (#425) lives in the external `PerryTS/iroh-bindings` - // repo (`bun add @perryts/iroh`) since v0.5.557 — same model - // as tursodb above. - "iroh" => &[], // Redis is detected via the ioredis class name in collect_modules, // but if it shows up as an explicit import we still need the feature. // `database-redis` umbrella retained for backwards-compat; diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index b72de46d51..59e13d4598 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -511,14 +511,6 @@ repo = "https://github.com/node-fetch/node-fetch" ref = "8b3320d2a7c07bce4afc6b2bf6c3bbddda85b01f" ported-at = "3.3.2" date = "2026-07-30" -[bindings.fetch] -crate = "perry-ext-fetch" -lib = "perry_ext_fetch" -tracking = "#466" -# Bare `fetch` is an alias for the node-fetch surface (same wrapper crate); it -# is not itself an npm package, so it inherits node-fetch's provenance. -alias-of = "node-fetch" - [bindings.ws] crate = "perry-ext-ws" lib = "perry_ext_ws" From 4bb01ecd2a75f1dbb16878d2a09f3efe81f9e7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 11:28:56 +0000 Subject: [PATCH 25/30] chore: regen API docs + governance table for the binding removal Fetch's manifest entries stay (internal dispatch tag for the built-in Web Fetch API), so mark it in the test-only INTERNAL_MODULE_KEYS allowlist now that it is no longer a NATIVE_MODULES import specifier. --- crates/perry-api-manifest/src/entries.rs | 9 +++++-- docs/api/perry.d.ts | 12 +-------- docs/src/api/reference.md | 33 +----------------------- docs/src/native-libraries/governance.md | 2 +- 4 files changed, 10 insertions(+), 46 deletions(-) diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 182a94665e..ce9ae85f87 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -228,9 +228,14 @@ pub const NODE_SUBMODULES: &[&str] = &[ ]; /// Internal manifest keys used by dispatch/property gates but not importable -/// module specifiers. +/// module specifiers. `fetch` covers the built-in Web Fetch API's value-typed +/// dispatch tag (`Response`/`Headers`/`Request`/`Blob`/`FormData` + the bare +/// global `fetch()` call) — real fetch/HTTP-client I/O, distinct from the +/// removed bare-name `"fetch"` alias for the `node-fetch` npm package that +/// used to double as its NATIVE_MODULES entry (see well_known_bindings.toml). #[cfg(test)] -pub(crate) const INTERNAL_MODULE_KEYS: &[&str] = &["inspector.Network", "punycode.ucs2"]; +pub(crate) const INTERNAL_MODULE_KEYS: &[&str] = + &["inspector.Network", "punycode.ucs2", "fetch"]; /// Modules handled entirely by `perry-runtime` — the linker doesn't /// need to pull in `perry-stdlib` for these. Migrated from diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index cb700c98cd..87dc9693bb 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2095 entries across 138 modules +// Coverage: 2093 entries across 136 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -2096,11 +2096,6 @@ declare module "iovalkey" { export function createClient(...args: any[]): any; } -declare module "iroh" { - /** stdlib */ - export function bind(...args: any[]): any; -} - declare module "jsonwebtoken" { /** stdlib */ export function decode(token: string): any; @@ -4141,11 +4136,6 @@ declare module "tty" { export function isatty(...args: any[]): any; } -declare module "tursodb" { - /** stdlib */ - export function open(...args: any[]): any; -} - declare module "typescript" { /** stdlib */ export const DiagnosticCategory: any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 457840e2d4..afcbe5c54a 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3054 entries across 140 modules. +Total: 3035 entries across 138 modules. ## Modules @@ -64,7 +64,6 @@ Total: 3054 entries across 140 modules. - [`inspector/promises`](#inspectorpromises) - [`ioredis`](#ioredis) - [`iovalkey`](#iovalkey) -- [`iroh`](#iroh) - [`jsonwebtoken`](#jsonwebtoken) - [`lodash`](#lodash) - [`lru-cache`](#lru-cache) @@ -132,7 +131,6 @@ Total: 3054 entries across 140 modules. - [`timers/promises`](#timerspromises) - [`tls`](#tls) - [`tty`](#tty) -- [`tursodb`](#tursodb) - [`typescript`](#typescript) - [`undici`](#undici) - [`url`](#url) @@ -2014,22 +2012,6 @@ Total: 3054 entries across 140 modules. - `createClient` — module -## `iroh` - -### Methods - -- `acceptBi` — instance -- `acceptOne` — instance -- `bind` — module -- `close` — instance -- `connClose` — instance -- `connect` — instance -- `nodeId` — instance -- `openBi` — instance -- `streamFinish` — instance -- `streamReadToEnd` — instance -- `streamWrite` — instance - ## `jsonwebtoken` ### Methods @@ -3786,19 +3768,6 @@ Total: 3054 entries across 140 modules. - `removeListener` — instance *(class: `WriteStream`)* - `setRawMode` — instance *(class: `ReadStream`)* -## `tursodb` - -### Methods - -- `close` — instance -- `exec` — instance -- `execBatch` — instance -- `isAutocommit` — instance -- `lastInsertRowid` — instance -- `open` — module -- `queryAll` — instance -- `queryOne` — instance - ## `typescript` ### Methods diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index dd9a302695..1591f27649 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -99,7 +99,7 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-events` | `events` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-exponential-backoff` | `exponential-backoff` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-fastify` | `fastify` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-fetch` | `fetch`
`node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | +| `perry-ext-fetch` | `node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-http` | `http`
`http2`
`https` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-ioredis` | `ioredis`
`iovalkey`
`redis` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-jsonwebtoken` | `jsonwebtoken` | Source package | Compile the upstream package source | Bundled; migration pending | From 99ba0527c99da7d0402797cfbaa4e8bbd9f77e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 11:36:55 +0000 Subject: [PATCH 26/30] chore: cargo fmt --- crates/perry-api-manifest/src/entries.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index ce9ae85f87..0e1415b629 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -234,8 +234,7 @@ pub const NODE_SUBMODULES: &[&str] = &[ /// removed bare-name `"fetch"` alias for the `node-fetch` npm package that /// used to double as its NATIVE_MODULES entry (see well_known_bindings.toml). #[cfg(test)] -pub(crate) const INTERNAL_MODULE_KEYS: &[&str] = - &["inspector.Network", "punycode.ucs2", "fetch"]; +pub(crate) const INTERNAL_MODULE_KEYS: &[&str] = &["inspector.Network", "punycode.ucs2", "fetch"]; /// Modules handled entirely by `perry-runtime` — the linker doesn't /// need to pull in `perry-stdlib` for these. Migrated from From 4c01c0e671c35505b9333410ae637dfe8f018115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 12:03:17 +0000 Subject: [PATCH 27/30] docs: changelog fragment for #10618 --- .../10618-tier-a-native-binding-removal.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 changelog.d/10618-tier-a-native-binding-removal.md diff --git a/changelog.d/10618-tier-a-native-binding-removal.md b/changelog.d/10618-tier-a-native-binding-removal.md new file mode 100644 index 0000000000..c6fc6d55ce --- /dev/null +++ b/changelog.d/10618-tier-a-native-binding-removal.md @@ -0,0 +1,39 @@ +Removed the three Tier A native bindings that were safe to delete without +any compiler work: the bare-name `fetch` alias for `node-fetch`, and the +leftover in-tree accounting for `tursodb`/`iroh` (their actual crates and +`well_known_bindings.toml` entries had already moved out to +`@perryts/tursodb` / `@perryts/iroh` in v0.5.557 — this finishes the job). + +`fetch` alias: dropped `[bindings.fetch]` from `well_known_bindings.toml` +and the `"fetch"` entry from `NATIVE_MODULES`. `node-fetch` itself, its +`perry-ext-fetch` crate, and the manifest entries backing the built-in Web +Fetch API's `Response`/`Headers`/`Request`/`Blob`/`FormData` dispatch tag +(a separate mechanism, confirmed via the pre-existing +`builtin_fetch_usage_does_not_synthesize_well_known_fetch` test) are +unaffected; `"fetch"` moved to the test-only `INTERNAL_MODULE_KEYS` +allowlist so `known_modules_consistent_with_manifest` still holds. + +`tursodb`/`iroh`: removed their `NATIVE_MODULES` entries, manifest method +rows, `stdlib_features.rs` feature-gate arms, and skip-list entries in the +`unimplemented_api_check.rs` coverage sweeps. Their presence in +`NATIVE_MODULES` with no backing crate made the bare `import * as tursodb +from "tursodb"` specifier short-circuit past file resolution and silently +claim nativeness with nothing behind it — a dangling reference, not a +working feature. The real `@perryts/tursodb` / `@perryts/iroh` packages are +consumed via their scoped specifier, which was never in `NATIVE_MODULES` +and resolves through the unrelated `node_modules`/`perry.nativeLibrary` +path, untouched here. + +Regenerated `docs/src/api/reference.md`, `docs/api/perry.d.ts` (drop the +`tursodb`/`iroh` sections, keep `## fetch`), and +`docs/src/native-libraries/governance.md`'s generated table (`perry-ext-fetch`'s +package mapping drops `fetch`, keeps `node-fetch`). + +Validated: cargo tests on `perry-api-manifest`, `perry-hir`, `perry-codegen` +(`manifest_consistency`), and `perry`'s `stdlib_features`/`optimized_libs` +unit tests all green; `run_lint_gates.sh` 76/77 (the one red, +"Public benchmark evidence freshness", is pre-existing on every PR); +targeted gap suite (every fixture using `node-fetch` or built-in `fetch`) +at 100% parity; hand probes confirm `import * as tursodb from "tursodb"` +now fails at compile time with a clear error instead of compiling to a +broken no-op, and built-in `fetch`/`Response`/`Headers` still resolve. From 028d908bcee588a496a1e45b89324199254f175b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:39:05 +0200 Subject: [PATCH 28/30] tooling: justify two gcheader casts and cargo fmt for train 218 --- crates/perry-runtime/src/object/class_registry/state.rs | 3 ++- scripts/addr_class_allowlist.txt | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 9b73174956..f01604a586 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -987,7 +987,8 @@ fn reserved_native_parent_prototype_bits(parent_id: u32) -> Option { CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE => ("events", "EventEmitterAsyncResource"), _ => return None, }; - let func_value = super::super::native_module::bound_native_callable_export_value(module, symbol); + let func_value = + super::super::native_module::bound_native_callable_export_value(module, symbol); let parent_proto = super::function_prototype::js_function_prototype_value_for_read(func_value); class_parent_prototype_bits(parent_proto) } diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index 4ab811e64c..ce58bea327 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -176,3 +176,5 @@ crates/perry-runtime/src/hot_diag/receiver_repr.rs | let derived_ptr = derived.a crates/perry-runtime/src/hot_diag/receiver_repr.rs | (addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; | #9973 debug-only trust-the-tag audit, and the raw read is the POINT of it: the block compares the ownership-derived header against a direct byte-offset read and counts disagreements in DIRECT_MISMATCH. Routing this side through `try_read_gc_header` would validate the address first and return None for exactly the implausible cases the audit exists to catch, so the canonical predicate cannot stand in here. `#[cfg(debug_assertions)]`; absent from release builds. crates/perry-runtime/src/json/stringify_record_output.rs | as *const crate::gc::GcHeader | #10004 repeated-output hit: `arr` is reread from a field of the live receiver already classified by `try_object`, and its full tagged bits must equal the array token admitted by `dense_array`; no allocation or GC occurs between that reread and this header check. Moving GC changes the receiver or field token into a miss before this read. Repeating the tracked-range lookup here would tax every admitted memo hit without strengthening that ownership proof. crates/perry-runtime/src/array/storage.rs | let header = (arr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; | #10060 logical array storage primitive: its unsafe contract requires a live, forwarding-resolved GC_TYPE_ARRAY supplied by the allocator, collector, or a validated array receiver. It never accepts a NaN-box payload or registry handle; the preceding allocation header is part of that ownership proof. No allocation or safepoint occurs during the read. +crates/perry-runtime/src/box/scope_release.rs | (closure as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader | #10613 test-only: inside a `#[test]` in this file's `mod tests`, which sets GC_FLAG_MARKED on a closure the test itself just allocated and then restores it. `try_read_gc_header` reads a header and cannot hand back the MUTABLE pointer the flag write needs, and the address is owned by the test rather than derived from untrusted input. Never compiled into a shipped binary. +crates/perry-runtime/src/object/prototype_chain.rs | let header = (obj_ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; | #10614 residual-prototype owner marking: the address is validated through the canonical predicate immediately above — `let Some(header) = try_read_gc_header(obj_ptr) else { return }`, plus an obj_type check — and this cast only re-derives a WRITABLE pointer to the header that predicate already proved present, so it can set GC_RESIDUAL_PROTO_OWNER in `_reserved`. No allocation or safepoint occurs between the validation and the write. From 198a5d1dc1482ab0f79736a064c619d4229da708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 19:28:06 +0200 Subject: [PATCH 29/30] fix(test): drop two unnecessary unsafe blocks in the #10612 ctor-arguments tests --- crates/perry-runtime/src/object/class_constructors.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 72f1bc3359..bd930c6ac2 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -1447,13 +1447,13 @@ mod constructor_arg_slot_tests { fn array_of(value: f64) -> (usize, u32) { let arr = crate::value::js_nanbox_get_pointer(value) as *const crate::array::ArrayHeader; assert!(!arr.is_null(), "expected a packed array, got {value}"); - let len = unsafe { crate::array::js_array_length(arr) }; + let len = crate::array::js_array_length(arr); (arr as usize, len) } fn element(value: f64, index: u32) -> f64 { let arr = crate::value::js_nanbox_get_pointer(value) as *const crate::array::ArrayHeader; - unsafe { crate::array::js_array_get_f64(arr, index) } + crate::array::js_array_get_f64(arr, index) } #[test] From 90c8943bb53bda2bfa42f45654a64249a0ee4016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:39:06 +0200 Subject: [PATCH 30/30] chore: release merge train 218 as v0.5.1596 --- CLAUDE.md | 2 +- Cargo.lock | 162 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 940ea4d729..d055ea528a 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.1595 +**Current Version:** 0.5.1596 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 617abc8390..0894a6f6d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1595" +version = "0.5.1596" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1595" +version = "0.5.1596" [[package]] name = "perry-parser" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1595" +version = "0.5.1596" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1595" +version = "0.5.1596" [[package]] name = "perry-ui-tvos" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 7eed35eb9a..32c668115e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1595" +version = "0.5.1596" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"