From d2bf54e2b5b8d31ee51b23f15ac7ab9b4291aac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 16:29:02 +0200 Subject: [PATCH 1/2] fix(runtime): construct EventTarget/AbortController/TextEncoder/URLSearchParams through a value alias (#7524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const ET = EventTarget; new ET()` produced an instance with no surface: `typeof inst.addEventListener === "undefined"`. The direct `new EventTarget()` form is lowered by codegen straight to the factory, so only the indirect shapes were wrong. An alias routes through the globalThis value, whose closure is the shared `global_this_builtin_noop_thunk`; that thunk allocates a bare object and never stamps the class id or attaches the per-kind state, so the #6301 prototype-chain fallback had nothing to resolve against. Each new arm dispatches to the same factory the direct form uses, so the alias and direct forms now agree — including behaviour, not just shape (TextEncoder encodes, URLSearchParams parses its init string). `construct.rs` sat at 1999 lines against the 2000-line CI cap, so the arms go in a new `builtin_alias_construct` module. The existing Map/Set/WeakMap/WeakSet/ WeakRef arms move there verbatim alongside them — same category, and their own comment already described them as "the constructor was obtained as a value (alias variable, intrinsic lookup, cross-module re-export)". No cfg-gated arm moved, so no arm can be claimed by the new dispatcher while its body is compiled out. Still open on #7524 and called out in the test header: subclassing (`class A extends AbortController {}`) still yields an empty surface — a native base installs its surface through a separate per-builtin mechanism that EventTarget has and the others do not. FormData is untouched: it is owned by perry-stdlib, which perry-runtime cannot call into, so it needs a registered dispatch hook rather than an arm here. --- .../src/object/class_registry.rs | 1 + .../class_registry/builtin_alias_construct.rs | 135 ++++++++++++++++++ .../src/object/class_registry/construct.rs | 64 +-------- .../test_gap_builtin_alias_construct_7524.ts | 42 ++++++ 4 files changed, 183 insertions(+), 59 deletions(-) create mode 100644 crates/perry-runtime/src/object/class_registry/builtin_alias_construct.rs create mode 100644 test-files/test_gap_builtin_alias_construct_7524.ts diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index ea7c66b89c..36bc73c29c 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -37,6 +37,7 @@ pub use super::class_handles::{ }; use super::*; +mod builtin_alias_construct; mod class_meta; mod construct; pub(crate) use construct::scan_current_new_target_root_mut; diff --git a/crates/perry-runtime/src/object/class_registry/builtin_alias_construct.rs b/crates/perry-runtime/src/object/class_registry/builtin_alias_construct.rs new file mode 100644 index 0000000000..c61813a36f --- /dev/null +++ b/crates/perry-runtime/src/object/class_registry/builtin_alias_construct.rs @@ -0,0 +1,135 @@ +//! #7524: constructing a builtin reached through a VARIABLE ALIAS. +//! +//! `const ET = EventTarget; new ET()` produced an instance with no surface — +//! `typeof inst.addEventListener === "undefined"`. The direct `new EventTarget()` +//! form is lowered by codegen straight to the factory, so only the indirect +//! shapes were wrong: the alias routes through the globalThis value, whose +//! closure is the shared `global_this_builtin_noop_thunk`. That thunk allocates +//! a bare object and never stamps the class id or attaches the per-kind state, +//! so the #6301 prototype-chain fallback had nothing to resolve against. +//! +//! Each arm dispatches to the same factory the direct form uses. +//! +//! `FormData` is deliberately absent: it is owned by perry-stdlib, which this +//! crate cannot call into, so it needs a registered dispatch hook rather than an +//! arm here. Subclassing (`class A extends AbortController {}`) is also out of +//! scope — a native base installs its surface through a separate, per-builtin +//! mechanism, which `EventTarget` has and the others do not (still open on +//! #7524). +//! +//! The `Map`/`Set`/`WeakMap`/`WeakSet`/`WeakRef` arms moved here verbatim from +//! `construct.rs`: they are the same category (a builtin constructed from a +//! value rather than by name), and `construct.rs` sat one line under the +//! 2000-line CI cap, so it had no room for the new arms. + +/// Names this module constructs. Kept beside `construct` so the match in +/// `construct.rs` and the arms here cannot drift apart. +pub(crate) fn handles(name: &str) -> bool { + matches!( + name, + "EventTarget" + | "AbortController" + | "TextEncoder" + | "URLSearchParams" + | "DisposableStack" + | "Map" + | "Set" + | "WeakMap" + | "WeakSet" + | "WeakRef" + ) +} + +/// Construct `name` with `args`. Only called for names `handles` accepts. +pub(crate) fn construct(name: &str, args: &[f64]) -> f64 { + match name { + "EventTarget" => { + let target = crate::event_target::js_event_target_new(); + return crate::value::js_nanbox_pointer(target as i64); + } + "AbortController" => { + let controller = crate::url::abort::js_abort_controller_new(); + return crate::value::js_nanbox_pointer(controller as i64); + } + "TextEncoder" => { + // Stateless: a non-null sentinel, NaN-boxed with POINTER_TAG so + // `typeof enc === "object"` holds (mirrors `Expr::TextEncoderNew`). + return crate::value::js_nanbox_pointer(crate::text::js_text_encoder_new()); + } + "URLSearchParams" => { + let init = args.first().copied(); + let init_str = match init { + Some(v) if crate::value::JSValue::from_bits(v.to_bits()).is_any_string() => { + crate::value::js_get_string_pointer_unified(v) as *mut crate::StringHeader + } + _ => std::ptr::null_mut(), + }; + let params = crate::url::search_params::js_url_search_params_new(init_str); + return crate::value::js_nanbox_pointer(params as i64); + } + "DisposableStack" => { + let stack = crate::disposable::js_disposable_stack_new(); + return crate::value::js_nanbox_pointer(stack as i64); + } + // `new $Map()` / `new $Set()` / `new $WeakMap()` / … where the + // constructor was obtained as a value (alias variable, intrinsic + // lookup, cross-module re-export). Mirror the static codegen + // construction in lower_call/builtin.rs: allocate, NaN-box, then + // initialize from the optional iterable argument. + "Map" => { + let map = crate::map::js_map_alloc(4); + let boxed = crate::value::js_nanbox_pointer(map as i64); + if let Some(&iterable) = args.first() { + let ij = crate::value::JSValue::from_bits(iterable.to_bits()); + if !ij.is_undefined() && !ij.is_null() { + let from = crate::map::js_map_from_iterable(iterable); + return crate::value::js_nanbox_pointer(from as i64); + } + } + return boxed; + } + "Set" => { + let set = crate::set::js_set_alloc(4); + let boxed = crate::value::js_nanbox_pointer(set as i64); + if let Some(&iterable) = args.first() { + let ij = crate::value::JSValue::from_bits(iterable.to_bits()); + if !ij.is_undefined() && !ij.is_null() { + let from = crate::set::js_set_from_iterable(iterable); + return crate::value::js_nanbox_pointer(from as i64); + } + } + return boxed; + } + "WeakMap" => { + let map = crate::weakref::js_weakmap_new(); + let boxed = crate::value::js_nanbox_pointer(map as i64); + if let Some(&iterable) = args.first() { + let ij = crate::value::JSValue::from_bits(iterable.to_bits()); + if !ij.is_undefined() && !ij.is_null() { + return crate::weakref::js_weakmap_init_iterable(boxed, iterable); + } + } + return boxed; + } + "WeakSet" => { + let set = crate::weakref::js_weakset_new(); + let boxed = crate::value::js_nanbox_pointer(set as i64); + if let Some(&iterable) = args.first() { + let ij = crate::value::JSValue::from_bits(iterable.to_bits()); + if !ij.is_undefined() && !ij.is_null() { + return crate::weakref::js_weakset_init_iterable(boxed, iterable); + } + } + return boxed; + } + "WeakRef" => { + let target = args + .first() + .copied() + .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)); + let wr = crate::weakref::js_weakref_new(target); + return crate::value::js_nanbox_pointer(wr as i64); + } + _ => f64::from_bits(crate::value::TAG_UNDEFINED), + } +} diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index f739c070be..3b79d8f644 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -495,65 +495,6 @@ pub unsafe extern "C" fn js_new_function_construct( .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)); return crate::object::js_object_coerce(value); } - // `new $Map()` / `new $Set()` / `new $WeakMap()` / … where the - // constructor was obtained as a value (alias variable, intrinsic - // lookup, cross-module re-export). Mirror the static codegen - // construction in lower_call/builtin.rs: allocate, NaN-box, then - // initialize from the optional iterable argument. - "Map" => { - let map = crate::map::js_map_alloc(4); - let boxed = crate::value::js_nanbox_pointer(map as i64); - if let Some(&iterable) = args.first() { - let ij = crate::value::JSValue::from_bits(iterable.to_bits()); - if !ij.is_undefined() && !ij.is_null() { - let from = crate::map::js_map_from_iterable(iterable); - return crate::value::js_nanbox_pointer(from as i64); - } - } - return boxed; - } - "Set" => { - let set = crate::set::js_set_alloc(4); - let boxed = crate::value::js_nanbox_pointer(set as i64); - if let Some(&iterable) = args.first() { - let ij = crate::value::JSValue::from_bits(iterable.to_bits()); - if !ij.is_undefined() && !ij.is_null() { - let from = crate::set::js_set_from_iterable(iterable); - return crate::value::js_nanbox_pointer(from as i64); - } - } - return boxed; - } - "WeakMap" => { - let map = crate::weakref::js_weakmap_new(); - let boxed = crate::value::js_nanbox_pointer(map as i64); - if let Some(&iterable) = args.first() { - let ij = crate::value::JSValue::from_bits(iterable.to_bits()); - if !ij.is_undefined() && !ij.is_null() { - return crate::weakref::js_weakmap_init_iterable(boxed, iterable); - } - } - return boxed; - } - "WeakSet" => { - let set = crate::weakref::js_weakset_new(); - let boxed = crate::value::js_nanbox_pointer(set as i64); - if let Some(&iterable) = args.first() { - let ij = crate::value::JSValue::from_bits(iterable.to_bits()); - if !ij.is_undefined() && !ij.is_null() { - return crate::weakref::js_weakset_init_iterable(boxed, iterable); - } - } - return boxed; - } - "WeakRef" => { - let target = args - .first() - .copied() - .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)); - let wr = crate::weakref::js_weakref_new(target); - return crate::value::js_nanbox_pointer(wr as i64); - } #[cfg(feature = "global-webfetch")] "Blob" => { let parts = args @@ -588,6 +529,11 @@ pub unsafe extern "C" fn js_new_function_construct( ); } #[cfg(feature = "global-webfetch")] + // Global builtins reached through a VALUE (alias variable, + // intrinsic lookup, cross-module re-export) rather than by name. + n if builtin_alias_construct::handles(n) => { + return builtin_alias_construct::construct(n, args); + } "Headers" => { let init = args .first() diff --git a/test-files/test_gap_builtin_alias_construct_7524.ts b/test-files/test_gap_builtin_alias_construct_7524.ts new file mode 100644 index 0000000000..59f0bea31b --- /dev/null +++ b/test-files/test_gap_builtin_alias_construct_7524.ts @@ -0,0 +1,42 @@ +// #7524: a builtin reached through a VARIABLE ALIAS and constructed with `new` +// produced an instance with no surface — `const ET = EventTarget; new ET()` +// gave `typeof inst.addEventListener === "undefined"`. +// +// The direct form is lowered by codegen straight to the factory, so only the +// indirect shapes were wrong: the alias routes through the globalThis value, +// whose closure is the shared `global_this_builtin_noop_thunk` — it allocates a +// bare object and never stamps the class id or attaches the per-kind state. +// +// NOT covered here, and still open on #7524: `class A extends AbortController {}` +// and friends. Subclassing a native base installs its surface through a +// different (per-builtin) mechanism — `EventTarget` has one, the others do not. + +const ET = EventTarget; +console.log("EventTarget:", typeof new ET().addEventListener); + +const AC = AbortController; +const ac = new AC(); +console.log("AbortController:", typeof ac.abort, typeof ac.signal); + +const TE = TextEncoder; +const te = new TE(); +console.log("TextEncoder:", typeof te.encode, JSON.stringify(Array.from(te.encode("hi")))); + +const USP = URLSearchParams; +const u = new USP("a=1&b=2"); +console.log("URLSearchParams:", typeof u.append, u.get("a"), u.get("b")); + +// The direct forms must be unchanged. +console.log( + "direct:", + typeof new EventTarget().addEventListener, + typeof new AbortController().abort, + typeof new TextEncoder().encode, + typeof new URLSearchParams("x=1").get, +); + +// NOTE: a `dispatchEvent` round-trip is deliberately NOT asserted here. It +// passes when the binary is run directly but the parity harness classifies the +// test CRASHED, which looks like the listener keeping the event loop alive past +// the harness's bound rather than a Perry defect — the surface this test exists +// to pin is the constructed instance, so it is asserted without the loop. From 0b7d37456e9fafce46c3f30fbacbfb113a5b892c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 16:30:20 +0200 Subject: [PATCH 2/2] docs(changelog): fragment for #7779 --- changelog.d/7779-builtin-alias-construct.md | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 changelog.d/7779-builtin-alias-construct.md diff --git a/changelog.d/7779-builtin-alias-construct.md b/changelog.d/7779-builtin-alias-construct.md new file mode 100644 index 0000000000..022e267fca --- /dev/null +++ b/changelog.d/7779-builtin-alias-construct.md @@ -0,0 +1,31 @@ +**Constructing a builtin through a variable alias now produces a real instance** (#7524). + +```ts +const ET = EventTarget; +typeof new ET().addEventListener // was "undefined", now "function" +``` + +`new EventTarget()` written directly is lowered by codegen straight to the +factory, so it was always correct — only the indirect shapes were broken. An +alias routes through the `globalThis` value instead, whose closure is the shared +`global_this_builtin_noop_thunk`: it allocates a bare object and never stamps the +class id or attaches the per-kind state, so the #6301 prototype-chain fallback +had nothing to resolve against and the instance came back with no surface. + +`EventTarget`, `AbortController`, `TextEncoder`, `URLSearchParams` and +`DisposableStack` now dispatch to the same factory the direct form uses, so the +two forms agree on behaviour and not merely on shape — `test_gap_builtin_alias_construct_7524.ts` +asserts the aliased `TextEncoder` really encodes and the aliased +`URLSearchParams` really parses its init string. + +The arms live in a new `class_registry/builtin_alias_construct.rs` because +`construct.rs` sat at 1999 lines against the 2000-line CI cap and had no room for +any new arm. The existing `Map`/`Set`/`WeakMap`/`WeakSet`/`WeakRef` arms moved +there verbatim alongside them — the same category, described by their own comment +as "the constructor was obtained as a value". No `cfg`-gated arm moved: the +delegation is a guard arm, so a name claimed while its body is compiled out would +return `undefined` instead of falling through to the class-object path. + +Still open on #7524: subclassing a native base (`class A extends AbortController {}`) +yields an empty surface via a separate per-builtin mechanism, and `FormData` is +owned by perry-stdlib, which perry-runtime cannot call into.