From fa478f1b6c132dd806df7a710868f1db872264d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 18:35:27 +0200 Subject: [PATCH 1/3] test(runtime): cover for-in visited levels across GC (cherry picked from commit 6f0f77494ab9f21002f76a7748b386fbb2ea1c44) --- ...est_issue_9869_for_in_visited_levels_gc.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 test-files/test_issue_9869_for_in_visited_levels_gc.ts diff --git a/test-files/test_issue_9869_for_in_visited_levels_gc.ts b/test-files/test_issue_9869_for_in_visited_levels_gc.ts new file mode 100644 index 0000000000..0fcb5aa57a --- /dev/null +++ b/test-files/test_issue_9869_for_in_visited_levels_gc.ts @@ -0,0 +1,22 @@ +// parity-env: PERRY_GC_SCHEDULE_SEED=9869 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_PROTECT_FROMSPACE=1 + +// #9869: `for-in` defers building its shadow set until a prototype level has +// an enumerable key to filter. The retained earlier levels must remain rooted +// while key-array construction allocates and triggers a moving collection. +const ancestor = { + own: "shadowed", + ancestor: "visible", +}; +const emptyMiddle = Object.create(ancestor); +const receiver = Object.create(emptyMiddle); +receiver.own = "receiver"; + +// Level zero records `receiver`; the empty middle level allocates a key array +// but keeps the shadow set deferred; the ancestor's keys then trigger a rebuild +// that reads both retained heap objects after evacuation. +const keys: string[] = []; +for (const key in receiver) { + keys.push(key); +} + +console.log(keys.join(",")); From 8b0b889696bed0b7d31f3d82e98309e21aa5252e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 18:26:51 +0200 Subject: [PATCH 2/3] fix(diag): remove dead string-wrapper counter (cherry picked from commit e991e1c8a6173f15682a68a1e0a4faae650fd785) --- changelog.d/9900-dead-string-wrapper-diag.md | 4 ++++ crates/perry-runtime/src/gc/diag_sites.rs | 18 ++++-------------- scripts/gc_runtime_root_holders.json | 6 ------ 3 files changed, 8 insertions(+), 20 deletions(-) create mode 100644 changelog.d/9900-dead-string-wrapper-diag.md diff --git a/changelog.d/9900-dead-string-wrapper-diag.md b/changelog.d/9900-dead-string-wrapper-diag.md new file mode 100644 index 0000000000..a26f98abdd --- /dev/null +++ b/changelog.d/9900-dead-string-wrapper-diag.md @@ -0,0 +1,4 @@ +### Fixed + +- GC primitive-dispatch diagnostics no longer expose a string-wrapper counter + that lost its only writer when boxed string indices became virtual. diff --git a/crates/perry-runtime/src/gc/diag_sites.rs b/crates/perry-runtime/src/gc/diag_sites.rs index 462d55afcb..5d7776bfab 100644 --- a/crates/perry-runtime/src/gc/diag_sites.rs +++ b/crates/perry-runtime/src/gc/diag_sites.rs @@ -369,22 +369,18 @@ pub(super) fn report_charges(label: &str) { // whose method the native dispatch tower does not recognise falls through to // `native_call_method::call_primitive_builtin_prototype_method`: it resolves // `globalThis..prototype[]` and, for a SLOPPY callee, boxes -// the receiver with `ToObject`. For a string that wrapper materialises one own -// property per UTF-16 code unit. So a single unrecognised method name on a hot -// render path turns into O(length) allocations per call, and the only way to -// tell WHICH names those are is to count them at the fork. +// the receiver with `ToObject`. A string wrapper exposes one virtual own index +// per UTF-16 code unit, so tracking receiver length shows the scale of the +// boxed surface alongside the method names that reach this fork. crate::perry_thread_local! { /// `".prototype." -> (calls, receiver_utf16_chars)`. static PRIMITIVE_DISPATCH: RefCell> = RefCell::new(HashMap::new()); - /// String wrappers actually materialised: (wrappers, index properties). - static STRING_WRAPPERS: Cell<(u64, u64)> = const { Cell::new((0, 0)) }; } /// Record one trip through the primitive-method fallback. `recv_chars` is the -/// receiver's UTF-16 length (0 when the receiver is not a string) — the number -/// of own index properties a sloppy callee's `ToObject` wrapper costs. +/// receiver's UTF-16 length (0 when the receiver is not a string). pub(crate) fn primitive_dispatch(builtin: &[u8], method: &str, recv_chars: u64) { if !gc_diag_enabled() { return; @@ -403,12 +399,6 @@ pub(super) fn report_primitive_dispatch(label: &str) { if !gc_diag_enabled() { return; } - let (wrappers, indices) = STRING_WRAPPERS.with(Cell::get); - if wrappers > 0 { - eprintln!( - "[gc-primitive-dispatch] {label}: string_wrappers={wrappers} index_properties={indices}" - ); - } let rows: Vec<(String, (u64, u64))> = PRIMITIVE_DISPATCH.with(|m| m.borrow().iter().map(|(k, v)| (k.clone(), *v)).collect()); if rows.is_empty() { diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 92d9286f61..13909495cd 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -341,12 +341,6 @@ "verdict": "not_a_gc_pointer", "why": "#9794: per-method primitive-dispatch counts, `HashMap`. Rust-owned method-name strings and counters." }, - { - "file": "crates/perry-runtime/src/gc/diag_sites.rs", - "name": "STRING_WRAPPERS", - "verdict": "not_a_gc_pointer", - "why": "#9794: materialized/elided string-wrapper counts as a `(u64, u64)` pair." - }, { "file": "crates/perry-runtime/src/gc/oldgen_defrag.rs", "name": "LAST_IDLE_PREDICTED_RELEASE", From 25885f969136750acd510d5a0df1982c10301fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 18:08:53 +0200 Subject: [PATCH 3/3] fix(runtime): expose module namespace data descriptors (cherry picked from commit 8b004bb5c21ef620bb78a605114d7aab3a34ec28) --- .../9899-module-namespace-descriptors.md | 5 +++++ .../perry-runtime/src/object/descriptors.rs | 6 ++++++ .../src/object/namespace_create.rs | 20 ++++++++++++++++++ .../test_gap_dynamic_import_alias_binding.ts | 21 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 changelog.d/9899-module-namespace-descriptors.md diff --git a/changelog.d/9899-module-namespace-descriptors.md b/changelog.d/9899-module-namespace-descriptors.md new file mode 100644 index 0000000000..5925f17990 --- /dev/null +++ b/changelog.d/9899-module-namespace-descriptors.md @@ -0,0 +1,5 @@ +### Fixed + +- Dynamic-import namespace exports now report standard data descriptors with + their current live-binding values instead of exposing the runtime's internal + accessor representation. diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index a17edbc176..28f77b966e 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -964,6 +964,12 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu return f64::from_bits(crate::value::TAG_UNDEFINED); } + // #9889: Hide the live binding's internal accessor behind the module + // namespace exotic object's required data-descriptor shape. + if (*obj).class_id == MODULE_NAMESPACE_CLASS_ID { + return module_namespace_own_property_descriptor(obj, key_str); + } + // Look up descriptor flags (default: all true). let attrs = key_rust .as_ref() diff --git a/crates/perry-runtime/src/object/namespace_create.rs b/crates/perry-runtime/src/object/namespace_create.rs index d6fc2d7bf4..bd044366c3 100644 --- a/crates/perry-runtime/src/object/namespace_create.rs +++ b/crates/perry-runtime/src/object/namespace_create.rs @@ -6,6 +6,26 @@ use super::*; pub(crate) const MODULE_NAMESPACE_CLASS_ID: u32 = 0xFFFF_4E53; +/// #9889: Implement the module namespace exotic object's [[GetOwnProperty]] +/// result while retaining accessors as the internal representation of live +/// bindings. The getter can allocate and evacuate both the namespace and key, +/// so keep them rooted until it returns. `build_data_descriptor` roots the +/// resulting value before allocating the descriptor object. +pub(crate) unsafe fn module_namespace_own_property_descriptor( + obj: *mut ObjectHeader, + key: *const crate::StringHeader, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let key_handle = scope.root_string_ptr(key); + let value = obj_handle.with_mut_ptr::(|current_obj| { + key_handle.with_const_ptr::(|current_key| { + js_object_get_field_by_name(current_obj, current_key) + }) + }); + super::descriptors::build_data_descriptor(f64::from_bits(value.bits()), true, true, false) +} + /// Apply the host-defined invariants shared by static and dynamic module /// namespace objects. #[no_mangle] diff --git a/test-files/test_gap_dynamic_import_alias_binding.ts b/test-files/test_gap_dynamic_import_alias_binding.ts index ef2d35e430..f8a1de38a7 100644 --- a/test-files/test_gap_dynamic_import_alias_binding.ts +++ b/test-files/test_gap_dynamic_import_alias_binding.ts @@ -3,6 +3,24 @@ async function main(): Promise { const ns = await import("./dynamic_import_alias_binding.ts"); + function logDescriptor( + name: "VAR_SET" | "directConst", + expected: string, + ): void { + const descriptor = Object.getOwnPropertyDescriptor(ns, name); + console.log( + name, + descriptor !== undefined, + descriptor !== undefined && "value" in descriptor, + descriptor?.writable, + descriptor?.enumerable, + descriptor?.configurable, + descriptor?.get === undefined, + descriptor?.set === undefined, + descriptor !== undefined && ns.checkAlias(descriptor.value, expected), + ); + } + console.log( typeof ns.VAR_SET, typeof ns.LET_SET, @@ -16,6 +34,8 @@ async function main(): Promise { ns.checkAlias(ns.CONST_SET, "const"), ns.checkAlias(ns.directConst, "direct"), ); + logDescriptor("VAR_SET", "var-before"); + logDescriptor("directConst", "direct"); ns.reassign(); console.log( @@ -24,6 +44,7 @@ async function main(): Promise { ns.checkAlias(ns.VAR_SET, "var-before"), ns.checkAlias(ns.LET_SET, "let-before"), ); + logDescriptor("VAR_SET", "var-after"); } main();