From 769ba1e1b24d9132329c8af58c8f9c3bfd2175e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 08:29:18 +0000 Subject: [PATCH 01/30] fix(runtime): root event/listener dispatch copies across moving GC Several event/listener dispatch loops clone listener callbacks and/or call arguments into plain Rust locals (a Vec, a Vec, or a raw array pointer), then call into user code that can allocate and trigger a moving minor collection, then reuse those unrooted copies for the next listener. Root every such copy through a RuntimeHandleScope and re-read the current (possibly relocated) value before each dispatch, instead of trusting the pre-call copy. Reproduced at crates/perry-runtime/src/node_stream_event_emitter.rs's emit_stream_event/call_listener_args (the path `class X extends EventEmitter` actually dispatches through): a 3-listener emitter whose second listener allocates heavily segfaults dereferencing the third listener's stale closure pointer, reliably, with no GC env knobs. Gone under PERRY_GEN_GC=0 (full mark-sweep, non-moving). Under PERRY_GC_DIAG=1 PERRY_GC_PROTECT_FROMSPACE=1 the from-space quarantine reports the exact fault: a retired-from-space deref of a GC_TYPE_CLOSURE object, matching a symbolized backtrace through js_native_call_value <- call_listener_args <- emit_stream_event. The same pattern is fixed at the four other sites an earlier code-read audit named: perry-stdlib's events.rs (js_event_emitter_emit/emit0, dispatch_error_monitor, emit_meta_event), domain.rs (emit_domain_event), worker_threads/worker_surface.rs (stream_emit_event), and events/warnings.rs (emit_warning). events.rs's own asynchronous dispatch branch already rooted its callback/receiver/ args via js_async_resource_run_in_async_scope; the synchronous branch did not, which is the same finding by construction, independent of the runtime repro. --- .../src/node_stream_event_emitter.rs | 53 ++++++++--- crates/perry-stdlib/src/domain.rs | 27 ++++-- crates/perry-stdlib/src/events.rs | 88 +++++++++++++------ crates/perry-stdlib/src/events/warnings.rs | 30 +++++-- .../src/worker_threads/worker_surface.rs | 28 ++++-- 5 files changed, 169 insertions(+), 57 deletions(-) diff --git a/crates/perry-runtime/src/node_stream_event_emitter.rs b/crates/perry-runtime/src/node_stream_event_emitter.rs index 54a19a3eca..4aababf4f1 100644 --- a/crates/perry-runtime/src/node_stream_event_emitter.rs +++ b/crates/perry-runtime/src/node_stream_event_emitter.rs @@ -746,6 +746,22 @@ pub(super) fn emit_stream_event(stream: f64, event: f64, args: &[f64]) -> f64 { if event_identity_bytes(event).is_none() { return f64::from_bits(super::TAG_FALSE); } + // #10600: `listener_snapshot`'s Vec, `stream`, `event` and `args` are all + // plain Rust locals, not GC roots. A listener can allocate enough to + // trigger a moving minor collection; an unrooted copy then holds a + // retired from-space address for the NEXT listener dispatched from this + // same loop (reproduced: a `class X extends EventEmitter` whose second + // listener allocates heavily segfaults dereferencing the third + // listener's stale closure pointer under the default generational GC — + // confirmed gone under `PERRY_GEN_GC=0`). Root the whole dispatch + // window through one handle scope and re-read every value's current + // (possibly relocated) bits before each call, the pattern `events.rs`'s + // async branch already uses. + let scope = crate::gc::RuntimeHandleScope::new(); + let stream_h = scope.root_nanbox_f64(stream); + let event_h = scope.root_nanbox_f64(event); + let arg_handles = scope.root_nanbox_f64_slice(args); + if super::string_value_eq(event, b"error") { if let Some(first) = args.first() { super::set_hidden_value(stream, super::hidden_error_key(), *first); @@ -756,14 +772,21 @@ pub(super) fn emit_stream_event(stream: f64, event: f64, args: &[f64]) -> f64 { if monitor_snapshot.iter().any(|(_, once)| *once) { remove_once_listeners(stream, monitor_event); } - for (listener, _) in monitor_snapshot { - call_listener_args(stream, listener, args); + let monitor_listener_values: Vec = monitor_snapshot.iter().map(|(l, _)| *l).collect(); + let monitor_handles = scope.root_nanbox_f64_slice(&monitor_listener_values); + for handle in &monitor_handles { + let live_args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); + call_listener_args( + stream_h.get_nanbox_f64(), + handle.get_nanbox_f64(), + &live_args, + ); } } - let snapshot = listener_snapshot(stream, event); + let snapshot = listener_snapshot(stream_h.get_nanbox_f64(), event_h.get_nanbox_f64()); if snapshot.is_empty() { - if super::string_value_eq(event, b"error") { + if super::string_value_eq(event_h.get_nanbox_f64(), b"error") { let err = args .first() .copied() @@ -773,17 +796,25 @@ pub(super) fn emit_stream_event(stream: f64, event: f64, args: &[f64]) -> f64 { return f64::from_bits(super::TAG_FALSE); } if snapshot.iter().any(|(_, once)| *once) { - remove_once_listeners(stream, event); + remove_once_listeners(stream_h.get_nanbox_f64(), event_h.get_nanbox_f64()); } // Node's Readable data delivery path does not route async `data` listener // rejections through captureRejections; custom EventEmitter-style events do. - let is_data = super::string_value_eq(event, b"data"); - let capture_rejections = - capture_rejections_enabled(stream) && !super::string_value_eq(event, b"error") && !is_data; - for (listener, _) in snapshot { - let result = call_listener_args(stream, listener, args); + let is_data = super::string_value_eq(event_h.get_nanbox_f64(), b"data"); + let capture_rejections = capture_rejections_enabled(stream_h.get_nanbox_f64()) + && !super::string_value_eq(event_h.get_nanbox_f64(), b"error") + && !is_data; + let listener_values: Vec = snapshot.iter().map(|(l, _)| *l).collect(); + let listener_handles = scope.root_nanbox_f64_slice(&listener_values); + for handle in &listener_handles { + let live_args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); + let result = call_listener_args( + stream_h.get_nanbox_f64(), + handle.get_nanbox_f64(), + &live_args, + ); if capture_rejections { - capture_listener_rejection(stream, result); + capture_listener_rejection(stream_h.get_nanbox_f64(), result); } else if is_data { // Node's Readable swallows a rejection returned by an async `data` // listener — it is neither captured to `error` nor surfaced as an diff --git a/crates/perry-stdlib/src/domain.rs b/crates/perry-stdlib/src/domain.rs index 3252effdcd..ca0666bd68 100644 --- a/crates/perry-stdlib/src/domain.rs +++ b/crates/perry-stdlib/src/domain.rs @@ -270,13 +270,30 @@ unsafe fn emit_domain_event(handle: Handle, event: &str, args: &[f64]) -> bool { return false; } let receiver = nanbox_handle(handle); + // #10600: `listeners` and `args` are plain Rust locals cloned out of the + // live domain, not GC roots. A listener can allocate enough to trigger a + // moving minor collection; an unrooted copy then holds a retired + // from-space address for the NEXT listener in this same loop. Root both + // through one handle scope and re-read the current bits before every + // call. + // // #10490: the displaced `this` is the caller's receiver and every listener - // is user code that can move it; restore it from a root, taken once. - let this_scope = perry_runtime::gc::RuntimeHandleScope::new(); - let previous_this = this_scope.root_nanbox_f64(perry_runtime::object::js_implicit_this_get()); - for listener in listeners { + // is user code that can move it too. Root it ONCE, in that same scope, + // before the first `js_implicit_this_set`, and restore it from that root + // rather than from a plain per-iteration local. + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let previous_this = scope.root_nanbox_f64(perry_runtime::object::js_implicit_this_get()); + let listener_handles = scope.root_nanbox_f64_slice(&listeners); + let arg_handles = scope.root_nanbox_f64_slice(args); + for listener_handle in &listener_handles { + let live_args = + perry_runtime::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); perry_runtime::object::js_implicit_this_set(receiver); - let _ = perry_runtime::closure::js_native_call_value(listener, args.as_ptr(), args.len()); + let _ = perry_runtime::closure::js_native_call_value( + listener_handle.get_nanbox_f64(), + live_args.as_ptr(), + live_args.len(), + ); perry_runtime::object::js_implicit_this_set(previous_this.get_nanbox_f64()); } true diff --git a/crates/perry-stdlib/src/events.rs b/crates/perry-stdlib/src/events.rs index 97ca999b41..7800558086 100644 --- a/crates/perry-stdlib/src/events.rs +++ b/crates/perry-stdlib/src/events.rs @@ -261,6 +261,27 @@ struct Listener { once: bool, } +/// #10600: `Listener.callback` is a raw, untagged heap pointer to a closure. +/// `snapshot` (cloned out of the live event map before dispatch, so a +/// once-listener removal mid-dispatch doesn't affect the emit already in +/// progress) is a plain Rust `Vec`, not a GC root. A listener can allocate +/// enough to trigger a moving minor collection; an unrooted `snapshot` entry +/// then holds a retired from-space address for the NEXT listener in the same +/// dispatch loop. Root every live callback through `scope` up front and read +/// each one back through its handle — never through `snapshot` itself — at +/// call time. +fn root_listener_callbacks<'scope>( + scope: &'scope perry_runtime::gc::RuntimeHandleScope, + snapshot: &[Listener], +) -> Vec> { + let raw: Vec = snapshot + .iter() + .filter(|l| l.callback != 0) + .map(|l| l.callback as u64) + .collect(); + scope.root_heap_word_u64_slice(&raw) +} + #[derive(Copy, Clone)] struct PendingOnce { promise: *mut Promise, @@ -406,11 +427,13 @@ impl EventEmitterHandle { let str_ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); let event_arg = js_nanbox_string(str_ptr as i64); let listener_arg = js_nanbox_pointer(listener_arg); - for l in snapshot { - if l.callback != 0 { - let closure_ptr = l.callback as *const ClosureHeader; - js_closure_call2(closure_ptr, event_arg, listener_arg); - } + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let event_arg_h = scope.root_nanbox_f64(event_arg); + let listener_arg_h = scope.root_nanbox_f64(listener_arg); + let callback_handles = root_listener_callbacks(&scope, &snapshot); + for handle in &callback_handles { + let closure_ptr = handle.get_heap_word_u64() as *const ClosureHeader; + js_closure_call2(closure_ptr, event_arg_h.get_nanbox_f64(), listener_arg_h.get_nanbox_f64()); } } @@ -778,14 +801,15 @@ unsafe fn dispatch_error_monitor(emitter: &mut EventEmitterHandle, arg: Option f64 { let Some(arr) = array_ptr_from_value(get_object_field_from_value(this, &key)) else { return js_bool(false); }; - let args = [arg]; - let len = perry_runtime::array::js_array_length(arr); - // #10490: the displaced `this` crosses every listener (user code); restore - // it from a root, taken once. - let this_scope = perry_runtime::gc::RuntimeHandleScope::new(); - let prev_this = this_scope.root_nanbox_f64(perry_runtime::object::js_implicit_this_get()); + // #10600: `this`, `arr` and `arg` are plain Rust locals, not GC roots. + // `arr` is the listener array's own raw pointer, so a stale copy after a + // listener allocates enough to trigger a moving minor collection + // corrupts every read for the rest of this loop, not just one listener. + // Root all three through one handle scope and re-read the current bits + // before every dispatch. + // + // #10490: the displaced `this` crosses every listener (user code) too. + // Root it ONCE in that same scope, before the first + // `js_implicit_this_set`, and restore it from that root rather than from + // a plain per-iteration local. + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let this_h = scope.root_nanbox_f64(this); + let arr_h = scope.root_raw_mut_ptr(arr); + let arg_h = scope.root_nanbox_f64(arg); + let prev_this = scope.root_nanbox_f64(perry_runtime::object::js_implicit_this_get()); + let len = perry_runtime::array::js_array_length(arr_h.get_raw_mut_ptr()); for i in 0..len { - let callback = perry_runtime::array::js_array_get_f64(arr, i); - perry_runtime::object::js_implicit_this_set(this); + let callback = perry_runtime::array::js_array_get_f64(arr_h.get_raw_mut_ptr(), i); + perry_runtime::object::js_implicit_this_set(this_h.get_nanbox_f64()); unsafe { + let args = [arg_h.get_nanbox_f64()]; let _ = perry_runtime::closure::js_native_call_value(callback, args.as_ptr(), args.len()); } From a00bac4a2111a17ae48493023ff9841bcf7bf6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 09:06:55 +0000 Subject: [PATCH 02/30] style: cargo fmt --- crates/perry-stdlib/src/events.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/perry-stdlib/src/events.rs b/crates/perry-stdlib/src/events.rs index 7800558086..2115929fd5 100644 --- a/crates/perry-stdlib/src/events.rs +++ b/crates/perry-stdlib/src/events.rs @@ -433,7 +433,11 @@ impl EventEmitterHandle { let callback_handles = root_listener_callbacks(&scope, &snapshot); for handle in &callback_handles { let closure_ptr = handle.get_heap_word_u64() as *const ClosureHeader; - js_closure_call2(closure_ptr, event_arg_h.get_nanbox_f64(), listener_arg_h.get_nanbox_f64()); + js_closure_call2( + closure_ptr, + event_arg_h.get_nanbox_f64(), + listener_arg_h.get_nanbox_f64(), + ); } } From 3b52301250c91c45fdc84dde3bb872fdfc42280d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 09:06:55 +0000 Subject: [PATCH 03/30] test: add gap test for event/listener dispatch GC rooting (#10600) --- ...ap_10600_event_emitter_dispatch_rooting.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test-files/test_gap_10600_event_emitter_dispatch_rooting.ts diff --git a/test-files/test_gap_10600_event_emitter_dispatch_rooting.ts b/test-files/test_gap_10600_event_emitter_dispatch_rooting.ts new file mode 100644 index 0000000000..1c6d2a520c --- /dev/null +++ b/test-files/test_gap_10600_event_emitter_dispatch_rooting.ts @@ -0,0 +1,39 @@ +import { EventEmitter } from "node:events"; + +// #10600: `class X extends EventEmitter` dispatches through +// perry-runtime's node_stream_event_emitter.rs, whose emit loop cloned +// listener callbacks (and the call args) into plain Rust locals, then +// called into user code that can allocate, then reused those unrooted +// copies for the next listener. A moving minor collection triggered by +// an allocation-heavy listener left the NEXT listener's snapshot entry +// pointing at retired from-space memory — no GC env knobs needed, this +// crashes under the plain default build. +class Bus extends EventEmitter {} + +const bus = new Bus(); +const seen: string[] = []; + +bus.on("tick", function (this: any, tag: string) { + seen.push("first:" + tag); +}); + +bus.on("tick", function (this: any, tag: string) { + // Allocate enough that a moving minor collection lands here, while the + // THIRD listener's closure is still a live, unrooted pointer captured + // by the emit loop's listener snapshot. + const churn: any[] = []; + for (let i = 0; i < 6000; i++) { + churn.push({ k: i, s: "x" + i, o: { i, s2: "y" + i } }); + } + seen.push("second:" + tag + ":" + churn.length); +}); + +bus.on("tick", function (this: any, tag: string) { + seen.push("third:" + tag); +}); + +for (let i = 0; i < 300; i++) { + bus.emit("tick", "t" + i); +} + +console.log(seen.length, seen[seen.length - 1]); From b3423d74d7a329a3557d5d2d4f49ebf51c8d346b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 09:39:56 +0000 Subject: [PATCH 04/30] docs: add changelog fragment for #10606 --- .../10606-event-emitter-moving-gc-rooting.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 changelog.d/10606-event-emitter-moving-gc-rooting.md diff --git a/changelog.d/10606-event-emitter-moving-gc-rooting.md b/changelog.d/10606-event-emitter-moving-gc-rooting.md new file mode 100644 index 0000000000..f721e6bc49 --- /dev/null +++ b/changelog.d/10606-event-emitter-moving-gc-rooting.md @@ -0,0 +1,40 @@ +### Fixed + +Event/listener dispatch loops in several runtime and stdlib sites cloned +listener callbacks and call arguments into plain Rust locals, then called +into user code that could allocate and trigger a moving minor collection, +then reused those unrooted copies for the next listener. A `class X extends +EventEmitter` with an allocation-heavy listener could segfault dereferencing +a later listener's stale closure pointer — reproducibly, on a plain default +build with no GC environment knobs. `PERRY_GC_DIAG=1 +PERRY_GC_PROTECT_FROMSPACE=1` isolated the fault to a retired-from-space +dereference of a `GC_TYPE_CLOSURE` object, and the crash disappeared under +`PERRY_GEN_GC=0` (full mark-sweep, non-moving), confirming the moving +collector as the cause. + +The primary site is `crates/perry-runtime/src/node_stream_event_emitter.rs` +(`emit_stream_event`/`call_listener_args`) — the path every `class X extends +EventEmitter` subclass and every Node stream class (`Readable`, `Writable`, +`Duplex`, `Transform`) actually dispatches through. The same pattern is also +fixed in `perry-stdlib`'s `events.rs` (`js_event_emitter_emit`/`emit0`, +`dispatch_error_monitor`, `emit_meta_event`), `domain.rs` +(`emit_domain_event`), `worker_threads/worker_surface.rs` +(`stream_emit_event`), and `events/warnings.rs` (`emit_warning`). Every +listener snapshot and call-argument copy that must stay live across a +dispatch loop is now rooted through a `RuntimeHandleScope`, re-reading each +value's current (possibly relocated) address before every call instead of +trusting the pre-call copy. + +Reconciled with #10490, which landed on `main` after this change was written +and fixes a different hazard in three of the same loops (`domain.rs`'s +`emit_domain_event`, `events/warnings.rs`'s `emit_warning`, and +`worker_threads/worker_surface.rs`'s `stream_emit_event`): the *displaced* +implicit `this`, saved across a listener call, was itself an unrooted local. +The two fixes are unioned rather than either superseding the other — taking +one side alone reintroduces the other's use-after-free. Each of those loops +now takes a single `RuntimeHandleScope` that roots the displaced `this` once, +before the first `js_implicit_this_set` and outside the loop, and restores it +from that root after every call, while the listener and argument copies are +rooted in the same scope and the arguments re-read immediately before each +dispatch. `events.rs`'s `call_emitter_listener` already carried #10490's +rooting and is unchanged by this reconciliation. From ccd7bc209b8e28eb9811506f7647757faf5099b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:49:47 +0200 Subject: [PATCH 05/30] style: cargo fmt the reconciled emit_warning this-rooting --- crates/perry-stdlib/src/events/warnings.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/perry-stdlib/src/events/warnings.rs b/crates/perry-stdlib/src/events/warnings.rs index a5e38748c2..e6f9beaa01 100644 --- a/crates/perry-stdlib/src/events/warnings.rs +++ b/crates/perry-stdlib/src/events/warnings.rs @@ -61,9 +61,9 @@ unsafe fn emit_warning(warning: f64) { let callback_h = scope.root_nanbox_f64(emit_warning); let process_h = scope.root_nanbox_f64(process); let arg_handles = scope.root_nanbox_f64_slice(&[warning]); - let previous_this = scope.root_nanbox_f64( - perry_runtime::object::js_implicit_this_set(process_h.get_nanbox_f64()), - ); + let previous_this = scope.root_nanbox_f64(perry_runtime::object::js_implicit_this_set( + process_h.get_nanbox_f64(), + )); let live_args = perry_runtime::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); perry_runtime::closure::js_native_call_value( From fec8e2be68b9cd5c8cb6ba9020d39e6c8ef410d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 10:18:19 +0200 Subject: [PATCH 06/30] perf(size): outline the CJS factory body, not just hir.init (#10575) #8595's entry outliner only ever chunked hir.init. For a CommonJS module, cjs_wrap::wrap_commonjs_for_target wraps the whole body as text inside a `function __perry_cjs_factory() {...}` closure nested in an anonymous IIFE; hir.init ends up with only a handful of wrapper statements, so admission never fired and the real body stayed one giant function. On typescript 5.9.3's _tsc.js this was a single 463,716-instruction/6.30 MB closure, past the machine-pipeline budget. find_cjs_factory_closure(_mut) locates that closure by walking hir.init's statement/expression tree (it is a Stmt::Let naming an Expr::Closure, not a hir.functions entry, since it is lexically nested). outline_entry_module now tries hir.init first (unchanged #8595 behavior) and falls back to the factory's body with the identical chunk_statements/analyze_stmts_outlining machinery, so a module is only ever outlined from one origin per compile. The factory always captures its own name from the wrapper's IIFE scope (`__cjs_module.__perry_cjs_factory = __perry_cjs_factory;`, which perry-runtime's module_require.rs calls through on a circular-require recovery path). A chunk is a plain, non-capturing function and can't read a captured id, so classify_for_chunking keeps any statement referencing one inline in the residual body rather than promoting it to a module global -- a global would turn a per-invocation-fresh capture into one program-wide instance and could silently break that recovery path. module_globals_emit.rs folds the factory's own logical statements into the same cross-chunk-let promotion emit_module_globals already does for hir.init, so a var shared across the factory's new chunks gets the same @perry_global_* treatment hir.init cross-chunk lets get. Verified on a synthetic 2107-statement CJS fixture (cross-chunk vars plus a closure created early and invoked from far-later statements) against Node's own output, and on a full typescript 5.9.3 build: the 463,716-instruction closure is gone, entry-outline reports "cjs factory: ... candidate=true", nm shows __perry_entry_chunk_* symbols, and `--noEmit demo.ts` / `--version` output and exit codes are byte-identical to before. --- .../src/codegen/entry_outline.rs | 967 ++++++++++++++++-- .../src/codegen/module_globals_emit.rs | 9 +- 2 files changed, 899 insertions(+), 77 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry_outline.rs b/crates/perry-codegen/src/codegen/entry_outline.rs index 1382421208..d77cba26eb 100644 --- a/crates/perry-codegen/src/codegen/entry_outline.rs +++ b/crates/perry-codegen/src/codegen/entry_outline.rs @@ -1,4 +1,4 @@ -//! Structured module-entry outlining (#8595). +//! Structured module-entry outlining (#8595, extended by #10575). //! //! The module top level is lowered into a single LLVM function (`@main` / //! `perry_module_init`). For a large minified bundle that one function is @@ -18,6 +18,59 @@ //! forces it for testing and measurement; `=0` disables it. Top-level await //! and a module-level TDZ preallocation remain fail-safe exclusions because a //! raw module-global load cannot yet perform the checked TDZ-box read. +//! +//! ## #10575: CommonJS module bodies live outside `hir.init` +//! +//! For a CommonJS source file, `cjs_wrap::wrap_commonjs_for_target` wraps the +//! whole body as *text* — `const _cjs = (function() { function +//! __perry_cjs_factory() { } return +//! __perry_cjs_factory(); })();` — before the normal parse/lower pipeline +//! ever sees it. Ordinary lowering represents the named, lexically-nested +//! `function __perry_cjs_factory() {...}` declaration the same way it would +//! an arrow/function EXPRESSION (it is not a top-level declaration, so it is +//! not hoisted into `hir.functions`): a `Stmt::Let` inside the wrapper's +//! outer IIFE, naming an `Expr::Closure`. `hir.init` itself holds only the +//! handful of statements the wrapper adds at module scope (the `_cjs` +//! binding, `export default`, …). Admission above therefore never fires for +//! CJS: the tens of thousands of real statements are not top-level HIR +//! statements anywhere, let alone in `hir.init`. +//! +//! [`outline_cjs_factory_module`] closes that gap: [`find_cjs_factory_closure_mut`] +//! walks `hir.init`'s statement/expression tree to locate that nested +//! closure, and the identical chunking transform +//! ([`chunk_statements`]/[`analyze_stmts_outlining`], same admission +//! thresholds, same fail-safe gates, same `__perry_entry_chunk_*` naming) +//! runs against ITS body instead of `hir.init`'s. New chunks are still +//! ordinary `hir.functions` entries, so `is_entry_chunk` and +//! `emit_module_globals`'s existing "referenced from a separate function +//! body" escape analysis pick them up for free. The one addition is +//! [`logical_outlined_function_stmts`]/the generalised +//! [`outlined_entry_global_let_ids`] residual scan, which teach the global- +//! promotion pass to also look for cross-chunk `var`s inside an outlined +//! factory body, not just inside `hir.init`. A module is only ever outlined +//! from ONE origin per compile (`hir.init` OR the factory, never both), so +//! there is no cross-origin interaction to reason about. +//! +//! The factory is only treated as a virtual module entry when it has the +//! exact wrap-generated shape: no params, not async/generator, and every id +//! it captures from its enclosing scope (the wrapper's outer IIFE) resolves +//! to a `Let` directly in that scope — see [`is_cjs_factory_shape`]. In +//! practice the factory always captures exactly one such id, its own name: +//! the wrapper's preamble does `__cjs_module.__perry_cjs_factory = +//! __perry_cjs_factory;`, a load-bearing self-reference `perry-runtime`'s +//! `module_require.rs` calls through on a circular-require recovery path. +//! Since a chunk is a plain, non-capturing `hir.functions` entry, it cannot +//! read a captured id the way the original (unsplit) closure could — so +//! [`classify_for_chunking`] keeps every statement that references one of +//! the factory's captured ids inline in the residual body (never relocated +//! into a chunk), preserving the exact closure-capture read codegen already +//! provides. This is deliberately NOT solved by promoting the captured id to +//! a module global the way an ordinary cross-chunk `hir.init` let is: a +//! global is one program-wide instance, but a captured id is fresh per +//! closure invocation — promoting it would silently break the recovery path +//! above if it ever re-invokes the factory closure. `wrap_commonjs_for_target` +//! never produces a factory outside this shape, so failing the shape check +//! is a defensive exit, not an expected one. use std::collections::HashSet; @@ -121,6 +174,51 @@ pub(crate) fn is_entry_chunk(function: &perry_hir::Function) -> bool { && !function.is_exported } +/// Every entry-chunk function in `hir.functions`, indexed by id. Chunks are +/// origin-agnostic — this map does not distinguish a chunk split from +/// `hir.init` from one split from an outlined function body (#10575). +fn chunk_map(hir: &HirModule) -> std::collections::HashMap { + hir.functions + .iter() + .filter(|function| is_entry_chunk(function)) + .map(|function| (function.id, function)) + .collect() +} + +/// Is `stmt` a bare, no-argument call to one of `chunks`? The shape +/// [`chunk_statements`] emits for every chunk call site. +fn as_chunk_call<'a>( + stmt: &perry_hir::Stmt, + chunks: &std::collections::HashMap, +) -> Option<&'a perry_hir::Function> { + match stmt { + perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) if args.is_empty() => { + match callee.as_ref() { + perry_hir::Expr::FuncRef(id) => chunks.get(id).copied(), + _ => None, + } + } + _ => None, + } +} + +/// Reconstruct the source-order statement stream of `stmts` after outlining: +/// every chunk-call site is replaced by that chunk's body, in place. +fn logical_stmts_of<'a>( + stmts: &'a [perry_hir::Stmt], + chunks: &std::collections::HashMap, +) -> Vec<&'a perry_hir::Stmt> { + let mut logical = Vec::new(); + for stmt in stmts { + if let Some(chunk) = as_chunk_call(stmt, chunks) { + logical.extend(chunk.body.iter()); + } else { + logical.push(stmt); + } + } + logical +} + /// Reconstruct the source-order module-entry statement stream after outlining. /// /// Several codegen analyses intentionally inspect module declarations rather @@ -128,33 +226,219 @@ pub(crate) fn is_entry_chunk(function: &perry_hir::Function) -> bool { /// static-field deduplication, and early `process.env` assignments). Replacing /// a range with a chunk call must not hide those original statements from the /// analyses. Non-chunk calls and all inline statements are returned unchanged. +/// +/// This only ever looks at `hir.init` — the module's own top level. A +/// CommonJS factory body outlined by [`outline_cjs_factory_module`] is a +/// different logical scope (see [`logical_outlined_function_stmts`]) and is +/// intentionally NOT included here: callers of this function want "the +/// module's own top-level declarations", which for a CJS-wrapped unit +/// genuinely is just the wrapper's handful of statements. pub fn logical_entry_stmts(hir: &HirModule) -> Vec<&perry_hir::Stmt> { - let chunks: std::collections::HashMap = hir - .functions - .iter() - .filter(|function| is_entry_chunk(function)) - .map(|function| (function.id, function)) - .collect(); - let mut logical = Vec::new(); - for stmt in &hir.init { - let chunk = match stmt { - perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) - if args.is_empty() => + logical_stmts_of(&hir.init, &chunk_map(hir)) +} + +/// Literal name `cjs_wrap::wrap_commonjs_for_target` gives the CJS module +/// factory closure it generates (`crates/perry/src/commands/compile/cjs_wrap/ +/// wrap.rs`: `function __perry_cjs_factory() { ... }`). +const CJS_FACTORY_NAME: &str = "__perry_cjs_factory"; + +/// If `expr` is itself an `Expr::Closure`, or a same-expression invocation of +/// one (`Expr::Call { callee: Box, .. }` — the +/// `(function(){...})()` IIFE shape `wrap_commonjs_for_target` always uses to +/// wrap a CJS body), return that closure. `None` for anything else — this is +/// intentionally narrow rather than a fully general expression search; the +/// wrapper's shape is fixed and known. +fn as_inline_closure(expr: &perry_hir::Expr) -> Option<&perry_hir::Expr> { + match expr { + perry_hir::Expr::Closure { .. } => Some(expr), + perry_hir::Expr::Call { callee, .. } => as_inline_closure(callee.as_ref()), + _ => None, + } +} + +/// Mutable twin of [`as_inline_closure`]. +fn as_inline_closure_mut(expr: &mut perry_hir::Expr) -> Option<&mut perry_hir::Expr> { + match expr { + perry_hir::Expr::Closure { .. } => Some(expr), + perry_hir::Expr::Call { callee, .. } => as_inline_closure_mut(callee.as_mut()), + _ => None, + } +} + +/// Is `closure` the exact shape `wrap_commonjs_for_target` generates for +/// `__perry_cjs_factory`: no params, an ordinary (non-async, non-generator) +/// function? `outer_body` is its enclosing scope (the wrapper's outer IIFE +/// body) — every id `closure` captures must be defined by a `Stmt::Let` +/// directly in `outer_body`, so [`outline_cjs_factory_module`] can always +/// find where a captured id comes from. (In practice `outer_body` has +/// exactly one such `Let` — the factory's own name — because the wrapper's +/// preamble does `__cjs_module.__perry_cjs_factory = __perry_cjs_factory;`, +/// a load-bearing self-reference `perry-runtime`'s `module_require.rs` calls +/// through for a circular-require recovery path; that is why the factory is +/// NOT required to capture nothing, unlike an ordinary outlining candidate.) +/// `wrap_commonjs_for_target` never produces anything outside this shape, so +/// failing the check is a defensive exit, not an expected one. +fn is_cjs_factory_shape( + params: &[perry_hir::Param], + captures: &[u32], + is_async: bool, + is_generator: bool, + outer_body_let_ids: &HashSet, +) -> bool { + if !params.is_empty() || is_async || is_generator { + return false; + } + captures.iter().all(|id| outer_body_let_ids.contains(id)) +} + +/// The ids directly `Stmt::Let`-defined in `body` (one level, not recursive +/// — exactly what a closure's own `captures` list can name from this scope). +fn top_level_let_ids(body: &[perry_hir::Stmt]) -> HashSet { + body.iter() + .filter_map(|stmt| match stmt { + perry_hir::Stmt::Let { id, .. } => Some(*id), + _ => None, + }) + .collect() +} + +/// Find the `__perry_cjs_factory` closure inside `init` (`hir.init`), if +/// `init` has the exact shape `wrap_commonjs_for_target` generates: a +/// top-level statement whose value is an immediately-invoked closure (the +/// wrapper's outer anonymous IIFE), one of whose OWN direct statements is a +/// `let __perry_cjs_factory = function() { ... }`-shaped binding (JS lowers +/// the wrapper's named `function __perry_cjs_factory() {...}` declaration to +/// exactly this: a `Stmt::Let` naming an `Expr::Closure`, not a top-level +/// `hir.functions` entry, because it is lexically nested inside the IIFE). +fn find_cjs_factory_closure(init: &[perry_hir::Stmt]) -> Option<&perry_hir::Expr> { + for stmt in init { + let outer_init = match stmt { + perry_hir::Stmt::Let { + init: Some(init), .. + } => init, + perry_hir::Stmt::Expr(expr) => expr, + _ => continue, + }; + let Some(perry_hir::Expr::Closure { + body: outer_body, .. + }) = as_inline_closure(outer_init) + else { + continue; + }; + let outer_body_let_ids = top_level_let_ids(outer_body); + for inner in outer_body { + let perry_hir::Stmt::Let { + name, + init: Some(init), + .. + } = inner + else { + continue; + }; + if name != CJS_FACTORY_NAME { + continue; + } + if let perry_hir::Expr::Closure { + params, + captures, + is_async, + is_generator, + .. + } = init { - match callee.as_ref() { - perry_hir::Expr::FuncRef(id) => chunks.get(id).copied(), - _ => None, + if is_cjs_factory_shape( + params, + captures, + *is_async, + *is_generator, + &outer_body_let_ids, + ) { + return Some(init); } } - _ => None, + } + } + None +} + +/// Mutable twin of [`find_cjs_factory_closure`]. Takes `&mut hir.init` +/// specifically (not `&mut HirModule`) so callers can hold this borrow while +/// independently borrowing `hir.functions` to append new chunk functions — +/// a function taking the whole module would make the borrow checker treat +/// every field as borrowed for as long as the returned reference lives. +fn find_cjs_factory_closure_mut(init: &mut [perry_hir::Stmt]) -> Option<&mut perry_hir::Expr> { + for stmt in init.iter_mut() { + let outer_init = match stmt { + perry_hir::Stmt::Let { + init: Some(init), .. + } => init, + perry_hir::Stmt::Expr(expr) => expr, + _ => continue, }; - if let Some(chunk) = chunk { - logical.extend(chunk.body.iter()); - } else { - logical.push(stmt); + let Some(perry_hir::Expr::Closure { + body: outer_body, .. + }) = as_inline_closure_mut(outer_init) + else { + continue; + }; + // Computed once as an OWNED set (not borrowed from `outer_body`) so + // the shape check below doesn't alias the `iter_mut()` that follows + // it — `outer_body`'s own `Let` ids don't change during this scan. + let outer_body_let_ids = top_level_let_ids(outer_body); + for inner in outer_body.iter_mut() { + let perry_hir::Stmt::Let { + name, + init: Some(init), + .. + } = inner + else { + continue; + }; + if name != CJS_FACTORY_NAME { + continue; + } + let shape_ok = matches!( + init, + perry_hir::Expr::Closure { params, captures, is_async, is_generator, .. } + if is_cjs_factory_shape(params, captures, *is_async, *is_generator, &outer_body_let_ids) + ); + if shape_ok { + return Some(init); + } } } - logical + None +} + +/// The CJS factory's residual body, if it was itself outlined this compile +/// (#10575) — i.e. it now contains at least one call to an entry chunk. +/// `None` for every module that isn't a large CJS bundle, and always `None` +/// when `hir.init` itself was the one outlined (a module is only ever +/// outlined from one origin). +fn outlined_factory_residual_body(hir: &HirModule) -> Option<&[perry_hir::Stmt]> { + let perry_hir::Expr::Closure { body, .. } = find_cjs_factory_closure(&hir.init)? else { + return None; + }; + let chunks = chunk_map(hir); + if body + .iter() + .any(|stmt| as_chunk_call(stmt, &chunks).is_some()) + { + Some(body) + } else { + None + } +} + +/// Like [`logical_entry_stmts`], but for the outlined CJS factory body +/// instead of `hir.init` (#10575). Empty unless [`outline_cjs_factory_module`] +/// actually outlined something this compile. +pub(crate) fn logical_outlined_function_stmts(hir: &HirModule) -> Vec<&perry_hir::Stmt> { + let chunks = chunk_map(hir); + match outlined_factory_residual_body(hir) { + Some(body) => logical_stmts_of(body, &chunks), + None => Vec::new(), + } } /// Moved declarations whose storage crosses a generated-function boundary. @@ -229,21 +513,30 @@ pub(crate) fn outlined_entry_global_let_ids(hir: &HirModule) -> HashSet { } let chunk_ids: HashSet = chunks.iter().map(|function| function.id).collect(); - for stmt in &hir.init { - match stmt { - perry_hir::Stmt::PreallocateBoxes(ids) => { - globals.extend(ids.iter().filter(|id| definer.contains_key(id)).copied()); - } - perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) - if args.is_empty() - && matches!(callee.as_ref(), perry_hir::Expr::FuncRef(id) if chunk_ids.contains(id)) => - { - // The compiler-owned call itself carries no module-local use. - } - _ => { - let mut refs = HashSet::new(); - collect_ref_ids_in_stmts(std::slice::from_ref(stmt), &mut refs); - globals.extend(refs.into_iter().filter(|id| definer.contains_key(id))); + // Residual bodies to scan for must-stay statements that reference a + // chunk-defined let: `hir.init` (always) plus any function body that was + // ITSELF outlined by `outline_cjs_factory_module` (#10575) — today at + // most the CJS factory, never both origins in the same compile. + let residual_bodies: Vec<&[perry_hir::Stmt]> = std::iter::once(hir.init.as_slice()) + .chain(outlined_factory_residual_body(hir)) + .collect(); + for body in residual_bodies { + for stmt in body { + match stmt { + perry_hir::Stmt::PreallocateBoxes(ids) => { + globals.extend(ids.iter().filter(|id| definer.contains_key(id)).copied()); + } + perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) + if args.is_empty() + && matches!(callee.as_ref(), perry_hir::Expr::FuncRef(id) if chunk_ids.contains(id)) => + { + // The compiler-owned call itself carries no module-local use. + } + _ => { + let mut refs = HashSet::new(); + collect_ref_ids_in_stmts(std::slice::from_ref(stmt), &mut refs); + globals.extend(refs.into_iter().filter(|id| definer.contains_key(id))); + } } } } @@ -278,16 +571,30 @@ pub(crate) fn analyze_entry_outlining(hir: &HirModule) -> EntryOutlineAnalysis { /// Pure analysis for an explicit chunk target — the testable core (no env). fn analyze_entry_outlining_with_target(hir: &HirModule, target: usize) -> EntryOutlineAnalysis { - let stmts = &hir.init; + analyze_stmts_outlining(&hir.init, hir.has_top_level_await, target, &HashSet::new()) +} + +/// Pure statement-list analysis shared by `hir.init` and (#10575) an outlined +/// function body such as the CJS factory — no `HirModule` needed beyond the +/// statements themselves and whether the surrounding scope can suspend across +/// a top-level `await` (only ever true for `hir.init`; a plain function body +/// passes `false`). `must_stay_ids` is the CJS-factory closure's own captured +/// ids (empty for `hir.init`) — see [`classify_for_chunking`]. +fn analyze_stmts_outlining( + stmts: &[perry_hir::Stmt], + has_top_level_await: bool, + target: usize, + must_stay_ids: &HashSet, +) -> EntryOutlineAnalysis { let total_stmts = stmts.len(); let ranges = chunk_ranges(total_stmts, target); - let chunk_count = count_prospective_chunks(stmts, target); + let chunk_count = count_prospective_chunks(stmts, target, must_stay_ids); // A top-level await splits init across an async suspension. A module-level // TDZ preallocation needs checked global loads, which module globals do not // provide yet. Both cases stay on the original lowering rather than // accepting a semantic approximation. - let gated_out = if hir.has_top_level_await { + let gated_out = if has_top_level_await { Some("top-level await") } else if stmts .iter() @@ -350,7 +657,8 @@ pub(crate) fn report_entry_outlining(hir: &HirModule) { // The transform runs in the HIR pipeline before codegen. Report clearly // when these figures describe the compact call stream rather than source // top-level statements. - let transform = if hir.functions.iter().any(is_entry_chunk) { + let already_outlined = hir.functions.iter().any(is_entry_chunk); + let transform = if already_outlined { " (already outlined; figures describe the chunk-call stream)" } else { "" @@ -371,6 +679,33 @@ pub(crate) fn report_entry_outlining(hir: &HirModule) { transform ), } + // #10575: a CommonJS module's real body is not in `hir.init` at all — it + // is the `__perry_cjs_factory` closure `cjs_wrap::wrap_commonjs_for_target` + // generates. Report on it too, using the same analysis, so the report + // reflects the body that will actually be outlined for a CJS module. + if let Some(perry_hir::Expr::Closure { body, captures, .. }) = + find_cjs_factory_closure(&hir.init) + { + let target = target_chunk_stmts(); + let must_stay_ids: HashSet = captures.iter().copied().collect(); + let fa = analyze_stmts_outlining(body, false, target, &must_stay_ids); + match fa.gated_out { + Some(reason) => eprintln!( + "[perry] entry-outline: {}: cjs factory: {} stmts; NOT a candidate ({}){}", + hir.name, fa.total_stmts, reason, transform + ), + None => eprintln!( + "[perry] entry-outline: {}: cjs factory: {} stmts → {} chunk(s) of ~{}, {} cross-chunk let(s) to globalize; candidate={}{}", + hir.name, + fa.total_stmts, + fa.chunk_count, + target, + fa.cross_chunk_lets, + fa.is_candidate(), + transform + ), + } + } } /// Outcome of attempting to outline a module entry body. @@ -568,11 +903,57 @@ fn stmt_contains_return(stmt: &perry_hir::Stmt) -> bool { } } +/// Does `stmt` reference any id in `must_stay_ids`? Always `false` (and O(1)) +/// for the overwhelmingly common empty case — `hir.init` never has must-stay +/// ids; only an outlined CJS factory closure does, one for each id it +/// captures from its enclosing scope (see [`is_cjs_factory_shape`]). +fn stmt_references_any(stmt: &perry_hir::Stmt, must_stay_ids: &HashSet) -> bool { + if must_stay_ids.is_empty() { + return false; + } + let mut refs = HashSet::new(); + collect_ref_ids_in_stmts(std::slice::from_ref(stmt), &mut refs); + refs.iter().any(|id| must_stay_ids.contains(id)) +} + +/// Like [`classify_top_level`], but a statement referencing one of +/// `must_stay_ids` is always treated as must-stay (`None`), regardless of +/// what `classify_top_level` would otherwise say. +/// +/// This exists for #10575's CJS-factory path: the factory closure captures +/// its own name from its enclosing scope (the wrapper's outer IIFE) — a +/// load-bearing self-reference `perry-runtime`'s `module_require.rs` calls +/// through on a circular-require recovery path. Chunk functions are plain, +/// non-capturing `hir.functions` entries, so a captured id can only be read +/// correctly from wherever the ORIGINAL closure-capture read already was — +/// it must never be relocated into a chunk. Keeping that one statement (in +/// practice, the wrapper's `__cjs_module.__perry_cjs_factory = +/// __perry_cjs_factory;` preamble line) inline preserves the exact +/// per-invocation closure-capture semantics codegen already provides, +/// instead of promoting the captured id to a module global — which would +/// change its lifetime from "fresh per closure call" to "one program-wide +/// instance," silently breaking that recovery path if it ever re-invokes the +/// factory. `hir.init`'s own outlining always passes an empty set here, so +/// this is a no-op for every non-CJS module. +fn classify_for_chunking( + stmt: &perry_hir::Stmt, + must_stay_ids: &HashSet, +) -> Option { + if stmt_references_any(stmt, must_stay_ids) { + return None; + } + classify_top_level(stmt) +} + /// How many chunk functions the interleaving would emit for `stmts` at /// `target` — a run of relocatable statements becomes ceil(run/target) chunks, /// and a must-stay statement (an unclassifiable shape) ends the current run. /// Used as a pre-scan so eligibility is decided before any mutation. -fn count_prospective_chunks(stmts: &[perry_hir::Stmt], target: usize) -> usize { +fn count_prospective_chunks( + stmts: &[perry_hir::Stmt], + target: usize, + must_stay_ids: &HashSet, +) -> usize { let mut chunks = 0usize; let mut run = 0usize; let mut run_safepoints = 0usize; @@ -584,7 +965,7 @@ fn count_prospective_chunks(stmts: &[perry_hir::Stmt], target: usize) -> usize { } }; for stmt in stmts { - match classify_top_level(stmt) { + match classify_for_chunking(stmt, must_stay_ids) { Some(TopLevelKind::Relocatable) => { run += 1; run_safepoints = run_safepoints.saturating_add( @@ -601,23 +982,57 @@ fn count_prospective_chunks(stmts: &[perry_hir::Stmt], target: usize) -> usize { chunks } -/// Attempt to outline `hir`'s entry body (#8595). Fail-safe: returns -/// `Skipped(reason)` and leaves `hir` untouched unless the whole body is -/// provably safe to relocate; callers proceed with the ordinary single-function -/// entry lowering in that case. +/// Attempt to outline `hir`'s entry body (#8595), and — if that finds nothing +/// to do — the CJS factory body instead (#10575). Fail-safe: returns +/// `Skipped(reason)` and leaves `hir` untouched unless a body is provably +/// safe to relocate; callers proceed with the ordinary single-function +/// lowering in that case. +/// +/// A module is only ever outlined from one origin: `hir.init` for an +/// ordinary (ESM/script) module, or the CJS factory for a CommonJS-wrapped +/// one. `hir.init` is tried first because it is cheap to check (a CJS +/// module's own `hir.init` is a handful of wrapper statements, never a +/// candidate) and because it is the historical #8595 behavior. pub fn outline_entry_module(hir: &mut HirModule) -> OutlineOutcome { - let mode = outline_mode(); + outline_entry_module_core(hir, outline_mode(), target_chunk_stmts()) +} + +/// Env-free core of [`outline_entry_module`] — the testable seam for the +/// hir.init-vs-CJS-factory orchestration itself (mode and chunk target are +/// ordinary parameters here, not read from the process environment, so +/// tests can exercise both branches without touching global env-var state +/// shared across a parallel test run). +fn outline_entry_module_core( + hir: &mut HirModule, + mode: OutlineMode, + target: usize, +) -> OutlineOutcome { if mode == OutlineMode::Disabled { return OutlineOutcome::Skipped("PERRY_OUTLINE_ENTRY disabled"); } let safepoints = crate::collectors::count_safepoint_sites(&hir.init); - if mode == OutlineMode::Auto && !meets_automatic_size_threshold(hir.init.len(), safepoints) { - return OutlineOutcome::Skipped("below automatic outlining threshold"); + let init_outcome = if mode == OutlineMode::Auto + && !meets_automatic_size_threshold(hir.init.len(), safepoints) + { + OutlineOutcome::Skipped("below automatic outlining threshold") + } else { + outline_entry_module_with_target(hir, target) + }; + if matches!(init_outcome, OutlineOutcome::Outlined { .. }) { + return init_outcome; + } + // #10575: `hir.init` was not a candidate (the overwhelmingly common case + // for a CJS-wrapped unit, whose whole body lives in `__perry_cjs_factory` + // instead — see the module doc comment). Try that function's body with + // the identical transform before giving up. + match outline_cjs_factory_module(hir, mode, target) { + outlined @ OutlineOutcome::Outlined { .. } => outlined, + OutlineOutcome::Skipped(_) => init_outcome, } - outline_entry_module_with_target(hir, target_chunk_stmts()) } -/// Env-free core of [`outline_entry_module`] — the testable seam. +/// Env-free core of [`outline_entry_module`]'s `hir.init` path — the testable +/// seam. fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> OutlineOutcome { let analysis = analyze_entry_outlining_with_target(hir, target); if let Some(reason) = analysis.gated_out { @@ -627,8 +1042,11 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli return OutlineOutcome::Skipped("not a candidate (too small)"); } // Pre-scan: decide eligibility before mutating. Outlining is worthwhile - // only if the interleaving would emit more than one chunk. - let prospective_chunks = count_prospective_chunks(&hir.init, target); + // only if the interleaving would emit more than one chunk. `hir.init` + // has no must-stay ids of its own (that concept exists only for the CJS + // factory's captured self-reference, #10575). + let no_must_stay_ids = HashSet::new(); + let prospective_chunks = count_prospective_chunks(&hir.init, target, &no_must_stay_ids); if prospective_chunks <= 1 { return OutlineOutcome::Skipped("would not split into multiple chunks"); } @@ -645,8 +1063,123 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli let module_is_strict = hir.init_is_strict; let original = std::mem::take(&mut hir.init); - // The rewritten body: chunk calls interleaved with any statement that had - // to stay inline, in original execution order. + let (new_body, chunk_fns) = chunk_statements( + original, + target, + &module_name, + module_is_strict, + &no_must_stay_ids, + &mut next_id, + ); + let chunks = chunk_fns.len(); + hir.functions.extend(chunk_fns); + hir.init = new_body; + OutlineOutcome::Outlined { chunks } +} + +/// The CJS-factory half of [`outline_entry_module`] (#10575): apply the +/// identical chunking transform to the `__perry_cjs_factory` closure's body +/// instead of `hir.init`. +/// +/// Unlike an ordinary named function declaration, this closure is NOT a +/// `hir.functions` entry — it is lexically nested inside the wrapper's outer +/// anonymous IIFE, so lowering represents it the same way as any other +/// function EXPRESSION: a `Stmt::Let` (naming it `__perry_cjs_factory`) +/// whose `init` is an `Expr::Closure`, reachable only by walking `hir.init`'s +/// statement/expression tree (see [`find_cjs_factory_closure_mut`]). New +/// chunk functions are still ordinary `hir.functions` entries — nothing +/// about where a *chunk* lives changes — only the body being split is found +/// differently. +fn outline_cjs_factory_module( + hir: &mut HirModule, + mode: OutlineMode, + target: usize, +) -> OutlineOutcome { + // Whole-module reads that must happen BEFORE taking a mutable borrow of + // `hir.init` below: once `find_cjs_factory_closure_mut` hands back a + // `&mut Expr` borrowed from `hir.init`, only `hir.init`-disjoint fields + // (like `hir.functions`, appended after) remain independently + // borrowable — a helper taking `&mut HirModule` as a whole would make + // the borrow checker treat every field as borrowed for the reference's + // lifetime, since it can't see the field-level split through the call. + let max_id = max_func_id(hir); + let module_name = hir.name.clone(); + + let Some(closure) = find_cjs_factory_closure_mut(&mut hir.init) else { + return OutlineOutcome::Skipped("no CommonJS factory function"); + }; + let perry_hir::Expr::Closure { + body, + captures, + is_strict, + .. + } = closure + else { + unreachable!("find_cjs_factory_closure_mut only ever returns Expr::Closure"); + }; + // The factory's own captured ids (in practice, just its self-reference — + // see the module doc comment) must never be relocated into a chunk: a + // chunk is a plain, non-capturing function and cannot read them. + // `is_cjs_factory_shape` already proved each one resolves to a `Let` in + // the enclosing IIFE, so keeping their reference sites inline preserves + // the exact closure-capture read codegen already emits for them. + let must_stay_ids: HashSet = captures.iter().copied().collect(); + + let safepoints = crate::collectors::count_safepoint_sites(body); + if mode == OutlineMode::Auto && !meets_automatic_size_threshold(body.len(), safepoints) { + return OutlineOutcome::Skipped("cjs factory below automatic outlining threshold"); + } + let analysis = analyze_stmts_outlining(body, false, target, &must_stay_ids); + if let Some(reason) = analysis.gated_out { + return OutlineOutcome::Skipped(reason); + } + if !analysis.is_candidate() { + return OutlineOutcome::Skipped("not a candidate (too small)"); + } + let prospective_chunks = count_prospective_chunks(body, target, &must_stay_ids); + if prospective_chunks <= 1 { + return OutlineOutcome::Skipped("would not split into multiple chunks"); + } + if prospective_chunks > (u32::MAX - max_id) as usize { + return OutlineOutcome::Skipped("function id space exhausted"); + } + let mut next_id = max_id + 1; + // A chunk carries the factory's own strictness, exactly as an `hir.init` + // chunk carries the module's (#9423) — these statements were the + // factory's top-level body a moment ago. + let is_strict = *is_strict; + let original = std::mem::take(body); + + let (new_body, chunk_fns) = chunk_statements( + original, + target, + &module_name, + is_strict, + &must_stay_ids, + &mut next_id, + ); + *body = new_body; + let chunks = chunk_fns.len(); + // `closure`/`body` borrowed only `hir.init`, so `hir.functions` remains + // independently borrowable here — see the comment above. + hir.functions.extend(chunk_fns); + OutlineOutcome::Outlined { chunks } +} + +/// Split `original` into chunk functions of ~`target` relocatable statements +/// each, returning the rewritten residual body (chunk calls interleaved with +/// any must-stay statement, in original order) and the new chunk functions. +/// Shared by the `hir.init` path and the CJS-factory path (#10575) — the only +/// difference between them is WHERE `original` came from and where the +/// results get written back. +fn chunk_statements( + original: Vec, + target: usize, + module_name: &str, + is_strict: bool, + must_stay_ids: &HashSet, + next_id: &mut u32, +) -> (Vec, Vec) { let mut new_body: Vec = Vec::new(); let mut chunk_fns: Vec = Vec::new(); // The current run of relocatable statements accumulating into a chunk. @@ -654,15 +1187,15 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli let mut run_safepoints = 0usize; // Emit the accumulated run as a chunk function and append its call, unless - // empty. `flush` is a closure over the mutable state via explicit params to - // keep the borrow checker happy. + // empty. `flush` is a plain fn over explicit params to keep the borrow + // checker happy. fn flush( run: &mut Vec, chunk_fns: &mut Vec, new_body: &mut Vec, next_id: &mut u32, module_name: &str, - module_is_strict: bool, + is_strict: bool, ) { if run.is_empty() { return; @@ -683,7 +1216,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli // real strictness. A chunk holds statements that were module // top-level code a moment ago; relocating them into a function must // not relax the mode they execute in. - is_strict: module_is_strict, + is_strict, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -699,20 +1232,20 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli } for stmt in original { - match classify_top_level(&stmt) { + match classify_for_chunking(&stmt, must_stay_ids) { Some(TopLevelKind::Relocatable) => run.push(stmt), None => { // A statement we cannot safely relocate (control flow, etc.): // end the current chunk run and keep this statement inline, at - // its original position, so eval order and any `hir.init` scan - // that reads it are preserved. + // its original position, so eval order and any residual-body + // scan that reads it are preserved. flush( &mut run, &mut chunk_fns, &mut new_body, - &mut next_id, - &module_name, - module_is_strict, + next_id, + module_name, + is_strict, ); run_safepoints = 0; new_body.push(stmt); @@ -728,9 +1261,9 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut run, &mut chunk_fns, &mut new_body, - &mut next_id, - &module_name, - module_is_strict, + next_id, + module_name, + is_strict, ); run_safepoints = 0; } @@ -739,15 +1272,12 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut run, &mut chunk_fns, &mut new_body, - &mut next_id, - &module_name, - module_is_strict, + next_id, + module_name, + is_strict, ); - let chunks = chunk_fns.len(); - hir.functions.extend(chunk_fns); - hir.init = new_body; - OutlineOutcome::Outlined { chunks } + (new_body, chunk_fns) } #[cfg(test)] @@ -809,7 +1339,7 @@ mod tests { }; let mut m = module_with_init(vec![allocation_heavy_stmt(), allocation_heavy_stmt()]); assert_eq!( - count_prospective_chunks(&m.init, usize::MAX), + count_prospective_chunks(&m.init, usize::MAX, &HashSet::new()), 2, "each allocation-heavy statement should exhaust a chunk budget" ); @@ -1150,4 +1680,289 @@ mod tests { let outcome = outline_entry_module_with_target(&mut m, 1); assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 2 }); } + + // --- #10575: CJS factory outlining ------------------------------------- + + /// A plain closure expression, reused for both the wrapper's outer + /// anonymous IIFE and (by default) its inner `__perry_cjs_factory`. + fn factory_closure(func_id: u32, body: Vec) -> Expr { + Expr::Closure { + func_id, + params: vec![], + return_type: Type::Any, + body, + captures: vec![], + mutable_captures: vec![], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: false, + is_generator: false, + is_strict: false, + } + } + + fn call_no_args(callee: Expr) -> Expr { + Expr::Call { + callee: Box::new(callee), + args: vec![], + type_args: vec![], + byte_offset: 0, + } + } + + /// Build the exact `hir.init` shape `wrap_commonjs_for_target` produces + /// for a CJS module — `const _cjs = (function() { let + /// __perry_cjs_factory = function() { ... }; return + /// __perry_cjs_factory(); })();` — with an explicit `factory` closure + /// expression, so the shape-rejection tests can hand in a deliberately + /// wrong one. `leading` is prepended to the IIFE's own body (used to + /// prove a preceding `PreallocateBoxes`, as a real compile emits, does + /// not defeat the search). + fn cjs_wrapped_init_with(leading: Vec, factory: Expr) -> Vec { + let mut outer_body = leading; + outer_body.push(let_stmt(101, CJS_FACTORY_NAME, factory)); + outer_body.push(Stmt::Return(Some(call_no_args(Expr::LocalGet(101))))); + let outer_closure = factory_closure(100, outer_body); + vec![let_stmt(102, "_cjs", call_no_args(outer_closure))] + } + + fn cjs_wrapped_init(factory_body: Vec) -> Vec { + cjs_wrapped_init_with(vec![], factory_closure(103, factory_body)) + } + + fn factory_closure_with_captures(func_id: u32, body: Vec, captures: Vec) -> Expr { + let mut closure = factory_closure(func_id, body); + if let Expr::Closure { captures: c, .. } = &mut closure { + *c = captures; + } + closure + } + + #[test] + fn find_cjs_factory_closure_matches_only_the_wrap_generated_shape() { + assert!(find_cjs_factory_closure(&cjs_wrapped_init(vec![])).is_some()); + + // A preceding `PreallocateBoxes` (as a real compile emits for the + // hoisted function declaration) does not defeat the search. + let with_prealloc = cjs_wrapped_init_with( + vec![Stmt::PreallocateBoxes(vec![101])], + factory_closure(103, vec![]), + ); + assert!(find_cjs_factory_closure(&with_prealloc).is_some()); + + // A param disqualifies it — the wrapper's factory always takes none. + let mut with_param = factory_closure(103, vec![]); + if let Expr::Closure { params, .. } = &mut with_param { + params.push(perry_hir::Param { + id: 200, + name: "x".into(), + ty: Type::Any, + default: None, + decorators: vec![], + is_rest: false, + arguments_object: None, + }); + } + assert!(find_cjs_factory_closure(&cjs_wrapped_init_with(vec![], with_param)).is_none()); + + // Async/generator/capturing disqualify it too. + let mut async_factory = factory_closure(103, vec![]); + if let Expr::Closure { is_async, .. } = &mut async_factory { + *is_async = true; + } + assert!(find_cjs_factory_closure(&cjs_wrapped_init_with(vec![], async_factory)).is_none()); + + let mut generator_factory = factory_closure(103, vec![]); + if let Expr::Closure { is_generator, .. } = &mut generator_factory { + *is_generator = true; + } + assert!( + find_cjs_factory_closure(&cjs_wrapped_init_with(vec![], generator_factory)).is_none() + ); + + let mut capturing_factory = factory_closure(103, vec![]); + if let Expr::Closure { captures, .. } = &mut capturing_factory { + captures.push(7); + } + assert!( + find_cjs_factory_closure(&cjs_wrapped_init_with(vec![], capturing_factory)).is_none() + ); + + // A differently-named binding is never mistaken for the factory. + let mut unrelated_body = vec![let_stmt(101, "helper", factory_closure(103, vec![]))]; + unrelated_body.push(Stmt::Return(Some(call_no_args(Expr::LocalGet(101))))); + let unrelated = vec![let_stmt( + 102, + "_cjs", + call_no_args(factory_closure(100, unrelated_body)), + )]; + assert!(find_cjs_factory_closure(&unrelated).is_none()); + + // No CJS wrapper at all (an ordinary ESM module). + assert!(find_cjs_factory_closure(&[let_stmt(0, "x", Expr::Number(1.0))]).is_none()); + } + + #[test] + fn cjs_factory_body_is_outlined_when_hir_init_is_not_a_candidate() { + // hir.init is the wrap_commonjs shape: a single statement (the `_cjs` + // binding), never a candidate on its own (chunk_count is always 1 + // for one statement, regardless of target) — the real body lives + // inside the nested `__perry_cjs_factory` closure. + // + // Factory body: `let shared = 1` (chunk), a lone statement (chunk), + // then `shared` read back (chunk) — the same cross-chunk-let shape as + // `only_boundary_crossing_or_preallocated_bindings_become_globals`, + // now living inside a closure instead of directly in `hir.init`. + let mut m = module_with_init(cjs_wrapped_init(vec![ + let_stmt(10, "shared", Expr::Number(1.0)), + Stmt::Expr(Expr::Number(0.0)), + Stmt::Expr(Expr::LocalGet(10)), + ])); + + let outcome = outline_entry_module_core(&mut m, OutlineMode::Forced, 1); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 3 }); + + // hir.init's own top-level shape is untouched — one statement, the + // `_cjs` binding — the transform outlined the FACTORY, not the + // module top level. + assert_eq!(m.init.len(), 1); + assert!(matches!(&m.init[0], Stmt::Let { name, .. } if name == "_cjs")); + + let Some(Expr::Closure { body, .. }) = find_cjs_factory_closure(&m.init) else { + panic!("factory closure still present and findable"); + }; + assert_eq!(body.len(), 3, "three ordered chunk calls"); + assert!( + body.iter() + .all(|s| matches!(s, Stmt::Expr(Expr::Call { .. }))), + "every residual statement in the factory is a chunk call: {body:?}" + ); + assert_eq!( + m.functions.iter().filter(|f| is_entry_chunk(f)).count(), + 3, + "three chunk functions were created" + ); + + // The cross-chunk let inside the factory is promoted exactly like a + // cross-chunk `hir.init` let would be. + assert_eq!(outlined_entry_global_let_ids(&m), HashSet::from([10])); + + // The factory's original statement order is recoverable for any + // codegen scan that needs it (mirrors `logical_entry_stmts` for + // `hir.init`). + let logical = logical_outlined_function_stmts(&m); + assert_eq!(logical.len(), 3); + assert!(matches!(logical[0], Stmt::Let { id: 10, .. })); + } + + #[test] + fn cjs_factory_self_reference_capture_stays_inline_not_chunked() { + // Mirrors the real `wrap_commonjs_for_target` shape: the wrapper's + // preamble does `__cjs_module.__perry_cjs_factory = + // __perry_cjs_factory;`, so the factory closure ALWAYS captures its + // own name (id 101 here — the wrapper's own `Let`) from the + // enclosing IIFE. `perry-runtime`'s `module_require.rs` calls + // through that captured value on a circular-require recovery path, + // so it must keep working after outlining. A chunk is a plain, + // non-capturing `hir.functions` entry and cannot read a captured id + // — the statement reading it (here, a bare `LocalGet(101)` standing + // in for the real assignment) must stay in the factory's own + // residual body, never relocated into a chunk, so it keeps reading + // it via ordinary closure-capture codegen exactly as before + // outlining (#10575) — NOT via a promoted module global, which + // would change a per-invocation-fresh capture into a program-wide + // single instance and silently break that recovery path if it ever + // re-invokes the factory. + let self_reference_read = Stmt::Expr(Expr::LocalGet(101)); + let factory = factory_closure_with_captures( + 103, + vec![ + self_reference_read, + let_stmt(10, "shared", Expr::Number(1.0)), + Stmt::Expr(Expr::Number(0.0)), + Stmt::Expr(Expr::LocalGet(10)), + ], + vec![101], + ); + let mut m = module_with_init(cjs_wrapped_init_with(vec![], factory)); + + let outcome = outline_entry_module_core(&mut m, OutlineMode::Forced, 1); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 3 }); + + let Some(Expr::Closure { body, .. }) = find_cjs_factory_closure(&m.init) else { + panic!("factory closure still present and findable"); + }; + assert!( + matches!(&body[0], Stmt::Expr(Expr::LocalGet(101))), + "the captured self-reference read stayed inline, in its \ + original (first) position: {body:?}" + ); + assert!( + body[1..] + .iter() + .all(|s| matches!(s, Stmt::Expr(Expr::Call { .. }))), + "everything else still outlined into ordered chunk calls: {body:?}" + ); + } + + #[test] + fn outline_entry_module_core_prefers_hir_init_over_the_cjs_factory() { + // hir.init has three top-level statements — two ordinary ones and + // (as one item among them) the CJS wrapper statement — all + // independently large enough to outline at target=1. hir.init must + // win: #8595's original behavior is unchanged, and a module is only + // ever outlined from one origin. #8595's transform has no CJS-aware + // special case, so it relocates the wrapper statement whole (as an + // opaque `Stmt::Let`) into its own chunk, untouched internally. + let mut init = vec![ + let_stmt(0, "x", Expr::Number(1.0)), + Stmt::Expr(Expr::LocalGet(0)), + ]; + init.extend(cjs_wrapped_init(vec![ + let_stmt(10, "shared", Expr::Number(1.0)), + Stmt::Expr(Expr::LocalGet(10)), + ])); + let mut m = module_with_init(init); + + let outcome = outline_entry_module_core(&mut m, OutlineMode::Forced, 1); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 3 }); + assert_eq!(m.init.len(), 3, "hir.init's own three chunk calls"); + + // The factory body is untouched: reconstruct the logical hir.init + // view (inlining hir.init's own chunks back) and dig into the + // relocated CJS-wrapper statement's nested closure. + let logical = logical_entry_stmts(&m); + assert_eq!(logical.len(), 3); + let cjs_stmt: &Stmt = *logical + .iter() + .find(|s| matches!(s, Stmt::Let { name, .. } if name == "_cjs")) + .expect("the CJS wrapper statement survived, just relocated"); + let Some(Expr::Closure { body, .. }) = + find_cjs_factory_closure(std::slice::from_ref(cjs_stmt)) + else { + panic!("the factory closure is still findable inside it"); + }; + assert_eq!(body.len(), 2, "the factory body was never touched"); + assert!(matches!(&body[0], Stmt::Let { id: 10, .. })); + + // No factory-outlining occurred: only hir.init's own three chunks + // exist. + assert_eq!(m.functions.iter().filter(|f| is_entry_chunk(f)).count(), 3); + } + + #[test] + fn outline_entry_module_core_declines_with_no_candidate_on_either_side() { + let mut m = module_with_init(vec![let_stmt(0, "_cjs", Expr::Number(0.0))]); + // No `__perry_cjs_factory` function at all (an ordinary small ESM + // module) — nothing to outline on either side. + let outcome = outline_entry_module_core(&mut m, OutlineMode::Forced, 1); + assert_eq!( + outcome, + OutlineOutcome::Skipped("not a candidate (too small)") + ); + assert_eq!(m.init.len(), 1); + assert!(m.functions.is_empty()); + } } diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index ee0669c9a4..c13c43a445 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -381,9 +381,16 @@ pub(crate) fn emit_module_globals( } } let logical_entry = super::entry_outline::logical_entry_stmts(hir); + // #10575: a CommonJS module's real top level is the `__perry_cjs_factory` + // function body, not `hir.init`. When that body was outlined (see + // `entry_outline::outline_cjs_factory_module`), its cross-chunk `var`s + // need exactly the same global promotion `hir.init`'s cross-chunk `let`s + // get. Empty when nothing was outlined from a function body, so this is a + // no-op for every module that isn't a large CJS bundle. + let logical_outlined_functions = super::entry_outline::logical_outlined_function_stmts(hir); let outlined_entry_globals = super::entry_outline::outlined_entry_global_let_ids(hir); let mut init_lets: Vec<&perry_hir::Stmt> = Vec::new(); - for stmt in logical_entry { + for stmt in logical_entry.into_iter().chain(logical_outlined_functions) { collect_init_lets(std::slice::from_ref(stmt), &mut init_lets); } // `Expr::New { class_name }` does not retain whether an unqualified name From 2b43a74c6b90423ce504a95752d0f82e1925cb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:06:56 +0000 Subject: [PATCH 07/30] fix(runtime): timeout.refresh() preserves ref state; Immediate has no numeric conversion refresh() called set_timer_ref_state(id, true) unconditionally, so refreshing an unrefd Timeout/Interval re-refd it -- hasRef() flipped to true and the process stayed alive for a callback that had been deliberately detached from the event loop. Node refresh() reschedules only and never touches ref state; drop the forced ref-state write and let the existing entry (set at schedule time, updated by any ref()/unref() since, pinned while the timer is queued) stand. js_number_coerce gave setImmediate handles the same numeric-conversion shortcut as Timeout handles, so +setImmediate(...) returned a number instead of NaN. Node only gives Timeout (setTimeout/setInterval) a numeric conversion; Immediate has none. Add is_immediate_timer_id and gate the shortcut on it so an Immediate falls through to the generic toPrimitive/toString path, which already yields NaN. Fixes #10541 Fixes #10542 --- crates/perry-runtime/src/builtins/numbers.rs | 6 ++ crates/perry-runtime/src/timer.rs | 21 +++- .../perry-runtime/src/timer/tests_inline.rs | 71 ++++++++++++++ ...2_timer_refresh_ref_immediate_primitive.ts | 97 +++++++++++++++++++ 4 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_10541_10542_timer_refresh_ref_immediate_primitive.ts diff --git a/crates/perry-runtime/src/builtins/numbers.rs b/crates/perry-runtime/src/builtins/numbers.rs index 6c4c6bf065..d2f8760cd8 100644 --- a/crates/perry-runtime/src/builtins/numbers.rs +++ b/crates/perry-runtime/src/builtins/numbers.rs @@ -562,8 +562,14 @@ pub extern "C" fn js_number_coerce(value: f64) -> f64 { // identifiers, so test assertions like `typeof x === "number"` // hold). Gate on the timer registry so unrelated small handles // (UI widgets, drizzle, etc.) still fall through to toPrimitive. + // #10542: only a Timeout (setTimeout/setInterval) converts to its + // id this way -- an Immediate (setImmediate) has no numeric + // conversion in Node and must fall through to the generic + // toPrimitive/toString path below (which yields NaN, matching + // `+setImmediate(...)`). if crate::value::addr_class::is_small_handle(id as usize) && crate::timer::is_known_timer_id(id) + && !crate::timer::is_immediate_timer_id(id) { return id as f64; } diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index 95b1162978..55328a9df2 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -634,6 +634,17 @@ pub(crate) fn timer_constructor_value(id: i64) -> Option { pub use ref_states::is_known_timer_id; +/// Whether `id` is specifically a `setImmediate` handle, as opposed to a +/// `Timeout` (`setTimeout`/`setInterval`, which Node also names `Timeout`). +/// #10542: Node's `Timeout` has a numeric conversion (`+setTimeout(...)` is +/// its internal id) but `Immediate` does not (`+setImmediate(...)` is +/// `NaN`) -- `js_number_coerce` gates its Timeout-only numeric shortcut on +/// this so an Immediate falls through to the generic (object-shaped) +/// ToPrimitive path instead. +pub(crate) fn is_immediate_timer_id(id: i64) -> bool { + matches!(timer_handle_kind(id), Some(CallbackTimerKind::Immediate)) +} + fn throw_mock_timer_invalid_state(message: &str) -> ! { let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); crate::node_submodules::register_error_code_pub(msg, "ERR_INVALID_STATE"); @@ -921,6 +932,14 @@ pub extern "C" fn js_timer_unref(timer_id: i64) { /// resets the next-deadline cursor to one full interval from now. #[no_mangle] pub extern "C" fn js_timer_refresh(timer_id: i64) { + // #10541: refresh() reschedules only -- it must NOT change ref state. + // Node's Timeout.refresh() "sets the timer's start time to the current + // time" and says nothing about ref/unref; a timer that was unref'd + // before refresh() stays unref'd (and a ref'd one stays ref'd). The + // id's ref-state entry is left untouched here -- it was set at + // schedule() time and by any ref()/unref() call since, and it cannot + // have been evicted while this timer is still queued (its + // `_scheduled: ScheduledTimerId` field pins the registry entry). let now = Instant::now(); { @@ -928,7 +947,6 @@ pub extern "C" fn js_timer_refresh(timer_id: i64) { if let Some(timer) = timers.iter_mut().find(|t| t.id == timer_id) { timer.deadline = now + Duration::from_millis(timer.delay_ms); timer.cleared = false; - set_timer_ref_state(timer_id, true); return; } } @@ -937,7 +955,6 @@ pub extern "C" fn js_timer_refresh(timer_id: i64) { if let Some(timer) = intervals.iter_mut().find(|t| t.id == timer_id) { timer.next_deadline = now + Duration::from_millis(timer.interval_ms); timer.cleared = false; - set_timer_ref_state(timer_id, true); } } diff --git a/crates/perry-runtime/src/timer/tests_inline.rs b/crates/perry-runtime/src/timer/tests_inline.rs index 96c6255cad..a4ee3ed9b3 100644 --- a/crates/perry-runtime/src/timer/tests_inline.rs +++ b/crates/perry-runtime/src/timer/tests_inline.rs @@ -314,3 +314,74 @@ mod mock_dispatch_own_pin_tests { js_mock_timers_reset(); } } + +#[cfg(test)] +mod refresh_and_immediate_primitive_tests { + use super::*; + + /// #10541: `refresh()` reschedules a timer but must not touch its ref + /// state -- neither re-ref an unref'd timer/interval nor unref a ref'd + /// one. Before the fix `js_timer_refresh` unconditionally called + /// `set_timer_ref_state(id, true)`. + #[test] + fn refresh_preserves_ref_state() { + let _serial = crate::gc::global_side_table_test_lock(); + test_clear_all_timer_scanner_roots(); + + let unrefd = js_set_timeout_callback(0, 50_000.0); + js_timer_unref(unrefd); + assert_eq!(js_timer_has_ref(unrefd), 0, "setup: unref() didn't take"); + js_timer_refresh(unrefd); + assert_eq!( + js_timer_has_ref(unrefd), + 0, + "refresh() re-ref'd an unref'd timeout" + ); + + let refd = js_set_timeout_callback(0, 50_000.0); + assert_eq!(js_timer_has_ref(refd), 1, "setup: new timer isn't ref'd"); + js_timer_refresh(refd); + assert_eq!( + js_timer_has_ref(refd), + 1, + "refresh() unref'd a ref'd timeout" + ); + + let unrefd_interval = setInterval(0, 50_000.0); + js_timer_unref(unrefd_interval); + js_timer_refresh(unrefd_interval); + assert_eq!( + js_timer_has_ref(unrefd_interval), + 0, + "refresh() re-ref'd an unref'd interval" + ); + + clearTimeout(unrefd); + clearTimeout(refd); + clearInterval(unrefd_interval); + } + + /// #10542: a `setImmediate` handle is distinguished from a + /// `setTimeout`/`setInterval` handle by kind, so `js_number_coerce` can + /// gate its Timeout-only numeric shortcut on it. + #[test] + fn immediate_kind_is_distinguished_from_timeout() { + let _serial = crate::gc::global_side_table_test_lock(); + test_clear_all_timer_scanner_roots(); + + let timeout = js_set_timeout_callback(0, 50_000.0); + let interval = setInterval(0, 50_000.0); + let immediate = js_set_immediate_callback(0); + + assert!(!is_immediate_timer_id(timeout), "setTimeout is a Timeout"); + assert!(!is_immediate_timer_id(interval), "setInterval is a Timeout"); + assert!( + is_immediate_timer_id(immediate), + "setImmediate is an Immediate" + ); + + clearTimeout(timeout); + clearInterval(interval); + clearImmediate(immediate); + } +} diff --git a/test-files/test_gap_10541_10542_timer_refresh_ref_immediate_primitive.ts b/test-files/test_gap_10541_10542_timer_refresh_ref_immediate_primitive.ts new file mode 100644 index 0000000000..dcd2934a46 --- /dev/null +++ b/test-files/test_gap_10541_10542_timer_refresh_ref_immediate_primitive.ts @@ -0,0 +1,97 @@ +// #10541 / #10542: `Timeout.refresh()` ref-state semantics, and +// `Immediate` vs `Timeout` numeric conversion. +// +// #10541: `refresh()` reschedules a timer using its original delay, but per +// Node does NOT touch ref/unref state. Perry's `js_timer_refresh` force-set +// the id ref'd (`set_timer_ref_state(id, true)`), so refreshing an unref'd +// timer re-ref'd it: `hasRef()` flipped to `true` and the (BUG-labelled) +// callback that had been deliberately detached from the event loop ran, +// keeping the process alive until it fired. +// +// #10542: Node's `Timeout` (setTimeout/setInterval) has a numeric +// conversion (`+t` is its internal id) but `Immediate` (setImmediate) does +// not (`+im` is `NaN`) -- Perry gave both handles the Timeout conversion. + +process.on("exit", () => console.log("exit")); + +function show(label: string, t: any): void { + const hasRef = typeof t.hasRef === "function" ? t.hasRef() : "missing"; + const ctor = t.constructor ? t.constructor.name : "missing"; + const primitive = +t; + console.log( + `${label}: hasRef=${hasRef} ctor=${ctor} typeof(+t)=${typeof primitive} isNaN(+t)=${Number.isNaN( + primitive, + )}`, + ); +} + +// --- #10541: refresh() must preserve ref state ------------------------------ + +// An unref'd timeout, refreshed: must stay unref'd. If the bug is present +// this callback runs (BUG) and keeps the process alive for ~1.2s. +const unrefTimeout = setTimeout( + () => console.log("BUG: unref'd refresh()'d timeout fired"), + 1200, +); +unrefTimeout.unref(); +show("unref'd timeout before refresh", unrefTimeout); +unrefTimeout.refresh(); +show("unref'd timeout after refresh", unrefTimeout); + +// An unref'd interval, refreshed: must stay unref'd. Cleared immediately +// (synchronously, before the event loop ever runs) so it never fires either +// way -- this only exercises the post-refresh() hasRef() state. +const unrefInterval = setInterval( + () => console.log("BUG: unref'd refresh()'d interval fired"), + 1200, +); +unrefInterval.unref(); +show("unref'd interval before refresh", unrefInterval); +unrefInterval.refresh(); +show("unref'd interval after refresh", unrefInterval); +clearInterval(unrefInterval); + +// A ref'd timeout, refreshed: must stay ref'd, and must still fire (proves +// refresh() itself -- the reschedule -- keeps working). +const refdTimeout = setTimeout( + () => console.log("ref'd refresh()'d timeout fired"), + 50, +); +show("ref'd timeout before refresh", refdTimeout); +refdTimeout.refresh(); +show("ref'd timeout after refresh", refdTimeout); + +// unref() then explicit ref() then refresh(): refresh() must not perturb an +// explicit re-ref either (guards a naive fix that always forces ref state +// to false instead of leaving it alone). +const reRefTimeout = setTimeout( + () => console.log("BUG: reRefTimeout should have been cleared"), + 5000, +); +reRefTimeout.unref(); +reRefTimeout.ref(); +show("reRef timeout after ref()", reRefTimeout); +reRefTimeout.refresh(); +show("reRef timeout after refresh", reRefTimeout); +clearTimeout(reRefTimeout); + +// --- #10542: Immediate has no numeric conversion; Timeout/Interval do ------- + +const immediate = setImmediate(() => + console.log("BUG: immediate should have been cleared"), +); +show("immediate", immediate); +console.log( + "Object.prototype.toString.call(immediate)", + Object.prototype.toString.call(immediate), +); + +const plainTimeout = setTimeout(() => {}, 5000); +show("plain timeout (unfired)", plainTimeout); + +// clearTimeout/clearImmediate must still work on the handles above. +clearTimeout(plainTimeout); +clearImmediate(immediate); +console.log("cleared plainTimeout and immediate"); + +console.log("main done"); From b2c279d041e1c5d2d33829ce9095adf52b2e1f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:12:21 +0000 Subject: [PATCH 08/30] fix(runtime): route AsyncResource super() through the bound-export value, not just the bare-import name class X extends AsyncResource threw "Class constructor AsyncResource cannot be invoked without 'new'" at super() for every heritage shape except a bare import binding. A local alias, a namespace member, and a CJS destructured require() all resolve to the identical bound native export value the canonical import does, but only the bare import shape was recognized statically at HIR-lowering time, so super() fell through to a plain call of the export -- which AsyncResource throws on by design. Recognize the bound export VALUE in js_fetch_or_value_super, exactly as the existing WASI arm does, and run the same native-backing init the canonical path already uses. --- .../src/object/global_this/fetch_globals.rs | 57 +++++++++++++++++-- ...ap_10453_asyncresource_heritage_helper.cjs | 31 ++++++++++ .../test_gap_10453_asyncresource_heritage.ts | 56 ++++++++++++++++++ 3 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 test-files/gap_10453_asyncresource_heritage_helper.cjs create mode 100644 test-files/test_gap_10453_asyncresource_heritage.ts diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 2230073e09..2160572c59 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -655,7 +655,13 @@ pub unsafe extern "C" fn js_fetch_or_value_super( b"Super constructor null is not a constructor", ); } - let wasi_parent = super::super::native_module::bound_native_callable_module_and_method( + // Resolve the parent to a bound native-module export VALUE, independent + // of how the heritage expression reached it: a bare import, a local + // alias, a namespace member, and a CJS destructured `require()` all + // produce the identical bound-closure representation (see + // `bound_native_callable_module_and_method`), even though only the bare + // import shape is recognized statically at HIR-lowering time. + let bound_native_parent = super::super::native_module::bound_native_callable_module_and_method( parent_val, ) .or_else(|| { @@ -665,10 +671,13 @@ pub unsafe extern "C" fn js_fetch_or_value_super( crate::object::class_registry::js_get_dynamic_parent_value(cid), ) }); - if wasi_parent.is_some_and(|(module, method)| { - super::super::native_module::normalize_native_module_alias(&module) == "wasi" - && method == "WASI" - }) { + if bound_native_parent + .as_ref() + .is_some_and(|(module, method)| { + super::super::native_module::normalize_native_module_alias(module.as_str()) == "wasi" + && method.as_str() == "WASI" + }) + { let arg0 = if args_len >= 1 && !args_ptr.is_null() { *args_ptr } else { @@ -677,6 +686,44 @@ pub unsafe extern "C" fn js_fetch_or_value_super( crate::wasi::js_wasi_init_subclass(this_box, arg0); return undef; } + // #10453: `class X extends AsyncResource` threw "Class constructor + // AsyncResource cannot be invoked without 'new'" for every heritage + // shape EXCEPT a bare `import { AsyncResource } from "node:async_hooks"` + // — the only shape `canonical_native_parent_name` recognizes statically + // (`crates/perry-hir/src/lower_decl/class_decl.rs`), which routes to the + // dedicated `js_async_resource_subclass_init` codegen + // (`crates/perry-codegen/src/expr/this_super_call.rs`). A local alias + // (`const Alias = AsyncResource`), a namespace member + // (`ah.AsyncResource`), and a CJS destructured + // `require('node:async_hooks')` all resolve `parent_val` to the exact + // same bound-native-export value the canonical import does, but HIR + // lowering can't see that statically for those shapes, so `super()` fell + // through to the ordinary value-super dispatch below — a plain CALL of + // the bound export, which `AsyncResource` throws on by design when + // invoked without `new` (`nm_dispatch_async_hooks`). Recognize the value + // here instead, exactly as the WASI arm above does, and run the same + // native-backing init the canonical path uses. + if bound_native_parent + .as_ref() + .is_some_and(|(module, method)| { + super::super::native_module::normalize_native_module_alias(module.as_str()) + == "async_hooks" + && method.as_str() == "AsyncResource" + }) + { + let type_value = if args_len >= 1 && !args_ptr.is_null() { + *args_ptr + } else { + undef + }; + let options = if args_len >= 2 && !args_ptr.is_null() { + *args_ptr.add(1) + } else { + undef + }; + crate::async_hooks::js_async_resource_subclass_init(this_box, type_value, options); + return undef; + } // `class X extends Temporal.` (non-spread `super(a, b)`): a Temporal // constructor returns a fresh NaN-boxed cell and does NOT mutate the // implicit `this`, so the ordinary dispatch below would drop that cell and diff --git a/test-files/gap_10453_asyncresource_heritage_helper.cjs b/test-files/gap_10453_asyncresource_heritage_helper.cjs new file mode 100644 index 0000000000..1e28c49d7d --- /dev/null +++ b/test-files/gap_10453_asyncresource_heritage_helper.cjs @@ -0,0 +1,31 @@ +'use strict'; +// CommonJS half of test_gap_10453_asyncresource_heritage.ts: the exact shape +// undici's API handlers use (`lib/api/api-request.js` etc.). +const { AsyncResource } = require('node:async_hooks'); +const asyncHooks = require('node:async_hooks'); + +class Plain extends AsyncResource { + constructor(type) { + super(type); + } +} + +class InTry extends AsyncResource { + constructor(type) { + try { + super(type); + } catch (err) { + throw err; + } + } +} + +// `require('node:async_hooks').AsyncResource` reached via a namespace member +// on a plain `require()` result (not destructured). +class ViaMemberExport extends asyncHooks.AsyncResource { + constructor(type) { + super(type); + } +} + +module.exports = { Plain, InTry, ViaMemberExport }; diff --git a/test-files/test_gap_10453_asyncresource_heritage.ts b/test-files/test_gap_10453_asyncresource_heritage.ts new file mode 100644 index 0000000000..02904833d8 --- /dev/null +++ b/test-files/test_gap_10453_asyncresource_heritage.ts @@ -0,0 +1,56 @@ +// #10453: `class X extends AsyncResource` threw "Class constructor +// AsyncResource cannot be invoked without 'new'" at `super()` for every +// heritage shape EXCEPT a bare `import { AsyncResource } from +// "node:async_hooks"` binding. A local alias (`const Alias = AsyncResource`), +// a namespace member (`ah.AsyncResource`), and a CJS destructured +// `require('node:async_hooks')` all reach the same bound native export +// VALUE the bare import does, but HIR lowering only recognized the bare +// import shape statically, so `super()` for the other shapes fell through to +// a plain CALL of the native export — and `AsyncResource` throws by design +// when invoked without `new`. +// +// `undici` (`lib/api/api-request.js` etc.) uses exactly the CJS destructured +// shape: `const { AsyncResource } = require('node:async_hooks'); class … +// extends AsyncResource`. +import { AsyncResource } from "node:async_hooks"; +import * as ah from "node:async_hooks"; +import { Plain, InTry, ViaMemberExport } from "./gap_10453_asyncresource_heritage_helper.cjs"; + +const Alias = AsyncResource; + +class ViaImport extends AsyncResource { + constructor() { + super("X"); + } +} +class ViaAlias extends Alias { + constructor() { + super("X"); + } +} +class ViaNamespace extends ah.AsyncResource { + constructor() { + super("X"); + } +} +function t(name: string, C: any, ...args: unknown[]) { + try { + const r = new C(...args); + console.log( + name, + "ok", + typeof r.runInAsyncScope, + typeof r.triggerAsyncId(), + r instanceof AsyncResource, + ); + } catch (e: any) { + console.log(name, "threw:", e.message); + } +} + +t("TS extends AsyncResource (import) ", ViaImport, "X"); +t("TS extends Alias ", ViaAlias, "X"); +t("TS extends ah.AsyncResource ", ViaNamespace, "X"); +t("CJS destructured require, super() ", Plain, "X"); +t("CJS destructured require, try{super}", InTry, "X"); +t("CJS namespace member export ", ViaMemberExport, "X"); From a4e9c1859b3ef14ffa637c118e67687a65ef8002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:20:11 +0000 Subject: [PATCH 09/30] docs(changelog): record the AsyncResource heritage-shape fix (#10621) --- .../10621-asyncresource-heritage-shapes.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 changelog.d/10621-asyncresource-heritage-shapes.md diff --git a/changelog.d/10621-asyncresource-heritage-shapes.md b/changelog.d/10621-asyncresource-heritage-shapes.md new file mode 100644 index 0000000000..f103e3aa88 --- /dev/null +++ b/changelog.d/10621-asyncresource-heritage-shapes.md @@ -0,0 +1,24 @@ +### Fixed + +- **`class X extends AsyncResource` threw at `super()` unless the heritage + was a bare `import { AsyncResource } from "node:async_hooks"` binding** + (#10453). A local alias (`const Alias = AsyncResource`), a namespace + member (`ah.AsyncResource`), and a CJS destructured + `require('node:async_hooks')` — the exact shape `undici`'s API handlers + use everywhere (`lib/api/api-request.js` etc.) — all threw `Class + constructor AsyncResource cannot be invoked without 'new'`. Only the bare + import shape was recognized statically at HIR-lowering time + (`canonical_native_parent_name`, `crates/perry-hir/src/lower_decl/class_decl.rs`), + routing to the dedicated `js_async_resource_subclass_init` codegen; every + other shape fell through `js_fetch_or_value_super` + (`crates/perry-runtime/src/object/global_this/fetch_globals.rs`) to a + plain CALL of the bound `async_hooks` export, which throws by design + without `new`. `js_fetch_or_value_super` already resolves ANY heritage + value to its bound native module/method via + `bound_native_callable_module_and_method` for the WASI case, regardless + of how the value was reached — this fix adds the same recognition for + `async_hooks`'s `AsyncResource`, so every aliasing shape now runs the + same native-backing init the canonical import already used. + `AsyncLocalStorage` likely has the same gap but isn't fixed here — its + subclass-init helper lives in `perry-stdlib`, which `perry-runtime` + cannot depend on. From fcdf7e4180eb5cd103914b9a59e4b1c855eea1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:18:06 +0000 Subject: [PATCH 10/30] fix(transform): give an exported dynamic-heritage class factory real per-evaluation identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class expression returned from a function (a mixin/factory — function withCommands(Base) { return class extends Base {}; }) had no per-evaluation identity when the function lived in a non-entry module: every call returned the SAME shared-template class object, re-parented to the most recently passed Base. specialize_captured_class_factories already fixes this for same-module callers by cloning a distinct class per call site, but it only ever sees call sites in the SAME module as the factory -- a caller in another module reaches the factory through an ordinary cross-module call that pass never visits, so an exported factory's own template stayed shared and got silently re-parented on each call. Give an exported factory real per-evaluation identity directly: when its body is nothing but the single-statement return class extends {} shape, upgrade the class's own ClassRef to ClassExprFresh, exactly what the same class expression would already lower to had it needed per-evaluation statics/captures/a private brand. This closes the gap for every caller, local or cross-module, without touching the existing same-module specialization (a locally-cloned call site never calls the factory at runtime at all, so it is unaffected). --- .../src/inline/factory_specialize.rs | 76 +++++++++++++++++ crates/perry-transform/src/inline/mod.rs | 12 ++- ...0455_class_expr_factory_identity_helper.ts | 11 +++ ...t_gap_10455_class_expr_factory_identity.ts | 82 +++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 test-files/gap_10455_class_expr_factory_identity_helper.ts create mode 100644 test-files/test_gap_10455_class_expr_factory_identity.ts diff --git a/crates/perry-transform/src/inline/factory_specialize.rs b/crates/perry-transform/src/inline/factory_specialize.rs index 4eb17a0e16..9c3d8c2574 100644 --- a/crates/perry-transform/src/inline/factory_specialize.rs +++ b/crates/perry-transform/src/inline/factory_specialize.rs @@ -1078,3 +1078,79 @@ pub fn specialize_captured_class_factories(module: &mut Module) { // Flush new specialized classes. module.classes.extend(new_classes); } + +/// #10455: `specialize_captured_class_factories` above only rewrites a +/// factory's CALL SITES, and only visits the module the Call expression +/// appears in — a caller in ANOTHER module reaches the factory through an +/// ordinary cross-module call this pass never sees. An exported factory +/// (`export function withCommands(Base) { return class extends Base {}; }`, +/// redis's `commander.js` `attachConfig` mixin shape) then keeps returning +/// the ONE shared-template `ClassRef` every local call above would +/// otherwise have cloned, and each call's `RegisterClassParentDynamic` +/// silently re-parents that single shared class in place: `withCommands(A) +/// === withCommands(B)` where the spec requires two distinct classes, and +/// an earlier caller's result is corrupted by a later call. +/// +/// Give an exported factory real per-EVALUATION identity directly, instead +/// of relying on caller-side cloning that cannot reach outside the module: +/// when its body is nothing but `return class extends {…}` — lowered +/// to `Sequence([RegisterClassParentDynamic { class_name, parent_expr }, +/// ClassRef(class_name)])` by `crates/perry-hir/src/lower/lower_expr/ +/// arm_class.rs` — upgrade the trailing `ClassRef` to `ClassExprFresh`, +/// exactly what the same class expression would have lowered to had it +/// needed per-evaluation statics/captures/a private brand. +/// `named_statics`/`computed_keys`/`captured_args`/static blocks/private +/// elements/self-binding are all empty here by construction: any of those +/// would already have forced `arm_class.rs` onto the `ClassExprFresh` route +/// at lowering time, so a class that still presents as a bare `ClassRef` +/// never had them. +/// +/// Scoped to this exact single-statement direct-return shape — the filed +/// repro's own pattern. A Let-bound intermediate variable, computed-name +/// evaluations, or Effect's object-literal wrapper shape are left to the +/// same-module handling above; those still work correctly for a caller in +/// the SAME module as the factory (the specialization this file already +/// performs), just not yet for a caller reached only across modules. +pub fn fresh_export_dynamic_heritage_factories(module: &mut Module) { + if module.exported_functions.is_empty() { + return; + } + let exported: HashSet = module + .exported_functions + .iter() + .map(|(_, id)| *id) + .collect(); + for f in &mut module.functions { + if !exported.contains(&f.id) { + continue; + } + let [Stmt::Return(Some(Expr::Sequence(parts)))] = f.body.as_mut_slice() else { + continue; + }; + if parts.len() != 2 { + continue; + } + let is_dynamic_heritage_classref = matches!( + (&parts[0], &parts[1]), + ( + Expr::RegisterClassParentDynamic { class_name: reg, .. }, + Expr::ClassRef(rf), + ) if reg == rf + ); + if !is_dynamic_heritage_classref { + continue; + } + let Expr::ClassRef(template) = parts.remove(1) else { + unreachable!("matched Expr::ClassRef(_) above"); + }; + parts.push(Expr::ClassExprFresh { + template, + evaluation_owner: None, + named_statics: Vec::new(), + computed_keys: Vec::new(), + computed_statics: Vec::new(), + static_init_order: Vec::new(), + captured_args: Vec::new(), + }); + } +} diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 219d36c3d2..11a313a1bb 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -44,7 +44,9 @@ pub(crate) use exact_receivers::{ collect_module_prototype_facts, intersect_exact_receiver_facts, invalidate_exact_receivers_for_expr, kill_referenced_exact_receivers, }; -pub(crate) use factory_specialize::specialize_captured_class_factories; +pub(crate) use factory_specialize::{ + fresh_export_dynamic_heritage_factories, specialize_captured_class_factories, +}; pub(crate) use imul::{detect_math_imul_polyfill, rewrite_imul_calls_in_stmts}; pub(crate) use substitute::{ collect_body_local_ids, substitute_locals, substitute_locals_in_stmts, substitute_this, @@ -497,6 +499,14 @@ fn inline_functions_inner( // no-op for the rewritten sites. specialize_captured_class_factories(module); + // #10455: same-module call sites above are cloned per call site; + // an EXPORTED factory also needs real per-evaluation identity for + // callers outside this module, which no per-module call-site pass + // can see. See `fresh_export_dynamic_heritage_factories`'s own doc + // comment for the exact shape and why it's safe to run after the + // pass above. + fresh_export_dynamic_heritage_factories(module); + // Phases 0 + 1 fused (Tier 4.1, v0.5.335): single iteration over // module.functions collects both Math.imul polyfill ids AND // inlinable-function candidates. Pre-Tier-4 these were two separate diff --git a/test-files/gap_10455_class_expr_factory_identity_helper.ts b/test-files/gap_10455_class_expr_factory_identity_helper.ts new file mode 100644 index 0000000000..38d01cd9f9 --- /dev/null +++ b/test-files/gap_10455_class_expr_factory_identity_helper.ts @@ -0,0 +1,11 @@ +// Non-entry module half of test_gap_10455_class_expr_factory_identity.ts — +// the exact shape redis's `commander.js` `attachConfig` uses +// (`Class = class extends BaseClass {}`), reached through an ordinary +// cross-module call. +export function withCommands(Base: any) { + return class extends Base {}; +} + +export function withCommandsB(Base: any) { + return class extends Base {}; +} diff --git a/test-files/test_gap_10455_class_expr_factory_identity.ts b/test-files/test_gap_10455_class_expr_factory_identity.ts new file mode 100644 index 0000000000..f34bb45184 --- /dev/null +++ b/test-files/test_gap_10455_class_expr_factory_identity.ts @@ -0,0 +1,82 @@ +// #10455: a class expression returned from a function had no per-evaluation +// identity when the function lived in a NON-ENTRY module — every call +// returned the SAME class object, re-parented to the most recently passed +// `Base`. A mixin/factory declared and called in the entry module (or +// specialized per call site by `specialize_captured_class_factories`) always +// worked; the same shape reached through an ordinary cross-module call did +// not, because the per-module specialization pass never sees callers outside +// its own module. redis's `commander.js` `attachConfig` (`Class = class +// extends BaseClass {}`) hits exactly this from `RedisClient.factory` and +// `Client.prototype.Multi = MultiCommand.extend(config)`. +import { withCommands, withCommandsB } from "./gap_10455_class_expr_factory_identity_helper.ts"; + +class A { + constructor() { + console.log(" A constructor"); + } + hello() { + return "A.hello"; + } +} +class B { + constructor() { + console.log(" B constructor"); + } + world() { + return "B.world"; + } +} + +function localWithCommands(Base: any) { + return class extends Base {}; +} + +// ── entry-module factory: two calls, two evaluations ── +const LocalA = localWithCommands(A); +const LocalB = localWithCommands(B); +const la = new LocalA(); +console.log( + "entry module: LocalA !== LocalB:", + LocalA !== LocalB, + "| instanceof A:", + la instanceof A, + "| typeof hello:", + typeof (la as any).hello, +); + +// ── non-entry-module factory: two calls, two evaluations ── +const ClientA = withCommands(A); +const ClientB = withCommands(B); +const ca = new ClientA(); +const cb = new ClientB(); +console.log( + "non-entry module: ClientA !== ClientB:", + ClientA !== ClientB, + "| instanceof A:", + ca instanceof A, + "| typeof hello:", + typeof (ca as any).hello, +); +console.log( + "non-entry module: cb instanceof B:", + cb instanceof B, + "| cb instanceof A:", + cb instanceof A, + "| typeof world:", + typeof (cb as any).world, +); +// ── three sequential calls at the SAME call site (loop) also stay distinct ── +const made: any[] = []; +for (let i = 0; i < 3; i++) { + made.push(withCommandsB(i % 2 === 0 ? A : B)); +} +console.log( + "loop calls pairwise distinct:", + made[0] !== made[1] && made[1] !== made[2] && made[0] !== made[2], +); +console.log( + "loop instances match their own parent:", + new made[0]() instanceof A, + new made[1]() instanceof B, + new made[2]() instanceof A, +); From 3d8ebe093efaeb11c896c3cc082ca91617f2eb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:20:12 +0000 Subject: [PATCH 11/30] docs(changelog): record the exported class-factory identity fix (#10622) --- .../10622-exported-class-factory-identity.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 changelog.d/10622-exported-class-factory-identity.md diff --git a/changelog.d/10622-exported-class-factory-identity.md b/changelog.d/10622-exported-class-factory-identity.md new file mode 100644 index 0000000000..19a45f1078 --- /dev/null +++ b/changelog.d/10622-exported-class-factory-identity.md @@ -0,0 +1,26 @@ +### Fixed + +- **A class expression returned from a factory function had no + per-evaluation identity when the function lived in a non-entry module** + (#10455). `function withCommands(Base) { return class extends Base {}; }` + called twice with different `Base`s returned the SAME class object, + re-parented to the most recently passed `Base` — declared and called in + the entry module, the identical shape already worked because + `specialize_captured_class_factories` (`crates/perry-transform/src/inline/factory_specialize.rs`) + clones a distinct class per call site, but that pass only ever sees call + sites in the SAME module the factory Call expression appears in; a caller + in another module reaches the factory through an ordinary cross-module + call the pass never visits. redis's `commander.js` `attachConfig` (`Class + = class extends BaseClass {}`) hits this every time it's called with a + second `BaseClass`, from `RedisClient.factory` and + `Client.prototype.Multi`, and `new Client(options)` ran the wrong parent + constructor. New pass `fresh_export_dynamic_heritage_factories`: for an + **exported** factory whose body is exactly `return class extends + {};` (no other members), upgrades the class's shared-template `ClassRef` + to a fresh-per-evaluation `ClassExprFresh` object directly — exactly what + the same class expression would already lower to had it needed + per-evaluation statics/captures/a private brand — so every caller, local + or cross-module, gets a genuinely distinct class per call. Scoped to + exported functions and this single-statement shape only; a `Let`-bound + intermediate variable or Effect's object-literal wrapper factory shape + still rely on same-module specialization alone. From 6b8ed84af517e50ce036cc9c291dd2aed2646cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:26:47 +0000 Subject: [PATCH 12/30] fix(hir): exclude inherited method names from ctor-body field detection A subclass constructor assigning this. where is a method inherited from a parent class allocated an own inline field slot for it, hiding the inherited method from the moment super() returned. Track own+inherited instance method names per class (mirroring the existing accessor-name tracking) and consult the union when deciding whether a constructor-body this. = ... assignment is a new data field or a method override. --- crates/perry-hir/src/lower/context.rs | 8 +- .../perry-hir/src/lower/lowering_context.rs | 28 +++++ crates/perry-hir/src/lower/tests.rs | 1 + .../tests/subclass_ctor_inherited_method.rs | 101 ++++++++++++++++++ crates/perry-hir/src/lower_decl/class_decl.rs | 20 ++++ ...87_subclass_ctor_hides_inherited_method.ts | 97 +++++++++++++++++ 6 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 crates/perry-hir/src/lower/tests/subclass_ctor_inherited_method.rs create mode 100644 test-files/test_gap_10487_subclass_ctor_hides_inherited_method.ts diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 6d1da1665b..53f7cadf01 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -88,6 +88,7 @@ impl LoweringContext { class_statics: Vec::new(), class_field_names: HashMap::new(), class_accessor_names: HashMap::new(), + class_method_names: HashMap::new(), class_native_extends: Vec::new(), class_field_types: HashMap::new(), enums: Vec::new(), @@ -591,10 +592,9 @@ impl LoweringContext { self.class_accessor_names.insert(class_name, accessor_names); } - /// Look up the accessor property names registered for a - /// class. The stored list includes inherited accessors (mirroring how - /// `class_field_names` stores the own+inherited union), so callers do - /// not need to walk the parent chain themselves. + /// Look up the accessor property names for a class. Includes inherited + /// accessors (mirroring `class_field_names`'s own+inherited union), so + /// callers do not need to walk the parent chain themselves. pub(crate) fn lookup_class_accessor_names( &self, class_name: &str, diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index aac869aceb..1eddfaed43 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -201,6 +201,18 @@ pub struct LoweringContext { /// `lookup_class_accessor_names` and walked across the parent chain when /// processing a subclass's ctor body. pub(crate) class_accessor_names: HashMap, + /// Issue #10487: own+inherited instance METHOD names per class (mirrors + /// `class_accessor_names`). Used by the "infer fields from ctor body + /// `this.x = ...`" pass to avoid mis-categorising an assignment that + /// overrides an INHERITED method (`this.close = () => …` where `close` + /// is declared on a parent class) as a new own data field — that + /// allocated an inline slot shadowing the inherited method from the + /// moment `super()` returns, so `this.close` read `undefined` until the + /// assignment ran (undici MockPool/MockClient's `this.close.bind(this)` + /// threw "Bind must be called on a function" for the same reason). + /// Own-class methods were already excluded (#665-adjacent zod fix); + /// this extends the exclusion across the `extends` chain. + pub(crate) class_method_names: HashMap>, /// Issue #562: class name → `(module, class)` tuple from /// `native_extends`. Populated when lowering each class, consumed by /// `destructuring.rs` to register `let x = new SubclassOfStream()` @@ -1176,3 +1188,19 @@ pub struct LoweringContext { /// bodies and module/script top-level both leave this false. pub(crate) in_nonarrow_fn: bool, } + +// Issue #10487: own+inherited instance method names per class (mirrors +// `class_accessor_names`'s register/lookup pair in context.rs). Split into +// its own `impl` block here rather than in context.rs, which sits at the +// file-size cap. +impl LoweringContext { + pub(crate) fn register_class_method_names(&mut self, class_name: String, names: Vec) { + self.class_method_names.insert(class_name, names); + } + + pub(crate) fn lookup_class_method_names(&self, class_name: &str) -> Option<&[String]> { + self.class_method_names + .get(class_name) + .map(|n| n.as_slice()) + } +} diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 54aa8f471c..4ecc0b1f87 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1995,4 +1995,5 @@ mod mixin_parent_chain; mod native_module_sync; mod nullish_over_optional_chain; +mod subclass_ctor_inherited_method; mod ui_widget_add_child; diff --git a/crates/perry-hir/src/lower/tests/subclass_ctor_inherited_method.rs b/crates/perry-hir/src/lower/tests/subclass_ctor_inherited_method.rs new file mode 100644 index 0000000000..24f337b608 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/subclass_ctor_inherited_method.rs @@ -0,0 +1,101 @@ +//! #10487: a subclass constructor assignment `this.m = …` must NOT become an +//! own inline field slot when `m` is a method INHERITED from a parent class +//! — that shadowed the inherited method with an own `undefined` field from +//! the moment `super()` returned, until the assignment statement ran. Split +//! from `tests.rs` for the 2000-line file cap. + +/// `this.close = …` in a subclass constructor, where `close` is a method +/// declared only on the parent, must not allocate an own `close` field. +#[test] +fn subclass_ctor_assignment_to_inherited_method_name_is_not_a_field() { + let source = r#" + class Base { + close() { return "closed"; } + } + class Sub extends Base { + constructor() { + super(); + this.seen = typeof this.close; + this.close = () => "replaced"; + } + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let sub = hir + .classes + .iter() + .find(|c| c.name == "Sub") + .expect("fixture declares class Sub"); + assert!( + !sub.fields.iter().any(|f| f.name == "close"), + "this.close = … must not become an own field on Sub when `close` is \ + inherited from Base; fields: {:?}", + sub.fields.iter().map(|f| &f.name).collect::>() + ); + assert!( + sub.fields.iter().any(|f| f.name == "seen"), + "this.seen = … has no parent-declared counterpart and must still \ + become an own field; fields: {:?}", + sub.fields.iter().map(|f| &f.name).collect::>() + ); +} + +/// Same requirement across TWO levels of inheritance (the method is +/// declared on a grandparent, not the immediate parent). +#[test] +fn grandparent_method_name_is_excluded_across_two_levels() { + let source = r#" + class Base { + close() { return "closed"; } + } + class Mid extends Base {} + class Grand extends Mid { + constructor() { + super(); + this.close = () => "replaced"; + } + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let grand = hir + .classes + .iter() + .find(|c| c.name == "Grand") + .expect("fixture declares class Grand"); + assert!( + !grand.fields.iter().any(|f| f.name == "close"), + "this.close = … must not become an own field on Grand when `close` \ + is inherited from Base via Mid; fields: {:?}", + grand.fields.iter().map(|f| &f.name).collect::>() + ); +} + +/// Control: a class's OWN method being self-bound in its OWN constructor +/// (the pre-existing #665-adjacent zod fix) must keep working — this +/// exclusion is orthogonal to the inherited-method one added here. +#[test] +fn own_class_method_self_assignment_is_still_not_a_field() { + let source = r#" + class Own { + close() { return "closed"; } + constructor() { + this.close = this.close.bind(this); + } + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let own = hir + .classes + .iter() + .find(|c| c.name == "Own") + .expect("fixture declares class Own"); + assert!( + !own.fields.iter().any(|f| f.name == "close"), + "an own-method self-assignment must not become a field either; \ + fields: {:?}", + own.fields.iter().map(|f| &f.name).collect::>() + ); +} diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 551137f1b2..c539100966 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -974,6 +974,20 @@ pub fn lower_class_decl( _ => {} } } + // Issue #10487: pull in the parent chain's own+inherited method + // names too, mirroring the accessor union just above. A subclass + // constructor's `this.close = …` overriding a PARENT method (not + // redeclared on this class) must be recognized as a method + // override, not a new own data field, or the field wins the + // dynamic-dispatch lookup and instance reads see `undefined` + // until the assignment statement runs. + if let Some(ref parent_name) = extends_name { + if let Some(parent_methods) = ctx.lookup_class_method_names(parent_name) { + for m in parent_methods { + method_names.insert(m.clone()); + } + } + } let declared_field_names: std::collections::HashSet = fields.iter().map(|f| f.name.clone()).collect(); @@ -1066,6 +1080,12 @@ pub fn lower_class_decl( // from the parent-chain lookup above. ctx.register_class_accessor_names(name.clone(), accessor_names); + // Issue #10487: register this class's complete (own + inherited) + // method-name set, mirroring the accessor registration just above, + // so a further subclass lowered after this one sees the full + // chain in one lookup. + ctx.register_class_method_names(name.clone(), method_names.into_iter().collect()); + // Issue #302: also register field TYPES so the for-of arm can // detect `for (... of this.someMap)` patterns. Only own fields are // registered here; inherited field types fall through to whichever diff --git a/test-files/test_gap_10487_subclass_ctor_hides_inherited_method.ts b/test-files/test_gap_10487_subclass_ctor_hides_inherited_method.ts new file mode 100644 index 0000000000..352f585307 --- /dev/null +++ b/test-files/test_gap_10487_subclass_ctor_hides_inherited_method.ts @@ -0,0 +1,97 @@ +// #10487: a subclass constructor that assigns `this.m = ...` where `m` is a +// method inherited from a PARENT class hid the inherited method from the +// moment `super()` returned, instead of only once the assignment ran. +// +// `lower_class_decl` (crates/perry-hir/src/lower_decl/class_decl.rs) scans +// each constructor's `this. = ...` assignments to decide whether +// `` needs a synthesized inline field slot, excluding names that are +// declared fields, inherited fields, or accessors — and the class's OWN +// methods (the #665-adjacent zod `this.parse.bind(this)` fix: an own-method +// override must not get a shadowing data slot). It did not exclude METHODS +// INHERITED FROM THE EXTENDS CHAIN, so `this.close = ...` in a subclass +// constructor (where `close` is declared only on the parent) allocated an +// own `close` field. That field exists (as `undefined`) as soon as `super()` +// returns, so it shadows the inherited method in every by-name lookup until +// the assignment statement actually runs. + +class Base { + close() { + return "closed"; + } +} +class Sub extends Base { + seen: string; + constructor() { + super(); + this.seen = typeof this.close; // inherited method, read BEFORE the own assignment below + this.close = () => "replaced"; + } +} +class Own { + seen: string; + close() { + return "closed"; + } + constructor() { + this.seen = typeof this.close; + this.close = () => "replaced"; + } +} +class Sub2 extends Base { + seen: string; + constructor() { + super(); + this.seen = typeof this.close; // control: no own assignment to `close` + } +} +class Sub3 extends Base { + original: any; + constructor() { + super(); + this.original = this.close.bind(this); // undici MockPool / MockClient shape + this.close = () => "replaced"; + } +} +// Grandparent-distance: the method is declared two levels up. +class Mid extends Base {} +class Grand extends Mid { + seen: string; + constructor() { + super(); + this.seen = typeof this.close; + this.close = () => "replaced"; + } +} +// Alias read: `const self = this; self.close` must see the same result. +class SubAlias extends Base { + seen: string; + constructor() { + super(); + const self = this; + this.seen = typeof self.close; + this.close = () => "replaced"; + } +} +// Assignment made in a regular method (not the constructor): documented as +// already working, must keep working. +class SubMethodAssign extends Base { + seen: string = ""; + replace() { + this.seen = typeof this.close; + this.close = () => "replaced"; + } +} + +console.log("Sub ", new Sub().seen); +console.log("Own ", new Own().seen); +console.log("Sub2 ", new Sub2().seen); +console.log("Grand ", new Grand().seen); +console.log("SubAlias ", new SubAlias().seen); +const sma = new SubMethodAssign(); +sma.replace(); +console.log("SubMethod", sma.seen); +try { + console.log("Sub3", new Sub3().original()); +} catch (e: any) { + console.log("Sub3 threw", e.constructor.name, e.message); +} From 63796567d9cbd4092d660464279b63c03ad4f338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:28:31 +0000 Subject: [PATCH 13/30] changelog: fragment for #10626 --- .../10626-subclass-ctor-inherited-method.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 changelog.d/10626-subclass-ctor-inherited-method.md diff --git a/changelog.d/10626-subclass-ctor-inherited-method.md b/changelog.d/10626-subclass-ctor-inherited-method.md new file mode 100644 index 0000000000..c51428a442 --- /dev/null +++ b/changelog.d/10626-subclass-ctor-inherited-method.md @@ -0,0 +1,17 @@ +### Fixed + +- **A subclass constructor assigning `this.m = ...` over an inherited method + hid that method from the moment `super()` returned, not just before the + assignment ran.** The constructor-body field-detection pass excluded a + class's OWN methods from being turned into shadow data slots (so + `this.parse = this.parse.bind(this)` self-binding kept working), but never + excluded methods inherited from the `extends` chain — so + `this.close = () => ...` in a subclass constructor, where `close` is + declared only on a parent class, allocated an own `close` field that + existed (as `undefined`) as soon as `super()` returned and shadowed the + inherited method in every by-name lookup until the assignment statement + executed. Instance method names are now tracked as an own+inherited union + per class (mirroring the existing accessor-name tracking) and consulted the + same way. This was blocking `undici`'s `MockPool`/`MockClient` + (`this[kOriginalClose] = this.close.bind(this)` over `DispatcherBase`'s + `close`), which threw `TypeError: Bind must be called on a function`. From ce86fa9a3f7ed827109a94f0276d1f83f1901cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:26:54 +0000 Subject: [PATCH 14/30] fix(codegen): refresh local_types on var redeclaration A hoisted var reaches lower_let as two Stmt::Lets sharing one local id (a body-entry predefine, then the real declaration); the second takes the #1803 redeclaration early return before ctx.local_types is updated. proven_local_types (consulted by is_numeric_expr) IS refreshed on redeclaration, but local_types (consulted by expr_may_return_boxed_value_from_raw_f64_fallback) was not, so the two predicates disagreed about the same local: a strict-equality compare against an out-of-bounds/hole read of a var-declared number array took the bare-fcmp numeric fast path, which cannot represent the NaN-boxed undefined tag such a read can produce. Refresh local_types on the redeclaration path too. --- crates/perry-codegen/src/stmt/let_stmt.rs | 16 +++ .../src/stmt/let_stmt_var_redeclare_tests.rs | 119 ++++++++++++++++++ crates/perry-codegen/src/stmt/mod.rs | 2 + .../test_gap_10488_var_array_void_compare.ts | 101 +++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 crates/perry-codegen/src/stmt/let_stmt_var_redeclare_tests.rs create mode 100644 test-files/test_gap_10488_var_array_void_compare.ts diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index f360f0a353..c2d71f73ac 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -383,6 +383,22 @@ pub(crate) fn lower_let( ctx, &perry_hir::Expr::LocalSet(id, Box::new(init_expr.clone())), )?; + // #10488: a hoisted `var`'s real declaration reaches this + // redeclaration branch (#1803 predefine-then-declare shape) and + // returns below before the fresh-declaration path's + // `ctx.local_types.insert` ever runs. `proven_local_types` a few + // lines up IS refreshed per-site, so `is_numeric_expr` (which + // consults it via `stable_local_type_proof`) sees this + // declaration's more specific type — but `local_types` keeps + // whatever the FIRST (predefine) site declared, normally `Any`. + // That desyncs `is_numeric_expr` from `static_type_of` / + // `expr_may_return_boxed_value_from_raw_f64_fallback`, which both + // read `local_types`: a strict-equality compare against an + // out-of-bounds/hole array read was treated as definitely-numeric + // (a bare `fcmp`, which cannot represent `undefined`) instead of + // falling back to a boxed compare. Refresh `local_types` here too + // so both predicates agree on this local's current type. + ctx.local_types.insert(id, refined_ty.clone()); } else if ctx.tdz_boxes.remove(&id) { // No-init reuse (`let x;`) of a TDZ-seeded box must still end the // dead zone by clearing the sentinel to `undefined`; otherwise a diff --git a/crates/perry-codegen/src/stmt/let_stmt_var_redeclare_tests.rs b/crates/perry-codegen/src/stmt/let_stmt_var_redeclare_tests.rs new file mode 100644 index 0000000000..20a6e56d33 --- /dev/null +++ b/crates/perry-codegen/src/stmt/let_stmt_var_redeclare_tests.rs @@ -0,0 +1,119 @@ +//! #10488: a hoisted `var` reaches `lower_let` as TWO `Stmt::Let`s sharing one +//! local id — a body-entry predefine (`Any = undefined`), then the real +//! declaration (here, `Array(Number) = [0]`) — and the second one takes the +//! #1803 redeclaration early return. `ctx.local_types` must be refreshed on +//! that path too, or `expr_may_return_boxed_value_from_raw_f64_fallback` +//! (which reads it via `static_type_of`) stays desynced from +//! `is_numeric_expr` (which reads the separately-refreshed +//! `proven_local_types`), and a strict-equality compare against an +//! out-of-bounds array read wrongly takes the bare-`fcmp` numeric fast path. + +use perry_hir::types::Type; +use perry_hir::{CompareOp, Expr, Stmt}; + +use crate::temp_root_coverage::main_ir_for as ir_for; + +const ARR: u32 = 1; +const R: u32 = 2; + +/// A CALL to the boxed comparison helper, not the unconditional `declare` +/// line — mirrors `compare_tests.rs`'s `JS_EQ_CALL`. +const JS_EQ_CALL: &str = "call i64 @js_eq("; + +/// Hand-build the exact HIR shape a hoisted `var arr = [0]; ...; arr[1] === +/// void 0;` lowers to: TWO `Let`s sharing id `ARR` (the predefine, then the +/// real array-literal declaration), followed by a strict-equality compare of +/// an out-of-bounds index read against `Expr::Void`. +#[test] +fn var_redeclared_numeric_array_compare_against_void_stays_boxed() { + let ir = ir_for( + "var_redeclare_void_compare", + vec![ + // Body-entry predefine: `var arr;` before the real declaration + // runs — declared `Any`, matching what hoisting emits. + Stmt::Let { + id: ARR, + name: "arr".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }, + // The real declaration: same id, now a proven `Array(Number)`. + // This is the REDECLARATION path in `lower_let` (#1803). + Stmt::Let { + id: ARR, + name: "arr".to_string(), + ty: Type::Array(Box::new(Type::Number)), + mutable: true, + init: Some(Expr::Array(vec![Expr::Integer(0)])), + }, + Stmt::Let { + id: R, + name: "r".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ARR)), + index: Box::new(Expr::Integer(1)), + }), + right: Box::new(Expr::Void(Box::new(Expr::Integer(0)))), + }), + }, + ], + ); + assert!( + ir.contains(JS_EQ_CALL), + "an out-of-bounds read of a var-redeclared Array(Number) compared \ + against `void 0` must fall back to the boxed js_eq helper (the \ + element read can be undefined, which the STATIC numeric fast path \ + can't represent); got IR:\n{ir}" + ); + // NOT a blanket "no fcmp anywhere" check: `js_eq`'s own dynamic + // comparison lowering has an internal, RUNTIME-guarded fast path (an + // `icmp` range check on the raw bits proves both operands are genuine + // untagged doubles before it dares an `fcmp`) that is safe and expected + // to appear here too — that is a property of the boxed path, not the + // STATIC always-numeric bug this test guards against. The call to + // `js_eq` above is what proves this comparison did NOT take the static + // fast path (`lower_strict_eq_against_number`, which emits an + // UNGUARDED `fcmp` with no dynamic dispatch at all). +} + +/// Control: the SAME shape through a `let` (no redeclaration ambiguity) +/// already took the boxed path before this fix and must keep doing so. +#[test] +fn let_numeric_array_compare_against_void_stays_boxed() { + let ir = ir_for( + "let_void_compare", + vec![ + Stmt::Let { + id: ARR, + name: "arr".to_string(), + ty: Type::Array(Box::new(Type::Number)), + mutable: false, + init: Some(Expr::Array(vec![Expr::Integer(0)])), + }, + Stmt::Let { + id: R, + name: "r".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ARR)), + index: Box::new(Expr::Integer(1)), + }), + right: Box::new(Expr::Void(Box::new(Expr::Integer(0)))), + }), + }, + ], + ); + assert!( + ir.contains(JS_EQ_CALL), + "control case (let, no redeclaration) must already take the boxed \ + path; got IR:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 4846931d95..768cbea9f5 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -33,6 +33,8 @@ mod let_buffer_views; mod let_object_facts; mod let_stmt; mod let_stmt_facts; +#[cfg(test)] +mod let_stmt_var_redeclare_tests; mod loops; mod masked_window_region; #[cfg(test)] diff --git a/test-files/test_gap_10488_var_array_void_compare.ts b/test-files/test_gap_10488_var_array_void_compare.ts new file mode 100644 index 0000000000..e5d62a7175 --- /dev/null +++ b/test-files/test_gap_10488_var_array_void_compare.ts @@ -0,0 +1,101 @@ +// #10488: `arr[i] === void 0` (and other undefined-valued comparisons) +// against an out-of-bounds/hole read of a `var`-declared number-literal +// array always compiled `false` (and `!==` always `true`). +// +// A hoisted `var` lowers to TWO HIR `Let`s sharing one LocalId: a body-entry +// predefine (`Any = undefined`) and the real declaration (`Array(Number) = +// [0]`, say). `lower_let` (crates/perry-codegen/src/stmt/let_stmt.rs) +// refreshes `ctx.proven_local_types` on every `Let`, but a redeclaration +// (the second `Let`, since the predefine already allocated the slot) took +// an early return that never touched `ctx.local_types`. That left +// `is_numeric_expr` (which consults `proven_local_types`) and +// `expr_may_return_boxed_value_from_raw_f64_fallback`/`static_type_of` +// (which read the now-stale `local_types`, still `Any`) disagreeing about +// the very same local: the fallback-hazard guard never fired, so a +// strict-equality compare against the array element compiled to a bare +// `fcmp` — which can't represent the NaN-boxed `undefined` tag a hole or +// out-of-bounds read actually produces. + +function varVoid(): boolean { + var arr = [0]; + return arr[1] === void 0; +} +function varNe(): boolean { + var arr = [0]; + return arr[1] !== void 0; +} +function varUndefVar(): boolean { + var arr = [0]; + var u; + return arr[1] === u; +} +function varTwoReads(): boolean { + var arr = [0]; + return arr[1] === arr[2]; +} +function varHole(): boolean { + var arr = [0, 1]; + arr.length = 5; + return arr[3] === void 0; +} +function varNegIdx(): boolean { + var arr = [0]; + return arr[-1] === void 0; +} +function varPropCompare(): boolean { + var arr = [0]; + var o: any = {}; + return arr[1] === o.v; +} +function varParamIdx(i: number): boolean { + var arr = [0]; + return arr[i] === void 0; +} +// Controls: already correct in Perry before this fix; must stay correct. +function varUndefined(): boolean { + var arr = [0]; + return arr[1] === undefined; +} +function letVoid(): boolean { + let arr = [0]; + return arr[1] === void 0; +} +function varInBoundsVoid(): boolean { + var arr = [0]; + return arr[0] === void 0; +} + +var NUMERALS = "0123456789abcdef"; +// decimal.js convertBase('255', 10, 16) (decimal.mjs:2608), verbatim. +function convertBase(str: string, baseIn: number, baseOut: number) { + var j, arr = [0], arrL, i = 0, strL = str.length; + for (; i < strL; ) { + for (arrL = arr.length; arrL--; ) arr[arrL] *= baseIn; + arr[0] += NUMERALS.indexOf(str.charAt(i++)); + for (j = 0; j < arr.length; j++) { + if (arr[j] > baseOut - 1) { + if (arr[j + 1] === void 0) arr[j + 1] = 0; + arr[j + 1] += (arr[j] / baseOut) | 0; + arr[j] %= baseOut; + } + } + } + return arr.reverse(); +} + +for (const f of [ + varVoid, + varNe, + varUndefVar, + varTwoReads, + varHole, + varNegIdx, + varPropCompare, + varUndefined, + letVoid, + varInBoundsVoid, +]) { + console.log(f.name, f()); +} +console.log("varParamIdx", varParamIdx(1)); +console.log("convertBase", JSON.stringify(convertBase("255", 10, 16))); From c8304f2734f101338cf610d7ab27b7a86f0830ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:28:25 +0000 Subject: [PATCH 15/30] changelog: fragment for #10627 --- changelog.d/10627-var-array-void-compare.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 changelog.d/10627-var-array-void-compare.md diff --git a/changelog.d/10627-var-array-void-compare.md b/changelog.d/10627-var-array-void-compare.md new file mode 100644 index 0000000000..d1b1cae40c --- /dev/null +++ b/changelog.d/10627-var-array-void-compare.md @@ -0,0 +1,15 @@ +### Fixed + +- **`arr[i] === void 0` (and other undefined-valued comparisons) against an + out-of-bounds or hole read of a `var`-declared number array always + compiled `false`, and `!==` always `true`.** A hoisted `var` lowers to two + HIR declarations sharing one local id (a body-entry predefine, then the + real declaration); the codegen redeclaration path refreshed the numeric + type PROOF used by `is_numeric_expr` but left the declared-type map used + by the boxed-fallback hazard guard stale at `Any`. The two disagreed about + the same local, so the hazard guard never caught the case and a strict + equality compare against the array element compiled to a bare `fcmp` — + which cannot represent the NaN-boxed `undefined` tag a hole/out-of-bounds + read actually produces. Both are now kept in sync on every `var` + redeclaration. This was blocking `decimal.js`'s `toHexadecimal`/ + `toBinary`/`toOctal` (`convertBase`'s carry-slot initialization check). From 1eab1f4e963b5195883c11319f56aab553cbc02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:29:23 +0000 Subject: [PATCH 16/30] fix(hir): forward inherited captures through a locally-shadowed class parent A subclass with no explicit constructor, extending a capture-bearing class expression held in a local (const Base = class {...}; const Sub = class extends Base {...};), never forwarded Base's captured enclosing-scope locals to the synthesized subclass constructor: the lowering deliberately drops the static extends_name for such a lexically-local heritage identifier (avoiding a same-named-class collision, #5437), and capture propagation was keyed off that same name. Resolve the heritage identifier through resolve_class_alias instead - the same table Expr::New's own capture lookup already uses for let X = class {...}; new X() - for capture forwarding only, gated to subclasses with no own constructor (an explicit constructor already forwards captures correctly via a separate mechanism). --- crates/perry-hir/src/lower/tests.rs | 1 + .../tests/class_expr_subclass_captures.rs | 80 ++++++++++++++++ crates/perry-hir/src/lower_decl/class_decl.rs | 86 ++++++++++++++++- ..._gap_10486_class_expr_subclass_captures.ts | 93 +++++++++++++++++++ 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 crates/perry-hir/src/lower/tests/class_expr_subclass_captures.rs create mode 100644 test-files/test_gap_10486_class_expr_subclass_captures.ts diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 4ecc0b1f87..bc4c1f498f 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1994,6 +1994,7 @@ mod function_ctor_runtime_routing; mod mixin_parent_chain; mod native_module_sync; +mod class_expr_subclass_captures; mod nullish_over_optional_chain; mod subclass_ctor_inherited_method; mod ui_widget_add_child; diff --git a/crates/perry-hir/src/lower/tests/class_expr_subclass_captures.rs b/crates/perry-hir/src/lower/tests/class_expr_subclass_captures.rs new file mode 100644 index 0000000000..c3d1b37835 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/class_expr_subclass_captures.rs @@ -0,0 +1,80 @@ +//! #10486: a class extending a capture-bearing class EXPRESSION held in a +//! local (`const Base = class { m() { return cap; } }; class Sub extends +//! Base {}`) must forward the base's captured locals to the synthesized +//! subclass constructor, even though `extends_name` is deliberately left +//! `None` for this lexically-local heritage shape (see the #5437 PQueue +//! fix). Split from `tests.rs` for the 2000-line file cap. + +/// The minimal repro from #10486: a subclass EXPRESSION with no own +/// constructor extending a base class EXPRESSION, both capturing distinct +/// enclosing-function locals. The `new Sub()` construction site must +/// forward BOTH captured ids (the base's and the subclass's own), not just +/// the subclass's own capture. +#[test] +fn subclass_of_local_class_expr_forwards_base_captures() { + let source = r#" + function outer() { + const baseCap = "base-capture"; + const subCap = "sub-capture"; + const Base = class { m() { return baseCap; } }; + const Sub = class extends Base { n() { return subCap; } }; + return new Sub().m(); + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let outer = hir + .functions + .iter() + .find(|f| f.name == "outer") + .expect("fixture declares function outer"); + let compact: String = format!("{:?}", outer.body) + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect(); + // The `new Sub()` site must append TWO forwarded capture args (the + // subclass's own `subCap` plus the inherited `baseCap`), not one. + assert!( + compact.contains("cap_args_appended:2"), + "expected new Sub() to forward both the base and subclass captures \ + (cap_args_appended: 2); got body: {compact}" + ); +} + +// NOTE: a subclass DECLARATION (as opposed to expression) extending a local +// class expression is a separate lowering path (`Expr::NewDynamic` with a +// `RegisterClassCaptures`/`RefreshClassExprCaptures`-based shared-box +// capture mechanism, not `Expr::New{cap_args_appended}`) that this fix does +// not cover — left as a known gap, see the PR body for #10486. + +/// A subclass that captures nothing of its own, extending a capture-bearing +/// local class expression, must still forward the base's capture (a +/// regression the naive "skip if the child's own union is empty" shape +/// would have reintroduced). +#[test] +fn subclass_with_no_own_captures_still_forwards_base_captures() { + let source = r#" + function outer() { + const cap = "only-base-cap"; + const Base = class { m() { return cap; } }; + const Sub = class extends Base {}; + return new Sub().m(); + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let outer = hir + .functions + .iter() + .find(|f| f.name == "outer") + .expect("fixture declares function outer"); + let compact: String = format!("{:?}", outer.body) + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect(); + assert!( + compact.contains("cap_args_appended:1"), + "expected new Sub() to forward the base's capture even though Sub \ + itself captures nothing; got body: {compact}" + ); +} diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index c539100966..862d5abdbb 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -414,6 +414,61 @@ pub fn lower_class_decl( (None, None, None, None) }; + // Issue #10486: the branches above deliberately leave `extends_name` + // None when the heritage identifier resolves to a lexically-scoped + // local (`locally_shadowed`) or a fully dynamic expression, to avoid + // corrupting the static class-registry walks (instanceof / method + // dispatch / field layout — see the `locally_shadowed` comment above, + // #5437's PQueue regression). Capture forwarding is narrower and + // already tolerates a wrong/missing match (`lookup_class_captures` + // returns `None` and `synthesize_class_captures` is then a no-op for + // that source), so resolve the heritage identifier through + // `resolve_class_alias` — the SAME table `Expr::New`'s own capture + // lookup already uses (`expr_new.rs`) for `let X = class {...}; new + // X()`, populated when `const X = class {...}`/`let X = Y` is lowered + // (`register_let_class_alias`) — rather than a raw name match: a class + // extending a capture-bearing class EXPRESSION held in a local + // (`const Base = class { m() { return cap; } }; class Sub extends + // Base {}`) still finds and forwards `Base`'s captures at + // construction. Without this, every inherited method read `undefined` + // for the base's captures because the synthesized subclass + // constructor never received them as params. A raw-text match (or + // `resolve_class_name`, which only disambiguates same-named class + // DECLARATIONS) would reintroduce exactly the #5437 same-named-local + // collision for a minified bundle where two unrelated functions each + // declare their own `const Base = class {...}`. + // Only fall back for a subclass with NO explicit constructor of its + // own: an explicit constructor's own `super(...)` call already forwards + // whatever the parent needs via a SEPARATE, already-correct mechanism + // (the issue's own "Works" list: "an explicit `constructor() { + // super(); }` in the subclass" — confirmed by probing that case against + // a build without this fallback). Widening the union unconditionally + // regressed it: the parent capture then also lands in THIS class's own + // `captures_vec`, and the auto-stash machinery below expects to own + // forwarding an inherited cap into `super(...)` only for the + // SYNTHESIZED default constructor shape, not a user-written one. + let has_own_constructor = class_decl + .class + .body + .iter() + .any(|m| matches!(m, ast::ClassMember::Constructor(_))); + let capture_parent_name: Option = extends_name.clone().or_else(|| { + if has_own_constructor { + return None; + } + class_decl + .class + .super_class + .as_deref() + .and_then(|sc| match sc { + ast::Expr::Ident(ident) => { + let raw = ident.sym.to_string(); + Some(ctx.resolve_class_alias(&raw).unwrap_or(raw)) + } + _ => None, + }) + }); + // First pass: collect static field/method names for early registration // This allows static method bodies to reference static fields let mut static_field_names = Vec::new(); @@ -1140,7 +1195,7 @@ pub fn lower_class_decl( synthesize_class_captures( ctx, &name, - extends_name.as_deref(), + capture_parent_name.as_deref(), extends.is_some() || extends_name.is_some() || native_extends.is_some() @@ -1455,6 +1510,33 @@ pub fn lower_class_from_ast( (None, None, None, None) }; + // Issue #10486: mirrors the capture-forwarding fallback in + // `lower_class_decl` above (see its comment for the full rationale) — + // a class EXPRESSION extending a lexically-local capture-bearing class + // EXPRESSION (`const Base = class {…}; const Sub = class extends Base + // {…}`) needs the alias-resolved heritage identifier for capture + // lookup even when `extends_name` was deliberately left None for + // class-registry resolution. + // See the matching guard in `lower_class_decl` above: skip the + // fallback when this class expression has its own explicit + // constructor (its `super(...)` already forwards correctly). + let has_own_constructor = class + .body + .iter() + .any(|m| matches!(m, ast::ClassMember::Constructor(_))); + let capture_parent_name: Option = extends_name.clone().or_else(|| { + if has_own_constructor { + return None; + } + class.super_class.as_deref().and_then(|sc| match sc { + ast::Expr::Ident(ident) => { + let raw = ident.sym.to_string(); + Some(ctx.resolve_class_alias(&raw).unwrap_or(raw)) + } + _ => None, + }) + }); + let mut static_field_names = Vec::new(); let mut static_method_names = Vec::new(); for member in &class.body { @@ -1849,7 +1931,7 @@ pub fn lower_class_from_ast( synthesize_class_captures( ctx, name, - extends_name.as_deref(), + capture_parent_name.as_deref(), extends.is_some() || extends_name.is_some() || native_extends.is_some() diff --git a/test-files/test_gap_10486_class_expr_subclass_captures.ts b/test-files/test_gap_10486_class_expr_subclass_captures.ts new file mode 100644 index 0000000000..d25b2f6f93 --- /dev/null +++ b/test-files/test_gap_10486_class_expr_subclass_captures.ts @@ -0,0 +1,93 @@ +// #10486: an inherited method of a capture-bearing class EXPRESSION saw +// `undefined` captures when called on an instance of a capture-bearing +// `class extends` subclass with no explicit constructor. +// +// `lower_class_decl`/`lower_class_from_ast` (crates/perry-hir/src/lower_decl/ +// class_decl.rs) deliberately leave `extends_name` at `None` when the +// heritage identifier resolves to a lexically-scoped local (`locally_ +// shadowed`) rather than a statically-registered class declaration — the +// #5437 PQueue fix, avoiding a retained name being re-resolved by the +// static parent-chain walks to an unrelated same-named class. +// `synthesize_class_captures` (lower_decl/class_captures.rs) unions a +// parent's registered captures into the child's synthesized-constructor +// params keyed off that SAME `extends_name`; when it's `None`, the union +// never ran, so the subclass constructor never received the base's +// captured locals and every inherited method read `undefined` for them. +// +// This is exactly the shape `const Base = class {...}; const Sub = class +// extends Base {...}` produces (both class EXPRESSIONS assigned to +// locals) — esbuild/tsc's typical bundled-class emit, and what broke +// typescript 5.8.2's CJS `transpileModule` (`IdentifierNameMultiMap +// extends IdentifierNameMap`, both class expressions in `typescript.js`'s +// module wrapper, the subclass with its own `add`/`remove` methods). +// +// NOTE: this fix is scoped to a subclass that has at least one member of +// its own (a method, in this file) and NO explicit constructor of its own +// — see `explicitCtor` below, the pre-existing "already works" control +// this fix must not regress. A subclass with a completely empty body +// (`const Sub = class extends Base {};`), or a base class DECLARATION +// (rather than expression) as the extends target, hits a SEPARATE, unfixed +// codegen field-layout bug — see the PR body for #10486. +// +// Each function below uses its own distinct Base/Sub identifier names +// (Base1/Sub1, Base2/Sub2, ...): re-using the same literal name across +// sibling functions hits an unrelated, pre-existing collision in the +// class-capture registries (confirmed present on a build with NONE of this +// PR's changes) that is out of this PR's scope. + +function minimal(): void { + const baseCap = "base-capture"; + const subCap = "sub-capture"; + const Base1 = class { + m() { + return baseCap; + } + }; + const Sub1 = class extends Base1 { + n() { + return subCap; + } + }; + console.log("minimal", new Base1().m(), new Sub1().m(), new Sub1().n()); +} +minimal(); + +// Control: an explicit `constructor() { super(); }` on the subclass must +// keep working (pre-existing "Works" case; a naive union of parent +// captures into every subclass regressed exactly this shape during +// development of this fix — kept here as the regression guard). +function explicitCtor(): void { + const cap2 = "explicit-super-cap"; + const Base2 = class { + m() { + return cap2; + } + }; + const Sub2 = class extends Base2 { + constructor() { + super(); + } + }; + console.log("explicitCtor", new Sub2().m()); +} +explicitCtor(); + +// A captured HELPER function (not just a string) read from an inherited +// method on a subclass instance; subclass has its own (uncaptured) member. +function capturedHelper(): void { + function helper3(x: string): string { + return "[" + x + "]"; + } + const Base3 = class { + m() { + return helper3("base"); + } + }; + const Sub3 = class extends Base3 { + own() { + return "own"; + } + }; + console.log("capturedHelper", new Sub3().m(), new Sub3().own()); +} +capturedHelper(); From e9254736c560a61b744abde54b4556356316eb35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:29:49 +0000 Subject: [PATCH 17/30] changelog: fragment for #10628 --- .../10628-class-expr-subclass-captures.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 changelog.d/10628-class-expr-subclass-captures.md diff --git a/changelog.d/10628-class-expr-subclass-captures.md b/changelog.d/10628-class-expr-subclass-captures.md new file mode 100644 index 0000000000..db9cd9ad50 --- /dev/null +++ b/changelog.d/10628-class-expr-subclass-captures.md @@ -0,0 +1,21 @@ +### Fixed + +- **An inherited method of a capture-bearing class expression could read + `undefined` captures when called on a subclass instance.** A subclass + with no explicit constructor, extending a capture-bearing class + EXPRESSION bound to a local (`const Base = class { m() { return cap; } }; + const Sub = class extends Base { n() {...} };`), never forwarded `Base`'s + captured enclosing-scope locals to the synthesized subclass constructor — + the lowering deliberately drops the static `extends_name` for such a + lexically-local heritage identifier (avoiding a same-named-class + collision, #5437), and capture propagation was keyed off that same name. + Capture forwarding now resolves the heritage identifier through the same + let/const class-alias table `new X()` construction already uses, scoped + to subclasses that have their own member and no explicit constructor of + their own (an explicit constructor already forwarded captures correctly + through a separate mechanism). This was blocking `typescript`'s CJS + `transpileModule` output (`IdentifierNameMultiMap extends + IdentifierNameMap`, both class expressions with their own methods, in the + bundled `typescript.js`). A subclass with a completely empty body, or + whose base is a class DECLARATION rather than expression, hits a + separate, still-open codegen field-layout gap (#10486). From 6d891cb13db7b8f01670ce300cc21aa3e5dab1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:49:28 +0000 Subject: [PATCH 18/30] fix(hir): keep the reified receiver for a computed dynamic key on a builtin namespace Math[k], JSON[k], Object[k] and other builtin namespace/constructor member reads with a non-literal computed key collapsed to the bare GlobalGet(0) intrinsic sentinel, so the read landed on the number 0 instead of the real object (Math[key](x) threw "(number).x is not a function"). #973's value-form reroute wraps these idents as PropertyGet{GlobalGet(0), name}; member_tail.rs undoes that reroute in member-object position so the intrinsic call/constant-fold paths for a STATICALLY-KNOWN member name (Math.max(...)) keep their pre-#973 bare receiver. That undo is only safe when the member name is known at lowering time -- outer_static_member is None for a computed non-literal key, which zeroed out the outer_is_reified_*/ outer_is_inherited_* guards instead of blocking the undo itself. Add outer_is_dynamic_computed_key to the existing conjunction so any dynamic key keeps the reified receiver, letting the runtime property lookup resolve against the real namespace/constructor object. Verified this needs no console carve-out (console[m](...) already falls back correctly to the generic dynamic-dispatch path once its receiver survives). Literal-key paths are untouched by construction -- the flag only fires for MemberProp::Computed with a non-string-literal key. --- .../src/lower/expr_member/member_tail.rs | 42 +++++ ...gap_10483_computed_key_namespace_member.ts | 155 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 test-files/test_gap_10483_computed_key_namespace_member.ts diff --git a/crates/perry-hir/src/lower/expr_member/member_tail.rs b/crates/perry-hir/src/lower/expr_member/member_tail.rs index 76acfd900f..d3b984a033 100644 --- a/crates/perry-hir/src/lower/expr_member/member_tail.rs +++ b/crates/perry-hir/src/lower/expr_member/member_tail.rs @@ -434,6 +434,47 @@ pub(crate) fn lower_member_tail( crate::analysis::is_builtin_static_function_member(property, member) }) .unwrap_or(false); + // #10483: a computed non-literal key (`Math[k]`, `JSON[op]`) + // cannot resolve to an intrinsic static at lowering time — + // `outer_static_member` is `None` for it, which only zeros out + // the `outer_is_reified_*`/`outer_is_inherited_*` flags above + // rather than blocking the undo below. Left unguarded, the undo + // hands codegen a bare `GlobalGet(0)` receiver and `Math[k]` + // reads a property of the number 0 instead of the + // namespace/constructor object. Keep the reified receiver for + // any dynamic key so the runtime property lookup runs against + // the real object. (`Array` already keeps its receiver for a + // dynamic key via `receiver_is_array_ctor_unknown_static` + // above — same `outer_static_member == None` trapdoor, fixed + // there first for a different reason (#5898); this flag is + // redundant-but-harmless for `Array` and load-bearing for + // every other builtin.) + // + // `console` is deliberately NOT excluded, despite `console[m]` + // having its own legacy workaround a few dozen lines down (the + // `js_console_method_by_value` IndexGet arm, added for the + // Next.js `prefixedLog` wall back when this same undo collapsed + // the receiver to the bare `GlobalGet(0)` sentinel for a + // call-position dynamic key). Verified rather than assumed: + // with this flag applied uniformly (no console carve-out), + // `console[method](msg)` lowers to a plain + // `IndexGet { PropertyGet{GlobalGet(0),"console"}, key }` + // dynamic call (confirmed via `--trace hir --focus`) instead of + // routing through `js_console_method_by_value` — and it runs + // correctly, because the real `console` receiver this flag now + // preserves is exactly what that generic dynamic-dispatch path + // needs. The old workaround only existed to compensate for the + // receiver being lost; once the receiver survives, the + // workaround's branch simply goes unreached for this shape. + // Confirmed byte-identical against Node for both the call form + // (`console[m](...)`) and the value-read form (`console[m]`, + // separately guarded by `receiver_is_detached_console_read` + // above) in `test_gap_10483_computed_key_namespace_member.ts`. + let outer_is_dynamic_computed_key = matches!( + &member.prop, + ast::MemberProp::Computed(c) + if !matches!(c.expr.as_ref(), ast::Expr::Lit(ast::Lit::Str(_))) + ); if !outer_is_prototype_or_proto && !outer_is_constructor_property && !receiver_is_namespace_value @@ -449,6 +490,7 @@ pub(crate) fn lower_member_tail( && !outer_is_inherited_object_proto_method && !outer_is_inherited_function_proto_method && !receiver_is_detached_console_read + && !outer_is_dynamic_computed_key { object_expr = Expr::GlobalGet(0); } diff --git a/test-files/test_gap_10483_computed_key_namespace_member.ts b/test-files/test_gap_10483_computed_key_namespace_member.ts new file mode 100644 index 0000000000..5ba85cee93 --- /dev/null +++ b/test-files/test_gap_10483_computed_key_namespace_member.ts @@ -0,0 +1,155 @@ +// #10483: `Math[k]` / `JSON[k]` / `Object[k]` / ... with a *variable* (non-literal) +// computed key must resolve against the real namespace/constructor object, not the +// number 0. #973 rerouted bare builtin idents used as VALUES to +// `PropertyGet{GlobalGet(0), name}`; member_tail.rs undoes that reroute in +// member-OBJECT position so the intrinsic call / constant-fold paths for a +// STATICALLY-KNOWN member name (`Math.max(...)`) keep their pre-#973 `GlobalGet(0)` +// receiver. That undo is wrong for a computed non-literal key — nothing can resolve +// to an intrinsic at lowering time, so the receiver collapsed to the bare `GlobalGet(0)` +// sentinel and the read landed on the number 0. Closed #6677 fixed only the +// string-literal direct-call form (`Math["max"](...)`); this covers the variable-key +// forms it left broken, in both call position and value-read position, with keys from +// a plain variable, a template literal, and a function return. Static (literal) key +// paths are re-asserted unchanged at the end — that's the entire point of the +// reroute-undo this fix narrows (test_gap_number_math regressed when #973 first +// landed). + +// --------------------------------------------------------------------------- +// call position, key from a plain variable +// --------------------------------------------------------------------------- +function callMath(key: string, ...args: number[]): number { + // @ts-ignore -- deliberately untyped computed access, the #10483 repro shape + return Math[key](...args); +} +console.log("call/variable:"); +console.log(callMath("max", 1, 5, 3)); +console.log(callMath("min", 1, 5, 3)); +console.log(callMath("round", 2.6)); + +function callJson(key: string, value: unknown): string { + // @ts-ignore + return JSON[key](value); +} +console.log(callJson("stringify", { a: 1, b: [1, 2, 3] })); + +function callReflect(key: string, target: object, prop: string): boolean { + // @ts-ignore + return Reflect[key](target, prop); +} +console.log(callReflect("has", { x: 1 }, "x")); +console.log(callReflect("has", { x: 1 }, "y")); + +function callNumber(key: string, value: number): boolean { + // @ts-ignore + return Number[key](value); +} +console.log(callNumber("isInteger", 5)); +console.log(callNumber("isInteger", 5.5)); + +function callArray(key: string, value: unknown): boolean { + // @ts-ignore + return Array[key](value); +} +console.log(callArray("isArray", [1, 2])); +console.log(callArray("isArray", {})); + +function callString(key: string, ...codes: number[]): string { + // @ts-ignore + return String[key](...codes); +} +console.log(callString("fromCharCode", 72, 105)); + +function callDate(key: string): boolean { + // @ts-ignore + return typeof Date[key]() === "number"; +} +console.log(callDate("now")); + +// --------------------------------------------------------------------------- +// value-read position (not called), key from a plain variable +// --------------------------------------------------------------------------- +console.log("value-read/variable:"); +function readTypeof(obj: unknown, key: string): string { + // @ts-ignore + return typeof obj[key]; +} +console.log(readTypeof(Math, "max")); +console.log(readTypeof(JSON, "stringify")); +console.log(readTypeof(Reflect, "ownKeys")); +console.log(readTypeof(Number, "isInteger")); +console.log(readTypeof(Date, "now")); +console.log(readTypeof(Array, "isArray")); +console.log(readTypeof(String, "fromCharCode")); + +function grabMax(key: string): (...xs: number[]) => number { + // @ts-ignore + return Math[key]; +} +const maxFn = grabMax("max"); +console.log(typeof maxFn, maxFn(7, 42, 3)); + +// --------------------------------------------------------------------------- +// key from a template literal +// --------------------------------------------------------------------------- +console.log("template-literal key:"); +function templateCall(part: string): number { + // @ts-ignore + return Math[`${part}`](4, 9); +} +console.log(templateCall("max")); +console.log(templateCall("min")); + +function templateRead(part: string): string { + // @ts-ignore + return typeof JSON[`${part}`]; +} +console.log(templateRead("parse")); + +// --------------------------------------------------------------------------- +// key from a function return +// --------------------------------------------------------------------------- +console.log("function-return key:"); +function pickMaxKey(): string { + return "max"; +} +// @ts-ignore +console.log(Math[pickMaxKey()](4, 9)); + +function pickIsIntegerKey(): string { + return "isInteger"; +} +// @ts-ignore +console.log(Number[pickIsIntegerKey()](10)); + +// --------------------------------------------------------------------------- +// static (literal) key controls — must stay byte-identical to before #10483 +// --------------------------------------------------------------------------- +console.log("static controls:"); +console.log(Math.max(1, 5, 3)); +console.log(Math["max"](1, 5, 3)); +console.log(Math.min(1, 5, 3)); +console.log(JSON.stringify({ z: 1 })); +console.log(JSON["stringify"]({ z: 1 })); +console.log(Number.parseInt("42", 10)); +console.log(Number.isInteger(5)); +console.log(Array.isArray([1])); +console.log(Reflect.has({ x: 1 }, "x")); + +// --------------------------------------------------------------------------- +// `console`'s pre-existing dynamic dispatch (#js_console_method_by_value, +// the Next.js `prefixedLog` fix) must keep working — #10483's new guard +// deliberately excludes "console" so the call-position undo it depends on +// still fires. +// --------------------------------------------------------------------------- +console.log("console dynamic dispatch:"); +function consoleCall(method: string, msg: string): void { + // @ts-ignore + console[method](msg); +} +consoleCall("log", "console[method](...) still dispatches"); + +function consoleValueRead(method: string): string { + // @ts-ignore + return typeof console[method]; +} +console.log(consoleValueRead("log")); From b111b4bb942cc6d285bc50643556ed22349cbd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:50:48 +0000 Subject: [PATCH 19/30] docs(changelog): add fragment for #10629 --- changelog.d/10629-computed-key-namespace-member.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 changelog.d/10629-computed-key-namespace-member.md diff --git a/changelog.d/10629-computed-key-namespace-member.md b/changelog.d/10629-computed-key-namespace-member.md new file mode 100644 index 0000000000..3e6c4ba3c4 --- /dev/null +++ b/changelog.d/10629-computed-key-namespace-member.md @@ -0,0 +1,12 @@ +Fixed `Math[k]`, `JSON[k]`, `Object[k]`, `Number[k]`, `Reflect[k]`, `Date[k]`, `String[k]`, and +other built-in namespace/constructor member reads with a *variable* (non-literal) computed key +reading a property of the number `0` instead of the real object — `Math[key](x)` threw +`(number).x is not a function`, and `typeof Math[key]` was `undefined` for every key. #973's +value-form reroute of bare built-in identifiers was correctly undone in member-object position for +a statically-known member name (`Math.max(...)`), so the intrinsic call/constant-fold paths could +keep their pre-#973 `GlobalGet(0)` receiver — but the undo also fired for a computed non-literal +key, where nothing can resolve to an intrinsic at lowering time, collapsing the receiver to the +bare `GlobalGet(0)` sentinel. Closed #6677 fixed only the string-literal direct-call form +(`Math["max"](...)`); this fixes the variable-key forms (call and value-read position, keys from a +variable, a template literal, or a function return) it left broken. Static (literal) key paths are +unaffected by construction. `#10483` From 556f424d68bd9620a5fbf809a85b738a41d5775f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 14:26:33 +0000 Subject: [PATCH 20/30] fix(transform): stop cross-module inlining bundling a separately exported sibling by value An exported function whose body references another exported function BY VALUE (`x === f`, not just as a call target) was a candidate for the cross-module function inliner, which bundled a private clone of the sibling into the destination module under a fresh symbol. Every function value materializes into a heap closure keyed by its wrapper symbol, so the clone's `f` and the canonical `f` every importer resolves through produced two distinct closures -- an in-module identity check silently disagreed with every importer's own view of the same function. gather_cross_module_functions now refuses a candidate whose dependency graph would need to bundle a separately-exported sibling referenced by value; it falls back to the ordinary cross-module call instead, which resolves through the shared canonical wrapper. Self-recursion is unaffected. Fixes #10554 --- .../src/inline/cross_module.rs | 31 +++++++++++- test-files/_helpers/fn_identity_10554/lib.ts | 37 ++++++++++++++ .../_helpers/fn_identity_10554/reexport.ts | 13 +++++ .../test_gap_10554_fn_identity_own_module.ts | 50 +++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 test-files/_helpers/fn_identity_10554/lib.ts create mode 100644 test-files/_helpers/fn_identity_10554/reexport.ts create mode 100644 test-files/test_gap_10554_fn_identity_own_module.ts diff --git a/crates/perry-transform/src/inline/cross_module.rs b/crates/perry-transform/src/inline/cross_module.rs index f3c5d865fd..ebb9b4fef9 100644 --- a/crates/perry-transform/src/inline/cross_module.rs +++ b/crates/perry-transform/src/inline/cross_module.rs @@ -163,6 +163,25 @@ pub fn gather_cross_module_functions(module: &Module) -> HashMap_` wrapper symbol, a different + // `js_closure_alloc_singleton` key than the sibling's own canonical + // `__perry_wrap_perry_fn___`, so `x === exportedSibling` + // inside the inlined body silently disagrees with every importer's view + // of `exportedSibling`. `exported_ids` gates `collect_function_graph`'s + // dependency walk below: a graph that would need to bundle a + // separately-exported function is refused entirely (no candidate), + // falling back to the ordinary cross-module call, which shares the + // source module's own canonical wrapper. + let exported_ids: HashSet = module + .exported_functions + .iter() + .map(|(_, id)| *id) + .collect(); + let mut out = HashMap::new(); for (exported_name, root_id) in &module.exported_functions { let mut visiting = HashSet::new(); @@ -174,6 +193,7 @@ pub fn gather_cross_module_functions(module: &Module) -> HashMap, visited: &mut HashSet, out: &mut Vec, + exported_ids: &HashSet, ) -> bool { if visited.contains(&id) { return true; @@ -268,7 +289,15 @@ fn collect_function_graph( refs.sort_unstable(); refs.dedup(); for dependency in refs { - if !collect_function_graph(dependency, functions, visiting, visited, out) { + // #10554: a dependency pulled in only because the body reads it as a + // VALUE (`Expr::FuncRef`) -- not merely calls it -- must not be + // bundled as a private clone when it is ALSO independently exported. + // `dependency != id` lets a function's own export status not block + // its (already-permitted) self-recursion. + if dependency != id && exported_ids.contains(&dependency) { + return false; + } + if !collect_function_graph(dependency, functions, visiting, visited, out, exported_ids) { return false; } if visited.len() > MAX_CROSS_MODULE_FUNCTION_GRAPH { diff --git a/test-files/_helpers/fn_identity_10554/lib.ts b/test-files/_helpers/fn_identity_10554/lib.ts new file mode 100644 index 0000000000..9f5012ed7c --- /dev/null +++ b/test-files/_helpers/fn_identity_10554/lib.ts @@ -0,0 +1,37 @@ +// Shared helpers for #10554: a function referenced inside its own module +// must be the SAME object an importer sees. +export function fnDecl() { + return 1; +} +export const fnExpr = function fnExprNamed() { + return 2; +}; +export const arrowFn = () => 3; + +// Value comparisons made FROM WITHIN this module -- the defect's exact +// shape: `isSame*` is itself exported, so it is a candidate for +// cross-module inlining, and its body's reference to the sibling export is +// a plain in-module reference, not an import. +export function isSameDecl(x: unknown) { + return x === fnDecl; +} +export function isSameExpr(x: unknown) { + return x === fnExpr; +} +export function isSameArrow(x: unknown) { + return x === arrowFn; +} +export function bothSame(a: unknown, b: unknown) { + return a === b; +} +export function makeSet() { + return new Set([fnDecl, fnExpr, arrowFn]); +} + +// A default export that ALSO references an exported sibling by value -- +// #10548 fixed the export-ROW identity for `export default F`; this checks +// the (distinct) cross-module-inliner defect #10554 fixes doesn't resurface +// under a default export. +export default function useDecl(x: unknown) { + return x === fnDecl; +} diff --git a/test-files/_helpers/fn_identity_10554/reexport.ts b/test-files/_helpers/fn_identity_10554/reexport.ts new file mode 100644 index 0000000000..b283403569 --- /dev/null +++ b/test-files/_helpers/fn_identity_10554/reexport.ts @@ -0,0 +1,13 @@ +// Barrel re-export -- a THIRD view of the same bindings, once removed from +// the declaring module. +export { + fnDecl, + fnExpr, + arrowFn, + isSameDecl, + isSameExpr, + isSameArrow, + bothSame, + makeSet, +} from "./lib.ts"; +export { default as useDeclDefault } from "./lib.ts"; diff --git a/test-files/test_gap_10554_fn_identity_own_module.ts b/test-files/test_gap_10554_fn_identity_own_module.ts new file mode 100644 index 0000000000..00c70bc13e --- /dev/null +++ b/test-files/test_gap_10554_fn_identity_own_module.ts @@ -0,0 +1,50 @@ +// #10554: a function referenced inside its own module is a different +// object from the same function imported elsewhere. +import useDecl, { + fnDecl, + fnExpr, + arrowFn, + isSameDecl, + isSameExpr, + isSameArrow, + bothSame, + makeSet, +} from "./_helpers/fn_identity_10554/lib.ts"; +import * as ns from "./_helpers/fn_identity_10554/lib.ts"; +import { + fnDecl as reFnDecl, + isSameDecl as reIsSameDecl, + useDeclDefault, +} from "./_helpers/fn_identity_10554/reexport.ts"; + +// 1. function declaration, function expression, arrow: in-module identity +// checked from a call made through the IMPORTED value. +console.log("decl:", isSameDecl(fnDecl)); +console.log("expr:", isSameExpr(fnExpr)); +console.log("arrow:", isSameArrow(arrowFn)); + +// 2. Both directions: importer's namespace view vs named-import view vs +// in-module (through the exported checker functions). +console.log("ns decl:", isSameDecl(ns.fnDecl), ns.fnDecl === fnDecl, fnDecl === ns.fnDecl); +console.log("ns expr:", isSameExpr(ns.fnExpr), ns.fnExpr === fnExpr); +console.log("ns arrow:", isSameArrow(ns.arrowFn), ns.arrowFn === arrowFn); + +// 3. Re-export (barrel): a third view, once removed. +console.log("reexport decl:", reIsSameDecl(reFnDecl), reFnDecl === fnDecl, isSameDecl(reFnDecl)); + +// 4. default export whose body ALSO references an exported sibling by +// value (distinct from #10434/#10548's export-row identity; exercises the +// cross-module-inliner defect instead). +console.log("default:", useDecl(fnDecl), useDeclDefault(fnDecl), useDecl === useDeclDefault); + +// 5. bothSame -- direct pass-through, no local materialization inside the +// callee (a control: unaffected by this defect, should always have passed). +console.log("bothSame decl:", bothSame(fnDecl, fnDecl), bothSame(fnDecl, ns.fnDecl)); +console.log("bothSame cross:", bothSame(fnDecl, fnExpr)); + +// 6. Set membership -- identity through collection storage/lookup, built +// FROM WITHIN the module (the same defect shape via a different value +// consumer than `===`). +const set = makeSet(); +console.log("set has:", set.has(fnDecl), set.has(fnExpr), set.has(arrowFn)); +console.log("set has via ns:", set.has(ns.fnDecl)); From 2d586b31294bada0e4bbd6d4ce51944e39b1d374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 14:27:03 +0000 Subject: [PATCH 21/30] docs(changelog): add fragment for #10630 --- changelog.d/10630-fn-identity-own-module.md | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 changelog.d/10630-fn-identity-own-module.md diff --git a/changelog.d/10630-fn-identity-own-module.md b/changelog.d/10630-fn-identity-own-module.md new file mode 100644 index 0000000000..27f7e52f13 --- /dev/null +++ b/changelog.d/10630-fn-identity-own-module.md @@ -0,0 +1,37 @@ +### Fixed + +- **A function referenced inside its own module is a different object from + the same function imported elsewhere (#10554).** `function f(){}; export + function isSame(x){ return x === f; }; export { f };` gave `isSame(f)` + `false` for an importer's own `f` — identity checks, registries/caches + keyed by function, `removeEventListener`/`off(fn)`, and memoization all + silently took the wrong branch. + + Root cause: the cross-module *function* inliner in + `perry-transform`'s `inline/cross_module.rs` harvests an exported + function's whole value-dependency graph — every function it transitively + references, including by value (`x === f`), not just as a call target — + and clones the entire graph into every importing module under fresh + `__perry_xmod_inline__` symbols. When the referenced function + (`f`) is *also* independently exported, it got cloned alongside the + candidate instead of resolved through its own canonical wrapper. Every + function value materializes into a heap closure keyed by its wrapper + *symbol* (`js_closure_alloc_singleton`), so the clone's `f` and the + canonical `f` every importer resolves through produced two distinct + closures — an in-module identity check comparing them disagreed with + every importer's own view. + + Fix: `gather_cross_module_functions` now refuses a candidate whose + dependency graph would need to bundle a *separately exported* sibling + function referenced by value — it falls back to an ordinary cross-module + call instead, which resolves through the shared canonical wrapper. + Self-recursion is unaffected. The directly-affected shape actually gets + **faster**, not slower: the unsound inline was paying for an extra + closure materialization on every call. + + Validation: new `test_gap_10554_fn_identity_own_module` (function + declaration, function expression, arrow-in-const, named export, a barrel + re-export, a default export referencing an exported sibling, `Set` + membership, both identity directions) fails on the baseline and matches + Node on the fix; the existing `test_gap_10434`/export/import/cross-module/ + inline/module gap-test families (16 tests) are unaffected. From 42626e41e903a3931096804e7d9bcb3a0be681e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 14:39:48 +0000 Subject: [PATCH 22/30] fix(runtime): add Symbol.toStringTag to Web/runtime built-ins Object.prototype.toString.call(x) fell through to the generic [object Object] for URL, URLSearchParams, Headers, Request, Response, FormData, Blob, File, AbortController, AbortSignal, TextEncoder, TextDecoder, EventTarget, Event and CustomEvent, and x[Symbol.toStringTag] read back undefined -- breaking the standard cross-realm type check utility/HTTP libraries use (axios decides body serialization this way). Two representations, two gaps: the Web Fetch family and TextEncoder/ TextDecoder are small-integer registry handles with no brand/property case; URL/URLSearchParams and AbortController/AbortSignal/EventTarget/ Event/CustomEvent are real objects whose instances are never linked to their .prototype via object_static_prototype, so a property installed only there would never be reached from an instance. A new web_builtin_to_string_tag answers both Object.prototype.toString and x[Symbol.toStringTag] from one place, and a real, correctly-shaped descriptor is also installed on each constructor's own .prototype for reflection. Fixes #10555 --- .../src/object/global_this/proto_methods.rs | 61 ++++++++++++++ crates/perry-runtime/src/object/mod.rs | 1 + crates/perry-runtime/src/object/tests.rs | 29 +++++-- .../perry-runtime/src/object/to_string_tag.rs | 82 +++++++++++++++++++ crates/perry-runtime/src/symbol/get.rs | 31 +++++++ .../perry-stdlib/src/fetch/body_metadata.rs | 9 ++ crates/perry-stdlib/src/fetch/dispatch.rs | 15 +++- ...p_10555_symbol_tostringtag_web_builtins.ts | 60 ++++++++++++++ 8 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 test-files/test_gap_10555_symbol_tostringtag_web_builtins.ts diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index 29f60e3d82..fa3237e267 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -4,6 +4,45 @@ use super::*; // `array_proto_*_thunk` without routing through the trunk re-exports. use super::array_error::*; +/// Install a FIXED-string `Symbol.toStringTag` data property (`{ value: tag, +/// writable: false, enumerable: false, configurable: true }`, ES2019 +/// WebIDL/`get %TypedArray%.prototype [ @@toStringTag ]` sibling shape but a +/// plain data property rather than a getter -- these Web API interfaces +/// each own a single fixed tag, unlike the shared TypedArray prototype) on +/// `proto_obj`. #10555: `Object.prototype.toString.call(x)` and +/// `x[Symbol.toStringTag]` for these types are ALSO answered directly by +/// `crate::object::web_builtin_to_string_tag` (`object/to_string_tag.rs`) +/// for every instance shape that reaches it -- most of these types' own +/// instances never link `[[Prototype]]` back to this very `proto_obj` (see +/// that function's doc comment), so that synthesized answer is load-bearing +/// for `x[Symbol.toStringTag]`/`toString.call(x)` on an INSTANCE. This +/// installs the matching descriptor on the constructor's `.prototype` +/// object itself so `Object.getOwnPropertyDescriptor(Ctor.prototype, +/// Symbol.toStringTag)` also reflects a real, correctly-shaped descriptor +/// (test262-style reflection, and libraries that copy descriptors off the +/// prototype rather than reading the instance). +unsafe fn install_web_builtin_to_string_tag(proto_obj: *mut ObjectHeader, tag: &str) { + if proto_obj.is_null() { + return; + } + let symbol = crate::symbol::well_known_symbol("toStringTag"); + if symbol.is_null() { + return; + } + let key = crate::string::js_string_from_bytes(tag.as_ptr(), tag.len() as u32); + let value = f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()); + crate::symbol::js_object_set_symbol_property( + crate::value::js_nanbox_pointer(proto_obj as i64), + crate::value::js_nanbox_pointer(symbol as i64), + value, + ); + crate::symbol::set_symbol_property_attrs( + proto_obj as usize, + symbol as usize, + crate::object::PropertyAttrs::new(false, false, true), + ); +} + /// Universal `Object.prototype` methods inherited by every receiver in /// JS. Installed on every built-in constructor's prototype since Perry's /// prototype chain on these built-ins doesn't walk back up to a shared @@ -685,11 +724,13 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: "TextEncoder" => { install_noop_proto_methods(proto_obj, &[("encode", 1), ("encodeInto", 2)]); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "TextEncoder") }; } #[cfg(feature = "global-text")] "TextDecoder" => { install_noop_proto_methods(proto_obj, &[("decode", 1)]); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "TextDecoder") }; } #[cfg(feature = "global-webfetch")] "Headers" => { @@ -709,6 +750,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ], ); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "Headers") }; } #[cfg(feature = "global-webfetch")] "Request" | "Response" => { @@ -781,6 +823,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: } } install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, builtin_name) }; } #[cfg(feature = "global-webfetch")] "Blob" | "File" => { @@ -795,6 +838,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ], ); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, builtin_name) }; } #[cfg(feature = "global-webfetch")] "FormData" => { @@ -814,6 +858,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ], ); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "FormData") }; } #[cfg(feature = "global-websocket")] "WebSocket" => { @@ -935,6 +980,22 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: // is wired alongside the `OBJ_FLAG_TYPED_ARRAY_PROTO` flag so the // generic property-get chain walk resolves the inherited methods. } + // #10555: these Web API types install NO methods here (their surface + // is either type-directed static dispatch or the small-int/handle + // dispatch tables), but each still needs its `.prototype`'s own + // `Symbol.toStringTag` descriptor for reflection -- see + // `install_web_builtin_to_string_tag`'s doc comment. + "URL" => unsafe { install_web_builtin_to_string_tag(proto_obj, "URL") }, + "URLSearchParams" => unsafe { + install_web_builtin_to_string_tag(proto_obj, "URLSearchParams") + }, + "AbortController" => unsafe { + install_web_builtin_to_string_tag(proto_obj, "AbortController") + }, + "AbortSignal" => unsafe { install_web_builtin_to_string_tag(proto_obj, "AbortSignal") }, + "EventTarget" => unsafe { install_web_builtin_to_string_tag(proto_obj, "EventTarget") }, + "Event" => unsafe { install_web_builtin_to_string_tag(proto_obj, "Event") }, + "CustomEvent" => unsafe { install_web_builtin_to_string_tag(proto_obj, "CustomEvent") }, _ => {} } } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index d23eab11e6..d633db2d2c 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -315,6 +315,7 @@ pub use this_binding::{ }; pub use to_string_tag::js_object_to_string; pub(crate) use to_string_tag::typed_array_to_string_tag_name; +pub(crate) use to_string_tag::web_builtin_to_string_tag; /// An atomic GC root whose backing slot belongs to the calling Perry agent. /// diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index ec8b14aa3d..43f8270dac 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -1309,12 +1309,11 @@ fn wide_object_own_key_present_uses_index_and_object_values_is_complete() { /// `js_object_to_string` must NOT dereference a handle-band value (a Web Fetch /// `Headers`/`Request`/`Response`/`Blob` registry id, or any other small native -/// handle) as a heap pointer. Such ids are NaN-boxed as `POINTER_TAG` values but -/// are not `GcHeader`-prefixed objects; reading the GC type byte at `id - 8` (or -/// `(*ObjectHeader).class_id` at `id`) faults on unmapped low memory. This is -/// the `claude -p` SIGSEGV (`EXC_BAD_ACCESS` at `0x3FFFB` == `0x40003 - 8`), -/// where the SDK coerced a `Headers` handle to a string while building a -/// request. The brand must fall through to the generic `[object Object]` tag. +/// handle) as a heap pointer -- `id - 8` / `id` faults on unmapped low memory. +/// This is the `claude -p` SIGSEGV (`EXC_BAD_ACCESS` at `0x3FFFB` == +/// `0x40003 - 8`). Every id here is unclaimed in a bare unit-test process +/// (not `TEXT_ENCODER_SENTINEL_ID` either -- see the sibling test below), so +/// the brand must fall through to the generic `[object Object]` tag. #[test] fn object_to_string_rejects_handle_band_ids() { use crate::value::addr_class; @@ -1322,9 +1321,14 @@ fn object_to_string_rejects_handle_band_ids() { addr_class::FETCH_HANDLE_BAND_START, // 0x40000 addr_class::FETCH_HANDLE_BAND_START + 3, // the 0x40003 from the crash addr_class::HANDLE_BAND_MAX - 1, // 0xFFFFF - 1usize, // common native handle + 3usize, // common native handle, unclaimed ] { assert!(addr_class::is_handle_band(id)); + assert_ne!( + id, + crate::text::TEXT_ENCODER_SENTINEL_ID as usize, + "must not pick an id #10555 gives real meaning to" + ); let handle = crate::value::js_nanbox_pointer(id as i64); // Must return a string brand without dereferencing the bogus pointer. let result = unsafe { js_object_to_string(handle) }; @@ -1336,6 +1340,17 @@ fn object_to_string_rejects_handle_band_ids() { } } +/// #10555: `TEXT_ENCODER_SENTINEL_ID` is the id every `TextEncoder` shares -- +/// unlike the ids above, `js_object_to_string` must brand it `TextEncoder` +/// unconditionally, matching the runtime's own treatment of that id. +#[test] +fn object_to_string_brands_the_text_encoder_sentinel() { + let handle = crate::value::js_nanbox_pointer(crate::text::TEXT_ENCODER_SENTINEL_ID); + let result = unsafe { js_object_to_string(handle) }; + let s = js_string_to_rust(JSValue::from_bits(result.to_bits())); + assert_eq!(s, "[object TextEncoder]"); +} + /// #5437 — captured-`undefined` tag-loss on Next.js dynamic/API routes. /// /// `js_class_capture_value_or` must NOT replace a snapshot whose slot is a diff --git a/crates/perry-runtime/src/object/to_string_tag.rs b/crates/perry-runtime/src/object/to_string_tag.rs index da985ed099..1b17f6b5ab 100644 --- a/crates/perry-runtime/src/object/to_string_tag.rs +++ b/crates/perry-runtime/src/object/to_string_tag.rs @@ -16,6 +16,82 @@ pub(crate) fn web_stream_to_string_tag(value: f64) -> Option<&'static str> { } } +/// `Symbol.toStringTag` for Perry's Web/runtime built-ins that carry no +/// registered class-id hook and (for the handle-backed ones) no real +/// `ObjectHeader` at all (#10555): `URL`/`URLSearchParams` (ordinary +/// class_id-0 objects, detected structurally — see `is_url_object_shape` / +/// `shape_is_url_search_params`), the Web Fetch family `Headers` / `Request` +/// / `Response` / `Blob` / `FormData` (small-int handles owned by +/// `perry-stdlib`, reached through `fetch_handle_kind_probe` — the same +/// probe `instanceof` already uses), `TextEncoder` / `TextDecoder` (small-int +/// handles owned by this crate's own `text` module), and the class-id-tagged +/// `AbortController` / `AbortSignal` / `EventTarget` / `Event` / `CustomEvent` +/// (real `ObjectHeader`s whose instances are never linked to their +/// `.prototype` object via `object_static_prototype`, so the generic +/// own/inherited-property walk in `object_to_string_tag_property` can never +/// reach a tag installed there). +/// +/// Shared by `js_object_to_string`'s brand string and +/// `js_object_get_symbol_property`'s `x[Symbol.toStringTag]` own-property +/// read (`crate::symbol::get`), so the two can never disagree. +pub(crate) fn web_builtin_to_string_tag(value: f64) -> Option<&'static str> { + let bits = value.to_bits(); + if (bits >> 48) != 0x7FFD { + return None; + } + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::value::addr_class::is_small_handle(addr) { + // Web Fetch handle family — one shared id counter, disjoint registries + // (see `js_fetch_handle_kind`'s own doc comment). + if let Some(probe) = crate::object::fetch_handle_kind_probe() { + match unsafe { probe(addr) } { + 1 => return Some("Response"), + 2 => return Some("Request"), + 3 => return Some("Headers"), + 4 => return Some("Blob"), + 5 => return Some("File"), + 6 => return Some("FormData"), + _ => {} + } + } + // `TextEncoder` is a single stateless sentinel id; `TextDecoder` + // instances are `DECODER_REGISTRY` members. Neither overlaps the + // Web Fetch band (`FETCH_HANDLE_BAND_START` starts well above 2). + if addr == crate::text::TEXT_ENCODER_SENTINEL_ID as usize { + return Some("TextEncoder"); + } + if crate::text::is_known_text_decoder_id(addr as i64) { + return Some("TextDecoder"); + } + return None; + } + // #10555 lint: `is_valid_obj_ptr` alone is not a sufficient handle-band + // guard (its own doc says so -- the Linux/Android/iOS/Windows HEAP_MIN + // floor sits below the handle band). The `is_small_handle` branch above + // already excludes that band, but it is too far above this line for the + // addr-class ratchet's pairing window, so re-validate right here with + // `try_read_gc_header` -- the same idiom `is_url_object_shape` / + // `shape_is_url_search_params` already use for this exact receiver kind. + let obj = match unsafe { crate::value::addr_class::try_read_gc_header(addr) } { + Some(h) if h.obj_type == crate::gc::GC_TYPE_OBJECT => addr as *const ObjectHeader, + _ => return None, + }; + if crate::url::is_url_object_shape(obj as *mut ObjectHeader) { + return Some("URL"); + } + if crate::url::search_params::shape_is_url_search_params(obj) { + return Some("URLSearchParams"); + } + match unsafe { (*obj).class_id } { + crate::url::abort::ABORT_CONTROLLER_CLASS_ID => Some("AbortController"), + crate::url::abort::ABORT_SIGNAL_CLASS_ID => Some("AbortSignal"), + crate::event_target::CLASS_ID_EVENT_TARGET => Some("EventTarget"), + crate::event_target::CLASS_ID_EVENT => Some("Event"), + crate::event_target::CLASS_ID_CUSTOM_EVENT => Some("CustomEvent"), + _ => None, + } +} + unsafe fn string_value_to_owned(value: f64) -> Option { let jv = crate::value::JSValue::from_bits(value.to_bits()); if !jv.is_any_string() { @@ -257,6 +333,12 @@ pub unsafe extern "C" fn js_object_to_string(value: f64) -> f64 { let str_ptr = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); return f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK)); } + if let Some(tag) = web_builtin_to_string_tag(value) { + let formatted = format!("[object {}]", tag); + let bytes = formatted.as_bytes(); + let str_ptr = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + return f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK)); + } if let Some(tag) = crate::builtins::boxed_primitive_to_string_tag(value) { let formatted = format!("[object {}]", tag); let bytes = formatted.as_bytes(); diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 30bd25b0e6..05a42c35af 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -569,6 +569,34 @@ unsafe fn web_stream_symbol_property(obj_f64: f64, sym_f64: f64) -> Option Some(f64::from_bits(TAG_UNDEFINED)) } +/// `Symbol.toStringTag` for Perry's Web/runtime built-ins that have no +/// registered prototype-chain or class-id hook reachable from the generic +/// resolvers below (#10555) -- see `web_builtin_to_string_tag`'s doc comment +/// for the full inventory and why each kind needs this. An own override +/// (`Object.defineProperty(x, Symbol.toStringTag, …)`) still wins: the +/// side-table read is a pointer-KEYED lookup, safe even for the +/// handle-backed kinds since it never dereferences `obj_f64` as a pointer. +unsafe fn web_builtin_to_string_tag_symbol_property(obj_f64: f64, sym_f64: f64) -> Option { + let sym_key = sym_key_from_f64(sym_f64); + if sym_key == 0 { + return None; + } + let to_string_tag = well_known_symbol("toStringTag"); + if to_string_tag.is_null() { + return None; + } + let ts_f64 = f64::from_bits(crate::value::JSValue::pointer(to_string_tag as *const u8).bits()); + if sym_key != sym_key_from_f64(ts_f64) { + return None; + } + if let Some(v) = own_symbol_property(obj_f64, sym_f64) { + return Some(v); + } + let tag = crate::object::web_builtin_to_string_tag(obj_f64)?; + let str_ptr = js_string_from_bytes(tag.as_ptr(), tag.len() as u32); + Some(f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK))) +} + #[no_mangle] pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f64) -> f64 { js_object_get_symbol_property_with_receiver(obj_f64, sym_f64, obj_f64) @@ -741,6 +769,9 @@ pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( if let Some(v) = web_stream_symbol_property(obj_f64, sym_f64) { return v; } + if let Some(v) = web_builtin_to_string_tag_symbol_property(obj_f64, sym_f64) { + return v; + } // #1213: Timeout/Immediate handles expose `Symbol.dispose` so // `using t = setTimeout(...)` and `t[Symbol.dispose]()` clear the timer. // The handle is a small id NaN-boxed as POINTER; the symbol-keyed read diff --git a/crates/perry-stdlib/src/fetch/body_metadata.rs b/crates/perry-stdlib/src/fetch/body_metadata.rs index 0695ff11ac..b66d7b6148 100644 --- a/crates/perry-stdlib/src/fetch/body_metadata.rs +++ b/crates/perry-stdlib/src/fetch/body_metadata.rs @@ -67,6 +67,15 @@ lazy_static::lazy_static! { static ref FORM_DATA_REGISTRY: Mutex> = Mutex::new(HashMap::new()); } +/// #10555: `instanceof FormData` / `Object.prototype.toString.call` / +/// `x[Symbol.toStringTag]` membership probe, mirroring `dispatch.rs`'s +/// `js_fetch_handle_kind` for the other Web Fetch handle kinds. Lives here +/// (not `dispatch.rs`) because `FORM_DATA_REGISTRY` is private to this +/// module; `dispatch.rs` reaches it via `super::body_metadata::…`. +pub(super) fn is_registered_form_data(id: usize) -> bool { + FORM_DATA_REGISTRY.lock().unwrap().contains_key(&id) +} + fn alloc_form_data(store: FormDataStore) -> usize { let id = alloc_fetch_handle_id(); FORM_DATA_REGISTRY.lock().unwrap().insert(id, store); diff --git a/crates/perry-stdlib/src/fetch/dispatch.rs b/crates/perry-stdlib/src/fetch/dispatch.rs index 2fc06a1a7c..f0de1b9aae 100644 --- a/crates/perry-stdlib/src/fetch/dispatch.rs +++ b/crates/perry-stdlib/src/fetch/dispatch.rs @@ -300,10 +300,14 @@ fn form_data_bound_method_value(form_id: usize, method_name: &'static str) -> f6 /// `instanceof` kind-probe for fetch handles (registered with the runtime at /// init via `js_register_fetch_handle_kind_probe`). Returns 0 = none, -/// 1 = Response, 2 = Request, 3 = Headers, 4 = Blob, 5 = File. Lets -/// `x instanceof Response` (etc.) resolve for the pointer-tagged small-integer -/// handles these types use instead of heap objects. Lives here (not `mod.rs`) -/// to keep that file under the 2,000-line lint gate. +/// 1 = Response, 2 = Request, 3 = Headers, 4 = Blob, 5 = File, 6 = FormData. +/// Lets `x instanceof Response` (etc.) resolve for the pointer-tagged +/// small-integer handles these types use instead of heap objects. #10555 +/// additionally reuses this for `Object.prototype.toString` / +/// `Symbol.toStringTag`; FormData (kind 6) is new here -- nothing previously +/// needed to tell it apart from the other fetch-family handles by id alone. +/// Lives here (not `mod.rs`) to keep that file under the 2,000-line lint +/// gate. #[no_mangle] pub extern "C" fn js_fetch_handle_kind(id: usize) -> u8 { if FETCH_RESPONSES.lock().unwrap().contains_key(&id) { @@ -318,6 +322,9 @@ pub extern "C" fn js_fetch_handle_kind(id: usize) -> u8 { if let Some(blob) = BLOB_REGISTRY.lock().unwrap().get(&id) { return if blob.file_name.is_some() { 5 } else { 4 }; } + if super::body_metadata::is_registered_form_data(id) { + return 6; + } 0 } diff --git a/test-files/test_gap_10555_symbol_tostringtag_web_builtins.ts b/test-files/test_gap_10555_symbol_tostringtag_web_builtins.ts new file mode 100644 index 0000000000..0e4ab97eb8 --- /dev/null +++ b/test-files/test_gap_10555_symbol_tostringtag_web_builtins.ts @@ -0,0 +1,60 @@ +// #10555: Web/runtime built-ins lack `Symbol.toStringTag`, so +// `Object.prototype.toString.call(x)` falls through to the generic +// `[object Object]` and `x[Symbol.toStringTag]` reads back `undefined`. +// Covers the full set the issue names plus adjacent built-ins that share the +// same fix shape: URL, URLSearchParams, Headers, Request, Response, +// FormData, Blob, AbortController, AbortSignal, TextEncoder, TextDecoder, +// EventTarget, Event. (Map/Promise/ArrayBuffer/DataView are deliberately out +// of scope -- see the PR body.) + +function describe(name: string, ctor: any, value: unknown): void { + const tagString = Object.prototype.toString.call(value); + const ownTag = String((value as any)[Symbol.toStringTag]); + const desc = Object.getOwnPropertyDescriptor(ctor.prototype, Symbol.toStringTag); + console.log( + name, + tagString, + ownTag, + desc ? desc.value : "MISSING", + desc ? desc.writable : "MISSING", + desc ? desc.enumerable : "MISSING", + desc ? desc.configurable : "MISSING", + ); +} + +describe("URL", URL, new URL("http://x/")); +describe("URLSearchParams", URLSearchParams, new URLSearchParams("a=1")); +describe("Headers", Headers, new Headers()); +describe("Request", Request, new Request("http://x/")); +describe("Response", Response, new Response("x")); +describe("FormData", FormData, new FormData()); +describe("Blob", Blob, new Blob(["a"])); +describe("AbortController", AbortController, new AbortController()); +describe("AbortSignal", AbortSignal, new AbortController().signal); +describe("TextEncoder", TextEncoder, new TextEncoder()); +describe("TextDecoder", TextDecoder, new TextDecoder()); +describe("EventTarget", EventTarget, new EventTarget()); +describe("Event", Event, new Event("x")); + +// The tag must never leak into JSON serialization (symbol keys never +// serialize -- a true invariant, kept here as a non-regression canary). +const u = new URL("http://x/"); +console.log("json:", JSON.stringify({ tag: String(u[Symbol.toStringTag]) })); + +// `typeof` must be unaffected by the new property (a pre-existing, +// unrelated `instanceof` gap for the generic-class-id representations this +// fix's own doc comment describes is out of scope for #10555 -- see the PR +// body). +console.log( + "typeof:", + typeof u, + typeof new Headers(), + typeof new AbortController(), + typeof new EventTarget(), +); + +// The descriptor is non-writable: a direct `Reflect.set` on the prototype +// object itself (no inheritance walk involved) must report failure without +// throwing, and must not change the value. +console.log("reflect-set:", Reflect.set(URL.prototype, Symbol.toStringTag, "Nope")); +console.log("still URL:", String(u[Symbol.toStringTag])); From fb75d92b39083188c5f3739da2e50279960f0cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 14:40:10 +0000 Subject: [PATCH 23/30] docs(changelog): add fragment for #10632 --- changelog.d/10632-symbol-tostringtag.md | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 changelog.d/10632-symbol-tostringtag.md diff --git a/changelog.d/10632-symbol-tostringtag.md b/changelog.d/10632-symbol-tostringtag.md new file mode 100644 index 0000000000..2486c690f8 --- /dev/null +++ b/changelog.d/10632-symbol-tostringtag.md @@ -0,0 +1,47 @@ +### Fixed + +- **Web/runtime built-ins lack `Symbol.toStringTag` (#10555).** + `Object.prototype.toString.call(new URLSearchParams())` was `[object + Object]` instead of `[object URLSearchParams]`, and `x[Symbol.toStringTag]` + read back `undefined` — the standard cross-realm type check `kindOf`, + `isURLSearchParams`, `isFormData`, `isBlob`, and lodash's `baseGetTag` use. + axios 1.19.0 decides how to serialize a request body this way: a + `URLSearchParams` body was sent as JSON instead of + `application/x-www-form-urlencoded`. + + Covers `URL`, `URLSearchParams`, `Headers`, `Request`, `Response`, + `FormData`, `Blob`, `File`, `AbortController`, `AbortSignal`, + `TextEncoder`, `TextDecoder`, `EventTarget`, `Event`, `CustomEvent` — both + the brand string and the real `x[Symbol.toStringTag]` value, plus a + correctly-shaped (`writable: false, enumerable: false, configurable: true`) + own descriptor on each constructor's `.prototype`. `Map`/`Promise`/ + `ArrayBuffer`/`DataView` are deliberately out of scope (their brand string + was already correct via a different, structural mechanism — only their own + property is missing, a separate fix). `Uint8Array` already had a correct + accessor. + + Root cause: two representations, two gaps. The Web Fetch family and + `TextEncoder`/`TextDecoder` are small-integer registry handles with no + brand/property case in `js_object_to_string` or + `js_object_get_symbol_property`. `URL`/`URLSearchParams` and + `AbortController`/`AbortSignal`/`EventTarget`/`Event`/`CustomEvent` are + real objects whose instances are never `[[Prototype]]`-linked to their + `.prototype` object, so a property installed only there (the issue's + suggested shape) would never be reached from an instance. + + Fix: a new `web_builtin_to_string_tag` in `perry-runtime` answers both + `Object.prototype.toString` and `x[Symbol.toStringTag]` from one place + (reusing the existing `fetch_handle_kind_probe`/structural/class-id + detectors), and a real descriptor is *also* installed on each + constructor's `.prototype` for reflection. The directly-affected path + measured ~4.7x fewer instructions, not slower (the new check runs early + and short-circuits several later brand checks a `Headers` handle used to + fall through). + + Validation: new `test_gap_10555_symbol_tostringtag_web_builtins` (brand + string, property value, and full descriptor shape for all 13 types, plus + JSON/typeof/non-writability checks) fails on the baseline and matches Node + on the fix. Two existing unit tests updated (not a regression — the fix + makes `TextEncoder`'s sentinel handle id meaningful, which one test had + assumed was generic); `test_gap_url*`/`test_gap_headers*`/ + `test_gap_fetch*`/`test_gap_events_import_4995` (12 tests) unaffected. From 87e6011a43493df41590e037500c4f12e8c7b160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:18:16 +0000 Subject: [PATCH 24/30] fix(runtime): resolve instanceof against a ClassExprFresh parent per evaluation instanceof's class-chain walk resolved a dynamic parent purely by the shared TEMPLATE class_id, so evaluating a heritage-carrying class expression more than once shadowed an EARLIER evaluation's parent once a LATER evaluation of the same factory ran. Each per-evaluation class object already pins its own heritage (js_class_object_pin_parent, consulted by super() and capture resolution since #9364); instanceof never consulted it. Pin the constructing class object onto each new instance too, and give instanceof a value-aware chain walk that prefers a pinned VALUE at each hop (falling back to the plain class_id registry once no further per-evaluation precision is available). Gated behind a monotone latch armed only when a class object is ever pinned, so the common never-evaluated-twice case pays a single idle-load check. --- .../src/object/class_constructors.rs | 13 ++ .../src/object/class_registry.rs | 7 +- .../class_registry/evaluation_heritage.rs | 72 +++++++ .../evaluation_heritage/tests.rs | 60 ++++++ .../object/class_registry/parent_static.rs | 91 +++++--- .../src/object/field_get_set/enumeration.rs | 2 + crates/perry-runtime/src/object/instanceof.rs | 194 ++++++++++++------ ...624_instanceof_classexprfresh_shared_id.ts | 82 ++++++++ 8 files changed, 423 insertions(+), 98 deletions(-) create mode 100644 test-files/test_gap_10624_instanceof_classexprfresh_shared_id.ts diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index bd930c6ac2..b07f3e93ec 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -1209,6 +1209,19 @@ pub(crate) unsafe fn replay_class_object_constructor( let scope = crate::gc::RuntimeHandleScope::new(); let classobj_handle = scope.root_nanbox_f64(classobj_value); let inst_handle = scope.root_raw_mut_ptr(inst); + // #10624: remember which specific evaluation of this template built + // `inst`, so a later `instanceof` check against it can walk THAT + // evaluation's own pinned heritage instead of the shared, possibly + // since-overwritten class_id registry (`object/instanceof.rs`'s + // `class_chain_reaches_dynamic`). Runs before anything below can + // allocate/collect and return early, so `inst` is pinned regardless of + // which path this replay takes. + inst_handle.with_mut_ptr::(|inst| { + super::class_registry::pin_instance_constructing_class( + inst, + classobj_handle.get_nanbox_f64(), + ); + }); // Spec: a derived class with no own `constructor` gets the implicit // `constructor(...args) { super(...args) }` — the nearest ancestor's ctor // must run with the same argument list. `lookup_class_constructor` holds diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 85557855b5..e43d06b822 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -53,7 +53,8 @@ pub mod decl_prototype_table; mod dispatch; pub(crate) mod evaluation_heritage; pub(crate) use evaluation_heritage::{ - active_class_evaluation_parent, is_self_heritage_value, push_active_class_evaluation, + active_class_evaluation_parent, instance_pinned_constructing_class, is_self_heritage_value, + pin_instance_constructing_class, push_active_class_evaluation, }; mod function_prototype; mod gc_roots; @@ -217,8 +218,8 @@ pub(crate) use parent_static::{ class_own_symbol_method, class_private_instance_getter_value, class_private_instance_setter_apply, class_static_accessor_getter_value, class_static_accessor_setter_apply, class_symbol_getter_value, class_symbol_setter_apply, - get_parent_class_id, lookup_class_symbol_method_in_chain, lookup_static_method_in_chain, - register_class, register_class_dynamic_static_accessor, + dynamic_value_class_id, get_parent_class_id, lookup_class_symbol_method_in_chain, + lookup_static_method_in_chain, register_class, register_class_dynamic_static_accessor, }; pub use parent_static::{ is_class_object_ptr, is_class_object_value, is_registered_class_prototype_object, diff --git a/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs b/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs index d882ba2d72..303b3e5dac 100644 --- a/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs +++ b/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs @@ -152,6 +152,78 @@ pub(crate) fn is_self_heritage_value(class_id: u32, parent_bits: u64) -> bool { parent_bits & 0xFFFF_0000_0000_0000 == INT32_TAG && parent_bits as u32 == class_id } +/// #10624: monotone "has any class object ever pinned its own heritage" +/// flag. `js_class_object_pin_parent` arms it before its own write, so +/// anything it can EVER make true (an instance pinned to its constructing +/// class object, or a class_id that has more than one live per-evaluation +/// parent) is only reachable once this is armed. `instanceof`'s value-aware +/// chain walk (`object/instanceof.rs`'s `class_chain_reaches_dynamic`) is +/// gated on it: the overwhelming majority of programs never evaluate a +/// heritage-carrying class expression more than once, and this keeps that +/// case exactly as cheap as it was before this fix (one relaxed-cost atomic +/// load) instead of paying a table lookup at every hop of every +/// `instanceof` check. See `registry_latch.rs`. +pub(crate) static CLASS_OBJECT_HERITAGE_PIN_LATCH: crate::registry_latch::RegistryLatch = + crate::registry_latch::RegistryLatch::new(); + +/// Own-property key under which a genuine INSTANCE (constructed via +/// `new ()`) remembers which SPECIFIC evaluation +/// built it (#10624). +/// +/// `js_class_object_pin_parent`'s pin lives on the CLASS OBJECT and answers +/// "what is MY parent" — `super()`, the prototype chain, and capture +/// resolution above all already consult it. Nothing, though, gave the +/// resulting INSTANCE a way back to that same evaluation: an instance +/// carries only its class's shared TEMPLATE `class_id` +/// (`ObjectHeader.class_id`), identical for every evaluation of the same +/// factory. `instanceof`'s class-chain walk therefore fell back to +/// `CLASS_REGISTRY`/`get_parent_class_id` — the same last-write-wins table +/// `super()` used to read before #9364 — so an instance built from an +/// EARLIER evaluation, checked after a LATER evaluation of the same +/// template has run, could construct correctly (via the class-object pin +/// above) yet fail `instanceof` against its own true parent (the later +/// evaluation's parent shadows it in that shared table). +/// +/// Pinning the constructing class object onto the instance too closes that +/// gap: `object/instanceof.rs`'s `class_chain_reaches_dynamic` walks from +/// THIS value, following the exact same per-evaluation pin chain +/// `pinned_class_object_for_ancestor` already walks for capture resolution, +/// instead of the shared class_id table. +pub(crate) const INSTANCE_CONSTRUCTING_CLASS_KEY: &str = "__perry_ctor_class_object"; + +/// Pin the per-evaluation class OBJECT that is about to construct `inst` +/// onto `inst` itself (#10624). A no-op when `classobj_value` is not itself +/// a per-evaluation class object, or carries no heritage of its own to +/// disambiguate — an ordinary class DECLARATION (or a heritage-less class +/// expression) has none of the ambiguity this exists to resolve, and the +/// plain class_id registry is already exact for those. +pub(crate) fn pin_instance_constructing_class(inst: *mut ObjectHeader, classobj_value: f64) { + if inst.is_null() || !is_class_object_value(classobj_value) { + return; + } + let class_ptr = crate::value::js_nanbox_get_pointer(classobj_value) as *const ObjectHeader; + if class_ptr.is_null() || class_object_pinned_parent(class_ptr).is_none() { + return; + } + // `js_class_object_pin_parent` already armed `CLASS_OBJECT_HERITAGE_PIN_LATCH` + // before writing `class_ptr`'s own pin above (the ordering rule in + // `registry_latch.rs`) — that write happens-before this one in this + // thread's program order, so the latch is already armed here. + let key_bytes = INSTANCE_CONSTRUCTING_CLASS_KEY.as_bytes(); + let key = crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32); + crate::object::js_object_set_field_by_name(inst, key, classobj_value); +} + +/// Read back the pin [`pin_instance_constructing_class`] wrote, or `None` +/// when `obj` was never pinned — including the common case where the latch +/// alone already answers "no" without scanning `obj`'s own fields at all. +pub(crate) fn instance_pinned_constructing_class(obj: *const ObjectHeader) -> Option { + if CLASS_OBJECT_HERITAGE_PIN_LATCH.is_idle() { + return None; + } + class_object_own_field_bytes(obj, INSTANCE_CONSTRUCTING_CLASS_KEY.as_bytes()) +} + #[cfg(test)] #[path = "evaluation_heritage/tests.rs"] mod tests; diff --git a/crates/perry-runtime/src/object/class_registry/evaluation_heritage/tests.rs b/crates/perry-runtime/src/object/class_registry/evaluation_heritage/tests.rs index 9be1c09723..8fe6dc2edf 100644 --- a/crates/perry-runtime/src/object/class_registry/evaluation_heritage/tests.rs +++ b/crates/perry-runtime/src/object/class_registry/evaluation_heritage/tests.rs @@ -176,3 +176,63 @@ fn an_active_replay_does_not_answer_for_another_class_id() { ); } } + +#[test] +fn sibling_class_objects_of_the_same_template_keep_distinct_pins() { + let _lock = crate::gc::global_side_table_test_lock(); + const TEMPLATE: u32 = 0x0936_40C0; + const FIRST_PARENT: u32 = 0x0936_40C1; + const LAST_PARENT: u32 = 0x0936_40C2; + register(TEMPLATE); + register(FIRST_PARENT); + register(LAST_PARENT); + + let scope = crate::gc::RuntimeHandleScope::new(); + + // First evaluation: pins FIRST_PARENT onto its own class object. + let first_handle = scope.root_raw_mut_ptr(crate::object::js_object_alloc(TEMPLATE, 0)); + first_handle.with_mut_ptr::(|class| { + crate::object::class_registry::js_object_mark_class(class as i64) + }); + js_register_class_parent_dynamic(TEMPLATE, class_ref(FIRST_PARENT)); + first_handle.with_mut_ptr::(|class| { + super::super::parent_static::js_class_object_pin_parent(class as i64, TEMPLATE) + }); + let first_pin = first_handle.with_mut_ptr::(|class| { + super::super::parent_static::class_object_pinned_parent(class as *const crate::ObjectHeader) + }); + assert_eq!( + first_pin.map(|v| v.to_bits()), + Some(class_ref(FIRST_PARENT).to_bits()), + "first evaluation's own pin must be readable immediately after being written", + ); + + // Second evaluation of the SAME template: pins LAST_PARENT onto a + // DIFFERENT class object, overwriting the shared CLASS_DYNAMIC_PARENT_VALUE + // stash for TEMPLATE. + let last_handle = scope.root_raw_mut_ptr(crate::object::js_object_alloc(TEMPLATE, 0)); + last_handle.with_mut_ptr::(|class| { + crate::object::class_registry::js_object_mark_class(class as i64) + }); + js_register_class_parent_dynamic(TEMPLATE, class_ref(LAST_PARENT)); + last_handle.with_mut_ptr::(|class| { + super::super::parent_static::js_class_object_pin_parent(class as i64, TEMPLATE) + }); + + // The EARLIER evaluation's own pin must be UNCHANGED by the later one. + let first_pin_again = first_handle.with_mut_ptr::(|class| { + super::super::parent_static::class_object_pinned_parent(class as *const crate::ObjectHeader) + }); + assert_eq!( + first_pin_again.map(|v| v.to_bits()), + Some(class_ref(FIRST_PARENT).to_bits()), + "an earlier evaluation's pin must survive a LATER sibling evaluation's pin write", + ); + let last_pin = last_handle.with_mut_ptr::(|class| { + super::super::parent_static::class_object_pinned_parent(class as *const crate::ObjectHeader) + }); + assert_eq!( + last_pin.map(|v| v.to_bits()), + Some(class_ref(LAST_PARENT).to_bits()), + ); +} diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index b4813599ad..bedfd46ad9 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -66,6 +66,57 @@ pub extern "C" fn js_register_class_parent(class_id: u32, parent_class_id: u32) } } +/// Resolve a class_id from an arbitrary NaN-boxed runtime VALUE: an INT32 +/// `ClassRef` (the payload IS the class_id, verified registered) or a +/// POINTER-tagged object (its `ObjectHeader.class_id`, falling back to the +/// synthetic class id a plain closure's reassigned `.prototype` was given). +/// `0` for anything else (primitives, an unregistered closure, `undefined`, +/// `null`) — "no answer", never a wrong one. +/// +/// Shared by `js_register_class_parent_dynamic` (deriving the class_id to +/// register a NEW parent edge) and `object/instanceof.rs`'s +/// `class_chain_reaches_dynamic` (#10624, walking an EXISTING +/// per-evaluation pin chain) — both need the identical "what class_id does +/// this value denote" answer. +pub(crate) fn dynamic_value_class_id(value: f64) -> u32 { + let bits = value.to_bits(); + const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; + const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; + let tag = bits & 0xFFFF_0000_0000_0000; + if tag == INT32_TAG { + // ClassRef: lower 32 bits are the class id. Verify it's + // actually a registered class id before trusting it. + let payload = bits as u32; + if payload == 0 { + 0 + } else { + let guard = REGISTERED_CLASS_IDS.read().unwrap(); + match guard.as_ref() { + Some(set) if set.contains(&payload) => payload, + _ => 0, + } + } + } else if tag == POINTER_TAG { + // Object instance: read class_id from the ObjectHeader. + let ptr = crate::value::js_nanbox_get_pointer(value) as *const ObjectHeader; + let from_obj = js_object_get_class_id(ptr); + if from_obj != 0 { + from_obj + } else { + // Issue #711 part 2: the value might be a closure whose + // `.prototype` was assigned to an object via the + // `function Base() {}; Base.prototype = X` pattern. Look + // up the synthetic class id assigned at + // `js_set_function_prototype` time. Returns 0 if the + // closure has no registered prototype object — falls + // through to the parentless baseline. + function_class_id(value) + } + } else { + 0 + } +} + /// Issue #711: dynamic parent-class registration for /// `class X extends fn(...)` shapes where the parent class_id is only /// known at runtime. Called from codegen-emitted module-init code at @@ -239,41 +290,9 @@ pub extern "C" fn js_register_class_parent_dynamic(class_id: u32, mut parent_val let bits = parent_value.to_bits(); let tag = bits & 0xFFFF_0000_0000_0000; - const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; - let parent_cid: u32 = if tag == INT32_TAG { - // ClassRef: lower 32 bits are the class id. Verify it's - // actually a registered class id before trusting it. - let payload = bits as u32; - if payload == 0 { - 0 - } else { - let guard = REGISTERED_CLASS_IDS.read().unwrap(); - match guard.as_ref() { - Some(set) if set.contains(&payload) => payload, - _ => 0, - } - } - } else if tag == POINTER_TAG { - // Object instance: read class_id from the ObjectHeader. - let ptr = crate::value::js_nanbox_get_pointer(parent_value) as *const ObjectHeader; - let from_obj = js_object_get_class_id(ptr); - if from_obj != 0 { - from_obj - } else { - // Issue #711 part 2: the value might be a closure whose - // `.prototype` was assigned to an object via the - // `function Base() {}; Base.prototype = X` pattern. Look - // up the synthetic class id assigned at - // `js_set_function_prototype` time. Returns 0 if the - // closure has no registered prototype object — falls - // through to the parentless baseline. - function_class_id(parent_value) - } - } else { - 0 - }; + let parent_cid: u32 = dynamic_value_class_id(parent_value); if parent_cid != 0 && parent_cid != class_id { register_class(class_id, parent_cid); @@ -355,6 +374,12 @@ pub extern "C" fn js_class_object_pin_parent(obj: i64, template_class_id: u32) { if parent.to_bits() == TAG_UNDEFINED { return; } + // #10624: arm BEFORE the write it advertises (the ordering rule in + // `registry_latch.rs`) — everything the latch gates (this own-property + // write, and `pin_instance_constructing_class`'s later instance pin, + // which never fires without this one already having happened) follows + // in this thread's program order. + super::evaluation_heritage::CLASS_OBJECT_HERITAGE_PIN_LATCH.arm(); let key_bytes = CLASS_OBJECT_PARENT_KEY.as_bytes(); let key = crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32); crate::object::js_object_set_field_by_name( diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index 246fc28b84..393fa36466 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -1651,6 +1651,8 @@ pub(crate) fn is_internal_runtime_key_bytes(b: &[u8]) -> bool { b == crate::object::map_set_subclass::BACKING_KEY || b == crate::weakref::WEAK_ENTRIES_KEY || b == crate::object::parent_static::CLASS_OBJECT_PARENT_KEY.as_bytes() + || b == crate::object::class_registry::evaluation_heritage::INSTANCE_CONSTRUCTING_CLASS_KEY + .as_bytes() || b == b"__perry_ctor_caps" || is_class_capture_key(b) || b.starts_with(crate::node_stream::NATIVE_BASE_SUPER_PREFIX) diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index da9b4ba13b..3802394625 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -1036,6 +1036,135 @@ fn class_chain_reaches_parents_only(start: u32, want: u32, depth0: usize) -> boo false } +/// #10624: `subclass_of_builtin_reaches`'s armed-latch arm. +fn class_chain_reaches_dynamic_armed(cur: u32, obj: *const ObjectHeader, want: u32) -> bool { + let pin = super::class_registry::instance_pinned_constructing_class(obj); + class_chain_reaches_dynamic(cur, pin, want) +} + +/// Does the ancestry chain from `start_cid` reach `want`, walking by VALUE +/// while precision is available? `class_chain_reaches` walks purely by +/// class_id through the shared, last-write-wins `CLASS_REGISTRY` — +/// ambiguous once the SAME `ClassExprFresh` template has been evaluated more +/// than once. Each hop here instead prefers, in order: (1) `start_pin`/a +/// pinned VALUE on the current node (`class_object_pinned_parent`, the same +/// per-evaluation edge `super()`/captures already consult), (2) +/// `template_dynamic_parent_value`, the actual parent VALUE for any class_id +/// registered dynamically. Exhausting both degrades to exactly +/// `class_chain_reaches`'s answer — so an instance from an EARLIER +/// evaluation stays correct even after a LATER one overwrote the table. +fn class_chain_reaches_dynamic(start_cid: u32, start_pin: Option, want: u32) -> bool { + const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + if start_cid == 0 || want == 0 { + return false; + } + let mut cur = start_cid; + let mut cur_value = start_pin; + let mut depth = 0usize; + loop { + if cur == want { + return true; + } + if depth > 64 { + return false; + } + if let Some(gid) = crate::object::class_generic_origin(cur) { + if gid == want || class_chain_reaches_parents_only(gid, want, depth + 1) { + return true; + } + } + let pinned = cur_value + .filter(|v| is_class_object_value(*v)) + .and_then(|v| { + class_object_pinned_parent( + crate::value::js_nanbox_get_pointer(v) as *const ObjectHeader + ) + }); + let next_value = pinned.unwrap_or_else(|| { + super::class_registry::parent_static::template_dynamic_parent_value(cur) + }); + if next_value.to_bits() == TAG_UNDEFINED { + return false; + } + let next_cid = dynamic_value_class_id(next_value); + if next_cid == 0 || next_cid == cur { + return false; + } + cur = next_cid; + cur_value = Some(next_value); + depth += 1; + } +} + +/// `class S extends Array {}` produces a real `ObjectHeader` instance whose +/// class-id chain reaches the built-in's reserved class id (a parent edge +/// registered at module init). The per-built-in probes in `js_instanceof` +/// short-circuit to `false` for such an instance (it isn't a *real* +/// Array/Map/Error/…), so walk the object's own class chain up front. Only +/// genuine `GC_TYPE_OBJECT` instances carry a `class_id` field. Refs +/// class/subclass-builtins/* and class/subclass/builtin-objects/*. +/// +/// Split out of `js_instanceof` (#10624) so that function's own size, and +/// thus how well its unrelated, far more common paths optimize, does not +/// depend on this ladder's own latch-gated logic. +fn subclass_of_builtin_reaches(value: f64, class_id: u32) -> bool { + let jv = crate::JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return false; + } + let obj = jv.as_pointer::(); + if !crate::value::addr_class::is_above_handle_band(obj as usize) { + return false; + } + let gc_header = + unsafe { (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader }; + if unsafe { (*gc_header).obj_type } != crate::gc::GC_TYPE_OBJECT { + return false; + } + let cur = unsafe { (*obj).class_id }; + // #10624: only pay for the value-aware walk once something has pinned + // per-evaluation heritage. + let reaches = + if super::class_registry::evaluation_heritage::CLASS_OBJECT_HERITAGE_PIN_LATCH.is_idle() { + class_chain_reaches(cur, class_id) + } else { + class_chain_reaches_dynamic_armed(cur, obj, class_id) + }; + if reaches { + return true; + } + + // #9362: util.inherits(DerivedClass, BaseClass) links DerivedClass.prototype + // to BaseClass.prototype at runtime; it does not (and must not) create an + // extends edge between the constructor objects. The class-id fast path + // above therefore misses even though the observable prototype chain + // contains BaseClass.prototype. Only pay for the spec prototype walk when + // the candidate class's declaration prototype has a user-selected parent. + // The two `class_decl_prototype_object` probes are class registry reads + // (TLS + RwLock + map, ~130 instructions each) and they ran EAGERLY on + // every call that got this far — which is every MISS, the path this whole + // ladder exists to answer `false` on. They exist only to ask a question + // whose answer is `false` for every receiver in a process that never + // re-points an object's prototype, and the latch answers that for the + // whole process in one load. Set, never cleared, and published before the + // flag it guards, so it can only ever be conservatively true. + if super::prototype_chain::any_user_prototype_override() { + let candidate_proto = super::class_registry::class_decl_prototype_object(cur); + let target_proto = super::class_registry::class_decl_prototype_object(class_id); + if !candidate_proto.is_null() + && !target_proto.is_null() + && super::prototype_chain::object_has_user_prototype_override(candidate_proto as usize) + && ordinary_has_instance_prototype_walk( + value, + super::class_constructor_ref_value(class_id), + ) + { + return true; + } + } + false +} + /// Check if a value is an instance of a class with the given class_id /// Walks the inheritance chain to check parent classes /// Returns NaN-boxed TAG_TRUE / TAG_FALSE so the result identifies as a boolean. @@ -1112,68 +1241,9 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { } } - // Subclass-of-built-in: `class S extends Array {}` produces a real - // ObjectHeader instance whose class-id chain reaches the built-in's - // reserved class id (a parent edge registered at module init). The - // per-built-in probes below short-circuit to `false` for such an - // instance (it isn't a *real* Array/Map/Error/…), so walk the object's - // own class chain up front. Only genuine `GC_TYPE_OBJECT` instances carry - // a `class_id` field — real Arrays/Maps/Errors have other GC types and - // fall through to their dedicated probes unchanged. Refs - // class/subclass-builtins/* and class/subclass/builtin-objects/*. - { - let jv = crate::JSValue::from_bits(value.to_bits()); - if jv.is_pointer() { - let obj = jv.as_pointer::(); - if crate::value::addr_class::is_above_handle_band(obj as usize) { - let gc_header = unsafe { - (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader - }; - if unsafe { (*gc_header).obj_type } == crate::gc::GC_TYPE_OBJECT { - let cur = unsafe { (*obj).class_id }; - if class_chain_reaches(cur, class_id) { - return true_val; - } - - // #9362: util.inherits(DerivedClass, BaseClass) links - // DerivedClass.prototype to BaseClass.prototype at - // runtime; it does not (and must not) create an extends - // edge between the constructor objects. The class-id fast - // path above therefore misses even though the observable - // prototype chain contains BaseClass.prototype. Only pay - // for the spec prototype walk when the candidate class's - // declaration prototype has a user-selected parent. - // The two `class_decl_prototype_object` probes are class - // registry reads (TLS + RwLock + map, ~130 instructions - // each) and they ran EAGERLY on every call that got this - // far — which is every MISS, the path this whole ladder - // exists to answer `false` on. They exist only to ask a - // question whose answer is `false` for every receiver in a - // process that never re-points an object's prototype, and - // the latch answers that for the whole process in one - // load. Set, never cleared, and published before the flag - // it guards, so it can only ever be conservatively true. - if super::prototype_chain::any_user_prototype_override() { - let candidate_proto = - super::class_registry::class_decl_prototype_object(cur); - let target_proto = - super::class_registry::class_decl_prototype_object(class_id); - if !candidate_proto.is_null() - && !target_proto.is_null() - && super::prototype_chain::object_has_user_prototype_override( - candidate_proto as usize, - ) - && ordinary_has_instance_prototype_walk( - value, - super::class_constructor_ref_value(class_id), - ) - { - return true_val; - } - } - } - } - } + // Subclass-of-built-in: see `subclass_of_builtin_reaches`. + if subclass_of_builtin_reaches(value, class_id) { + return true_val; } // Temporal reference types (`d instanceof Temporal.Duration`, …). A Temporal // value is a NaN-boxed pointer to a brand-tagged cell, not an ObjectHeader diff --git a/test-files/test_gap_10624_instanceof_classexprfresh_shared_id.ts b/test-files/test_gap_10624_instanceof_classexprfresh_shared_id.ts new file mode 100644 index 0000000000..a76d8688d4 --- /dev/null +++ b/test-files/test_gap_10624_instanceof_classexprfresh_shared_id.ts @@ -0,0 +1,82 @@ +// Gap test for #10624: `instanceof` against a `ClassExprFresh` parent must +// not resolve by shared (template) class id. Each per-evaluation class +// object's OWN pinned heritage must be honored even after a LATER +// evaluation of the same factory has overwritten the shared last-write-wins +// slot that `instanceof`'s class-chain walk otherwise reads. + +function extend(Base: any, tag: string) { + return class extends Base { + getTag() { + return tag; + } + }; +} + +class Root { + kind() { + return "root"; + } +} +class RootAlt { + kind() { + return "alt"; + } +} + +// Same call site invoked twice (a loop), so both evaluations share Perry's +// internal template class id - the shape #10624 is about. +const bases = [Root, RootAlt]; +const tags = ["r1", "r2"]; +const evaluations: any[] = []; +for (let i = 0; i < bases.length; i++) { + evaluations.push(extend(bases[i], tags[i])); +} +const A = evaluations[0]; // extends Root +const B = evaluations[1]; // extends RootAlt (later eval; overwrites the shared dynamic-parent slot) + +// Construction order interleaved: build from the EARLIER evaluation (A) +// *after* the LATER evaluation (B) has already run. +const earlyInstance = new A(); +console.log("earlyInstance instanceof Root:", earlyInstance instanceof Root); +console.log("earlyInstance instanceof RootAlt:", earlyInstance instanceof RootAlt); +console.log("earlyInstance instanceof A:", earlyInstance instanceof A); +console.log("earlyInstance instanceof B:", earlyInstance instanceof B); +console.log("earlyInstance.getTag():", earlyInstance.getTag()); + +const lateInstance = new B(); +console.log("lateInstance instanceof Root:", lateInstance instanceof Root); +console.log("lateInstance instanceof RootAlt:", lateInstance instanceof RootAlt); +console.log("lateInstance.getTag():", lateInstance.getTag()); + +// instanceof in both directions, re-checked after more evaluations ran. +console.log("earlyInstance instanceof RootAlt (again):", earlyInstance instanceof RootAlt); +console.log("lateInstance instanceof Root (again):", lateInstance instanceof Root); + +// A THIRD evaluation, constructed immediately (control - the "latest" +// evaluation was never the stale case, so this must always have worked). +const C = extend(Root, "r3"); +const freshInstance = new C(); +console.log("freshInstance instanceof Root:", freshInstance instanceof Root); +console.log("freshInstance instanceof RootAlt:", freshInstance instanceof RootAlt); + +// Two-level subclass: a SECOND dynamic factory evaluated against a specific +// evaluation of the FIRST one (A, not B), checked after yet another +// evaluation of the first factory has run and overwritten its shared slot +// again. Checked against Root/RootAlt only (distinct classes, distinct +// class ids) - not against A/D directly, which exercises a separate, +// pre-existing limitation (instanceof against a *specific* sibling +// evaluation of the same template, referenced directly as the RHS, is not +// this issue's mechanism). +function extendAgain(Base: any, mark: string) { + return class extends Base { + extra() { + return mark; + } + }; +} +const G = extendAgain(A, "grandchild"); // extends A specifically, i.e. transitively Root +const D = extend(RootAlt, "r4"); // yet another eval of `extend` - overwrites its shared slot again +const grandchildInstance = new G(); +console.log("grandchildInstance instanceof Root:", grandchildInstance instanceof Root); +console.log("grandchildInstance instanceof RootAlt:", grandchildInstance instanceof RootAlt); +console.log("grandchildInstance.extra():", grandchildInstance.extra()); From c457de9acabb88ea844728486b3138a27a1e3281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:20:01 +0000 Subject: [PATCH 25/30] changelog: add fragment for #10640 --- ...0640-instanceof-classexprfresh-shared-id.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 changelog.d/10640-instanceof-classexprfresh-shared-id.md diff --git a/changelog.d/10640-instanceof-classexprfresh-shared-id.md b/changelog.d/10640-instanceof-classexprfresh-shared-id.md new file mode 100644 index 0000000000..8aa6dc9ede --- /dev/null +++ b/changelog.d/10640-instanceof-classexprfresh-shared-id.md @@ -0,0 +1,18 @@ +### Fixed + +- `instanceof` against a `ClassExprFresh` parent (a heritage-carrying class + expression — captures, statics, private elements, or a self-binding) + resolved the dynamic parent by the SHARED template `class_id` rather than + per evaluation. An instance built from an EARLIER evaluation of a + repeatedly-evaluated factory, constructed after a LATER evaluation of the + same factory had run, constructed with the correct parent (a prior fix, + #9364/#6438, already gives each evaluation its own pinned heritage for + `super()`/capture resolution) but could fail `instanceof` against its own + true parent — the later evaluation's parent shadowed it in the shared, + last-write-wins `CLASS_REGISTRY` that `instanceof`'s class-chain walk read. + Fixed by pinning the constructing class object onto each new instance too + (`class_registry/evaluation_heritage.rs`) and giving `instanceof`'s chain + walk a value-aware path (`instanceof.rs`'s `class_chain_reaches_dynamic`) + that prefers a pinned VALUE at each hop over the shared class_id table, + gated behind a monotone latch so the common (never-evaluated-twice) case + is unaffected. Runtime-only; no codegen changes. (#10624) From 05e7a3fa86c8a456025910167382e321758dcbfc Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 18:01:19 +0000 Subject: [PATCH 26/30] refactor(runtime): split instanceof.rs for the file-size cap Post-rebase onto current main, instanceof.rs (with #10624's own subclass_of_builtin_reaches / class_chain_reaches_dynamic additions) sits at 2028 lines, over the 2000-line cap check_file_size.sh enforces. Move the two `#[no_mangle]` dispatch entry points -- js_instanceof_dynamic and js_instanceof -- verbatim into instanceof/dynamic_dispatch.rs and instanceof/static_dispatch.rs, following the class_registry.rs `.rs` + `/` split pattern already used in this crate. Each new file pulls in every helper it needs via `use super::*;`, same as every other submodule under object/. Pure relocation: no behaviour change, no reordering of logic. instanceof.rs: 907 lines. instanceof/dynamic_dispatch.rs: 406 lines. instanceof/static_dispatch.rs: 738 lines. --- crates/perry-runtime/src/object/instanceof.rs | 1133 +---------------- .../src/object/instanceof/dynamic_dispatch.rs | 406 ++++++ .../src/object/instanceof/static_dispatch.rs | 738 +++++++++++ 3 files changed, 1150 insertions(+), 1127 deletions(-) create mode 100644 crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs create mode 100644 crates/perry-runtime/src/object/instanceof/static_dispatch.rs diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 3802394625..525ee24b15 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -18,6 +18,12 @@ const CLASS_ID_CRYPTO_KEY: u32 = 0xFFFF00C2; /// `value instanceof Function` reserved id (see `js_instanceof`). const CLASS_ID_FUNCTION: u32 = 0xFFFF00F0; +mod dynamic_dispatch; +mod static_dispatch; + +pub use dynamic_dispatch::js_instanceof_dynamic; +pub use static_dispatch::js_instanceof; + /// Whether `value` is callable — the predicate behind `x instanceof Function` /// and `Function[Symbol.hasInstance]`. Covers every Perry function /// representation: heap closures (declarations / expressions / arrows / @@ -169,403 +175,6 @@ fn builtin_ctor_class_id_from_value(type_ref: f64) -> Option { Some(class_id) } -#[no_mangle] -pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { - const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; - // `proxy instanceof C` uses the proxy's `[[GetPrototypeOf]]`, which (absent a - // trap) forwards to the target — so it is equivalent to `target instanceof - // C`. The proxy itself is a small registered id with no class chain, so - // without this it always returned false. Unwrap nested proxies (drizzle - // aliases columns as `new Proxy(column, …)` and its `is(value, type)` brand - // check relies on `value instanceof type`). Bounded to guard a cycle. - let mut value = value; - { - let mut depth = 0; - while depth < 16 && crate::proxy::js_proxy_is_proxy(value) != 0 { - value = crate::proxy::js_proxy_target(value); - depth += 1; - } - } - // `temporalValue instanceof Temporal.` — Temporal values dispatch via - // brand arms (not a real prototype chain), so resolve the constructor to - // its kind and compare against the value's brand. A non-Temporal value, or - // a Temporal value of a different kind, yields `false`. - if let Some(kind) = super::global_this::temporal_ctor_kind(type_ref) { - if crate::temporal::temporal_kind(value) == Some(kind) { - return f64::from_bits(crate::value::TAG_TRUE); - } - // `class X extends Temporal.` instance: a plain heap object whose - // [[Prototype]] chain reaches `Temporal..prototype`. It carries - // the brand via a stashed cell rather than the Temporal-cell tag, so - // recover that cell and compare its kind. The receiver reaches here both - // NaN-boxed (top16 == 0x7FFD) and as a raw-I64 heap pointer (top16 == 0, - // how module-level object vars are stored) — accept both. (#5587) - #[cfg(feature = "temporal")] - { - let bits = value.to_bits(); - let top16 = bits >> 48; - let raw = if top16 == 0x7FFD { - (bits & crate::value::POINTER_MASK) as usize - } else if top16 == 0 { - bits as usize - } else { - 0 - }; - if raw != 0 { - if let Some(cell) = unsafe { crate::object::temporal_subclass_cell(raw) } { - if crate::temporal::temporal_kind(cell) == Some(kind) { - return f64::from_bits(crate::value::TAG_TRUE); - } - } - } - } - return f64::from_bits(TAG_FALSE); - } - // Spec step (InstanceofOperator): an OWN user-defined `@@hasInstance` - // overrides even native constructor brand checks. The native generic hook - // lives on Function.prototype, so the own-property gate distinguishes an - // explicit override from that inherited default without recursion. - { - let hi_sym = crate::symbol::well_known_symbol("hasInstance"); - if !hi_sym.is_null() { - let hi_f64 = f64::from_bits(crate::value::JSValue::pointer(hi_sym as *const u8).bits()); - if unsafe { crate::symbol::js_object_has_own_symbol(type_ref, hi_f64) } { - let cb = unsafe { crate::symbol::js_object_get_symbol_property(type_ref, hi_f64) }; - if let HasInstanceOutcome::Result(result) = dispatch_own_has_instance(cb, value) { - return result; - } - } - } - } - // Native http(s).Agent handles have no heap prototype chain. After any own - // override above has had first refusal, retain their native brand check. - if let Some((module, method)) = unsafe { bound_native_callable_module_and_method(type_ref) } { - if matches!(module.as_str(), "http" | "https") && method == "Agent" { - let matched = small_native_handle_id(value) - .zip(crate::object::http_agent_handle_probe()) - .is_some_and(|(handle, probe)| unsafe { probe(handle) }); - return f64::from_bits(if matched { - crate::value::TAG_TRUE - } else { - TAG_FALSE - }); - } - } - let bits = type_ref.to_bits(); - // `class_ref_id` requires `is_class_id_registered`, not just the tag — - // a user-crafted NaN payload sharing the 0x7FFE band (a real JS number - // constructed via `DataView.setFloat64`, not a codegen-emitted class - // ref) must fall through to the unresolved-RHS `TypeError` below - // instead of being dispatched into `js_instanceof` as a bogus class id. - if let Some(class_id) = class_ref_id(type_ref) { - return js_instanceof(value, class_id); - } - // #9502: a heap class object's template id identifies its code, not its - // evaluation. Compare the actual prototype objects so sibling evaluations - // remain distinct and a chain through earlier evaluations still matches. - if is_class_object_value(type_ref) { - // Static/forward `new C()` sites can still construct by template id - // without attaching an evaluated prototype. Retain that representation's - // class-id check; recorded individual chains are authoritative. - if !super::prototype_chain::object_has_prototype_divergence(value_addr(value)) { - let obj = crate::JSValue::from_bits(bits).as_pointer::(); - return js_instanceof(value, js_object_get_class_id(obj)); - } - return f64::from_bits(if ordinary_has_instance_prototype_walk(value, type_ref) { - crate::value::TAG_TRUE - } else { - TAG_FALSE - }); - } - // A builtin constructor held in a VARIABLE — `const RS = ReadableStream; body - // instanceof RS` — arrives here as the ClosureHeader-backed function installed - // on `globalThis`, so none of the class-id paths above match and the prototype - // walk below returns false. Codegen only special-cases the *static identifier* - // form (`body instanceof ReadableStream`), where it hands the builtin class id - // straight to `js_instanceof`, which brand-checks these natively-backed values - // via the stream / fetch kind probes (their instances are handles, not heap - // objects with a real prototype chain). - // - // Minified bundles almost always alias constructors into locals, so the - // variable form is the common one in the wild: `x instanceof ` for - // ReadableStream / Response / Headers silently returned `false` while Node - // returns `true`. That made a large esbuild-bundled CLI app mis-detect its - // `fetch()` body, throw "The first argument must be a Readable, a - // ReadableStream, or an async iterable", and abort its background - // tar-stream downloads entirely. - // - // Recover the builtin's name from the constructor closure (recorded by - // `set_bound_native_closure_name` when globalThis is populated) and reuse the - // static path's class id, so both spellings agree. - if let Some(class_id) = builtin_ctor_class_id_from_value(type_ref) { - return js_instanceof(value, class_id); - } - // #6558: `e instanceof WebAssembly.CompileError` (and LinkError / - // RuntimeError). These constructors live on the WebAssembly NAMESPACE — - // not on `globalThis`, so the builtin-name path above never resolves - // them — and their instances are ErrorHeader-backed values with no - // prototype chain reaching the namespace ctor's `.prototype`, so the - // ordinary prototype walk below can't brand them either. Identify the - // ctor by its dedicated thunk func_ptr (GC-move-safe) and brand-check - // the instance by its error `.name`. - if let Some(matches) = super::global_this::webassembly_error_ctor_instanceof(value, type_ref) { - return f64::from_bits(if matches { - crate::value::TAG_TRUE - } else { - crate::value::TAG_FALSE - }); - } - // #6558 sibling: `mod instanceof WebAssembly.Module` for the wasm-host - // module wrapper. Its `[[Prototype]]` does not reach the namespace ctor's - // `.prototype`, so brand-check its GC-aware internal wrapper identity. - // Only a positive match short-circuits here; a miss returns `None` so the - // value still flows to the prototype walk below (how `WebAssembly.Memory` - // instances resolve, and how a foreign object answers `false`). - if let Some(true) = super::global_this::webassembly_value_ctor_instanceof(value, type_ref) { - return f64::from_bits(crate::value::TAG_TRUE); - } - if let Some((module, method)) = unsafe { bound_native_callable_module_and_method(type_ref) } { - if module == "stream" - && matches!( - method.as_str(), - "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" | "Stream" - ) - && (crate::node_stream::is_classic_stream_instance_of(value, method.as_str()) - || super::tls_constructor_prototype_is_instance_of(value, method.as_str())) - { - return f64::from_bits(crate::value::TAG_TRUE); - } - if module == "events" && method == "EventEmitter" { - // #10556: a genuine subclass instance (`class Sub extends - // EventEmitter {}`) is a real ObjectHeader carrying Sub's own - // class id, not a handle and not prototype-linked to the real - // `EventEmitter.prototype` — so it is invisible to the - // handle/prototype probes below. Delegate to the static path - // first: `js_instanceof` walks the class-chain parent edge that - // codegen registers for `extends EventEmitter` - // (`builtin_parent_reserved_class_id` in - // perry-codegen/src/expr/instance_misc1.rs), and its own - // `CLASS_ID_EVENT_EMITTER` branch already covers the direct - // handle/`util.inherits` cases. Keep the general prototype walk - // as a fallback for shapes neither path reaches. - return f64::from_bits( - if js_instanceof(value, CLASS_ID_EVENT_EMITTER).to_bits() == crate::value::TAG_TRUE - || ordinary_has_instance_prototype_walk(value, type_ref) - { - crate::value::TAG_TRUE - } else { - TAG_FALSE - }, - ); - } - if module == "events" - && method == "EventEmitterAsyncResource" - && is_event_emitter_async_resource_instance_value(value) - { - return f64::from_bits(crate::value::TAG_TRUE); - } - if module == "async_hooks" - && matches!(method.as_str(), "AsyncLocalStorage" | "AsyncResource") - { - let raw = value_addr(value); - let matched = if method == "AsyncResource" { - crate::async_hooks::resolve_async_resource_handle(raw as i64).is_some() - || (crate::value::addr_class::is_plausible_heap_addr(raw) - && ordinary_has_instance_prototype_walk(value, type_ref)) - } else { - let candidate = small_native_handle_id(value).unwrap_or(raw as i64); - let native = (candidate != 0) && { - super::class_handles::handle_property_dispatch().is_some_and(|dispatch| { - let property = b"getStore"; - let result = - unsafe { dispatch(candidate, property.as_ptr(), property.len()) }; - value_is_callable(result) - }) - }; - native - || (crate::value::addr_class::is_plausible_heap_addr(raw) - && ordinary_has_instance_prototype_walk(value, type_ref)) - }; - return f64::from_bits(if matched { - crate::value::TAG_TRUE - } else { - TAG_FALSE - }); - } - if module == "tty" - && matches!(method.as_str(), "ReadStream" | "WriteStream") - && crate::tty::is_tty_stream_instance(value, method.as_str()) - { - return f64::from_bits(crate::value::TAG_TRUE); - } - if module == "fs" { - let matched = match method.as_str() { - "Stats" => crate::fs::is_fs_stats_instance_value(value), - "Dir" => crate::fs::is_fs_dir_instance_value(value), - "Dirent" => crate::fs::is_fs_dirent_instance_value(value), - "ReadStream" | "FileReadStream" | "WriteStream" | "FileWriteStream" - | "Utf8Stream" => crate::fs::is_fs_stream_instance_value(value, method.as_str()), - _ => false, - }; - if matched { - return f64::from_bits(crate::value::TAG_TRUE); - } - } - if module == "tls" - && method == "SecureContext" - && crate::tls::is_secure_context_instance(value) - { - return f64::from_bits(crate::value::TAG_TRUE); - } - if module == "tls" && matches!(method.as_str(), "Server" | "TLSSocket") { - let want = if method == "Server" { 1 } else { 2 }; - if let (Some(handle), Some(probe)) = ( - small_native_handle_id(value), - crate::object::tls_handle_kind_probe(), - ) { - return f64::from_bits(if unsafe { probe(handle) } == want { - crate::value::TAG_TRUE - } else { - TAG_FALSE - }); - } - } - if module == "wasi" && method == "WASI" && crate::wasi::is_wasi_instance(value) { - return f64::from_bits(crate::value::TAG_TRUE); - } - if module == "repl" { - let matched = match method.as_str() { - "Recoverable" => crate::node_repl::is_recoverable_value(value), - "REPLServer" => crate::node_repl::is_repl_server_value(value), - _ => false, - }; - if matched { - return f64::from_bits(crate::value::TAG_TRUE); - } - } - // #2689: `net.Stream` is an alias for `net.Socket`; both should match - // a live socket handle via the runtime probe. - if module == "net" && matches!(method.as_str(), "Socket" | "Stream") { - if let Some(handle) = small_native_handle_id(value) { - let net_socket = crate::object::net_socket_handle_probe() - .map(|probe| unsafe { probe(handle) }) - .unwrap_or(false); - let tls_socket = crate::object::tls_handle_kind_probe() - .map(|probe| unsafe { probe(handle) == 2 }) - .unwrap_or(false); - if net_socket || tls_socket { - return f64::from_bits(crate::value::TAG_TRUE); - } - } - } - if module == "console" - && method == "Console" - && crate::builtins::is_console_instance_value(value) - { - return f64::from_bits(crate::value::TAG_TRUE); - } - if module == "crypto" && method == "KeyObject" { - let addr = value_addr(value); - return if addr != 0 - && (crate::buffer::is_secret_key(addr) - || crate::buffer::asymmetric_key_meta(addr).is_some()) - { - f64::from_bits(crate::value::TAG_TRUE) - } else { - f64::from_bits(TAG_FALSE) - }; - } - if module == "perf_hooks" { - let class_id = match method.as_str() { - "Performance" => crate::perf_hooks::CLASS_ID_PERFORMANCE, - "PerformanceEntry" => crate::perf_hooks::CLASS_ID_PERFORMANCE_ENTRY, - "PerformanceMark" => crate::perf_hooks::CLASS_ID_PERFORMANCE_MARK, - "PerformanceMeasure" => crate::perf_hooks::CLASS_ID_PERFORMANCE_MEASURE, - "PerformanceObserverEntryList" => { - crate::perf_hooks::CLASS_ID_PERFORMANCE_OBSERVER_ENTRY_LIST - } - "PerformanceResourceTiming" => { - crate::perf_hooks::CLASS_ID_PERFORMANCE_RESOURCE_TIMING - } - _ => 0, - }; - if class_id != 0 { - return js_instanceof(value, class_id); - } - } - } - if is_buffer_constructor_value(type_ref) { - return js_instanceof(value, crate::buffer::BUFFER_TYPE_ID); - } - if let Some(name) = identify_global_builtin_constructor(type_ref) { - match name { - "Crypto" => { - return if is_native_module_namespace_value(value, "crypto.webcrypto") { - f64::from_bits(crate::value::TAG_TRUE) - } else { - f64::from_bits(TAG_FALSE) - }; - } - "SubtleCrypto" => { - return if is_native_module_namespace_value(value, "crypto.subtle") { - f64::from_bits(crate::value::TAG_TRUE) - } else { - f64::from_bits(TAG_FALSE) - }; - } - "CryptoKey" => { - let addr = value_addr(value); - return if addr != 0 && crate::buffer::crypto_key_meta(addr).is_some() { - f64::from_bits(crate::value::TAG_TRUE) - } else { - f64::from_bits(TAG_FALSE) - }; - } - _ => {} - } - let class_id = global_builtin_constructor_class_id(name); - if class_id != 0 { - let r = js_instanceof(value, class_id); - if r.to_bits() == crate::value::TAG_TRUE { - return r; - } - // #5989: an object that inherits a builtin's prototype via - // `Fn.prototype = Object.create(Builtin.prototype)` is `instanceof - // Builtin` per the spec even though it carries no builtin class id — - // react-server-dom's flight Chunk inherits `Promise.prototype` this - // way, so `chunk instanceof Promise` must be true. Walk the real - // [[Prototype]] chain against `Builtin.prototype` before answering - // false. - if ordinary_has_instance_prototype_walk(value, type_ref) { - return f64::from_bits(crate::value::TAG_TRUE); - } - return f64::from_bits(TAG_FALSE); - } - } - if crate::node_submodules::is_diagnostics_channel_constructor_value(type_ref) { - return if crate::node_submodules::diagnostics_channel_is_channel_instance_value(value) { - f64::from_bits(crate::value::TAG_TRUE) - } else { - f64::from_bits(TAG_FALSE) - }; - } - // `inst instanceof Intl.`: Intl instances are plain heap objects whose - // `[[Prototype]]` is `Intl..prototype` but carry no class-id, so the - // arms above can't match them. Walk their static-prototype chain. - // `Intl.*` brand checks. Behind `intl-namespace`: with the feature off no - // Intl constructor value can exist (the namespace install is a no-op), so - // the probe could never match — and skipping it keeps this always-live - // dispatcher from statically pinning every Intl constructor thunk (~204 KB). - #[cfg(feature = "intl-namespace")] - if let Some(is_inst) = crate::intl::intl_instanceof(value, type_ref) { - return if is_inst { - f64::from_bits(crate::value::TAG_TRUE) - } else { - f64::from_bits(TAG_FALSE) - }; - } - js_instanceof_dynamic_tail(value, type_ref) -} /// Runtime class id for a globalThis built-in constructor *name*. /// @@ -1168,736 +777,6 @@ fn subclass_of_builtin_reaches(value: f64, class_id: u32) -> bool { /// Check if a value is an instance of a class with the given class_id /// Walks the inheritance chain to check parent classes /// Returns NaN-boxed TAG_TRUE / TAG_FALSE so the result identifies as a boolean. -#[no_mangle] -pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { - const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; - const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; - let true_val = f64::from_bits(TAG_TRUE); - let false_val = f64::from_bits(TAG_FALSE); - - if class_id == 0 { - return false_val; - } - // `proxy instanceof C` follows the proxy's prototype chain, which forwards - // to the target (absent a `getPrototypeOf` trap) — so unwrap to the target - // before walking the class chain. The proxy is a small id with no chain of - // its own. (drizzle's aliased-column proxies + `is(value, type)`.) - let mut value = value; - { - let mut depth = 0; - while depth < 16 && crate::proxy::js_proxy_is_proxy(value) != 0 { - value = crate::proxy::js_proxy_target(value); - depth += 1; - } - } - // User-defined `Symbol.hasInstance` takes precedence over the built-in - // prototype-chain walk — and over the ordinary class-chain fast path below. - // `new C() instanceof C` must run a class-level `@@hasInstance` rather than - // short-circuit on the chain (the hook can return `false` for a real - // instance), so both hook forms are consulted here, ahead of that walk. - // - // Form 1: the HIR lifts `static [Symbol.hasInstance](v)` to a top-level - // function `__perry_wk_hasinstance_` and the LLVM backend registers a - // pointer to it against the class id at module init. - if let Some(func_ptr) = lookup_has_instance_hook(class_id) { - let hook: extern "C" fn(f64) -> f64 = unsafe { std::mem::transmute(func_ptr as *const u8) }; - let result = hook(value); - // Normalize: any truthy NaN-boxed bool stays as the TAG_TRUE/FALSE - // sentinel. User-written `return typeof v === "number" && ...` - // already returns a NaN-boxed bool, so this is usually a no-op. - let rbits = result.to_bits(); - if rbits == TAG_TRUE || rbits == TAG_FALSE { - return result; - } - // Fallback: treat as truthy → TRUE, zero/undefined → FALSE. - if result.is_nan() && rbits & 0xFFFF_0000_0000_0000 == 0x7FFC_0000_0000_0000 { - return false_val; - } - if result == 0.0 || result.is_nan() { - return false_val; - } - return true_val; - } - - // Form 2: the `Object.defineProperty(C, Symbol.hasInstance, { value: fn })` - // form (zod 4) stores the closure in the class static-symbol table. Read it - // off the class id (OWN lookup only — never resolves Function.prototype's - // default @@hasInstance thunk, so no recursion). A present-but-non-callable - // value throws; only `null`/`undefined` falls through to the chain. - // - // The latch check is what keeps `well_known_symbol("hasInstance")` — a - // string-keyed interning probe — off the path entirely in the (dominant) - // case where no class in the program declares any static Symbol member. - if crate::symbol::CLASS_STATIC_SYMBOLS_LATCH.is_armed() { - let hi_sym = crate::symbol::well_known_symbol("hasInstance"); - if !hi_sym.is_null() { - let hi_f64 = f64::from_bits(crate::value::JSValue::pointer(hi_sym as *const u8).bits()); - if let Some(vb) = crate::symbol::class_static_symbol_lookup(class_id, hi_f64) { - let cb = f64::from_bits(vb); - if let HasInstanceOutcome::Result(r) = dispatch_own_has_instance(cb, value) { - return r; - } - } - } - } - - // Subclass-of-built-in: see `subclass_of_builtin_reaches`. - if subclass_of_builtin_reaches(value, class_id) { - return true_val; - } - // Temporal reference types (`d instanceof Temporal.Duration`, …). A Temporal - // value is a NaN-boxed pointer to a brand-tagged cell, not an ObjectHeader - // with a class chain, so probe the cell's brand kind directly. Keep the band - // in sync with perry-runtime/src/temporal/mod.rs. - if (crate::temporal::CLASS_ID_TEMPORAL_FIRST..=crate::temporal::CLASS_ID_TEMPORAL_LAST) - .contains(&class_id) - { - return if crate::temporal::temporal_value_matches_class_id(value, class_id) { - true_val - } else { - false_val - }; - } - // `value instanceof Function` — true for any callable value. Per - // `OrdinaryHasInstance`, every Perry function (declaration, expression, - // arrow, method, bound function, native handle, built-in constructor) - // has `Function.prototype` in its prototype chain. Keep `CLASS_ID_FUNCTION` - // in sync with perry-codegen/src/expr/instance_misc1.rs. - if class_id == CLASS_ID_FUNCTION { - return if value_is_callable(value) { - true_val - } else { - false_val - }; - } - // Keep in sync with perry-codegen/src/expr/instance_misc1.rs. - let classic_stream_name = match class_id { - 0xFFFF0070 => Some("Stream"), - 0xFFFF0071 => Some("Readable"), - 0xFFFF0072 => Some("Writable"), - 0xFFFF0073 => Some("Duplex"), - 0xFFFF0074 => Some("Transform"), - 0xFFFF0075 => Some("PassThrough"), - _ => None, - }; - if let Some(name) = classic_stream_name { - return if crate::node_stream::is_classic_stream_instance_of(value, name) - || super::tls_constructor_prototype_is_instance_of(value, name) - { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_EVENT_EMITTER { - return if is_event_emitter_instance_value(value) - || super::tls_constructor_prototype_is_instance_of(value, "EventEmitter") - { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE { - return if is_event_emitter_async_resource_instance_value(value) { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_ASYNC_RESOURCE { - return if crate::async_hooks::resolve_async_resource_handle(value_addr(value) as i64) - .is_some() - { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_ASYNC_LOCAL_STORAGE { - let candidate = small_native_handle_id(value).unwrap_or(value_addr(value) as i64); - let matched = candidate != 0 && { - super::class_handles::handle_property_dispatch().is_some_and(|dispatch| { - let property = b"getStore"; - let result = unsafe { dispatch(candidate, property.as_ptr(), property.len()) }; - value_is_callable(result) - }) - }; - return if matched { true_val } else { false_val }; - } - if class_id == CLASS_ID_NET_SOCKET { - return if let Some(handle) = small_native_handle_id(value) { - let net_socket = crate::object::net_socket_handle_probe() - .map(|probe| unsafe { probe(handle) }) - .unwrap_or(false); - let tls_socket = crate::object::tls_handle_kind_probe() - .map(|probe| unsafe { probe(handle) == 2 }) - .unwrap_or(false); - if net_socket || tls_socket { - true_val - } else { - false_val - } - } else { - false_val - }; - } - if class_id == crate::fs::CLASS_ID_FS_STATS_EXPORT { - return if crate::fs::is_fs_stats_instance_value(value) { - true_val - } else { - false_val - }; - } - if class_id == crate::fs::CLASS_ID_FS_DIR { - return if crate::fs::is_fs_dir_instance_value(value) { - true_val - } else { - false_val - }; - } - if class_id == crate::fs::CLASS_ID_FS_DIRENT { - return if crate::fs::is_fs_dirent_instance_value(value) { - true_val - } else { - false_val - }; - } - if class_id == crate::fs::CLASS_ID_FS_READ_STREAM { - return if crate::fs::is_fs_stream_instance_value(value, "ReadStream") { - true_val - } else { - false_val - }; - } - if class_id == crate::fs::CLASS_ID_FS_WRITE_STREAM { - return if crate::fs::is_fs_stream_instance_value(value, "WriteStream") { - true_val - } else { - false_val - }; - } - if class_id == crate::fs::CLASS_ID_FS_UTF8_STREAM { - return if crate::fs::is_fs_stream_instance_value(value, "Utf8Stream") { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_CRYPTO { - return if is_native_module_namespace_value(value, "crypto.webcrypto") { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_SUBTLE_CRYPTO { - return if is_native_module_namespace_value(value, "crypto.subtle") { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_CRYPTO_KEY { - let addr = value_addr(value); - return if addr != 0 && crate::buffer::crypto_key_meta(addr).is_some() { - true_val - } else { - false_val - }; - } - - let bits = value.to_bits(); - let jsval = crate::JSValue::from_bits(bits); - - // Native/exotic subclass instances (typed arrays, ArrayBuffers, boxed - // primitives, Dates, …) do not carry a Perry `ObjectHeader.class_id`. - // Their constructor records the distinct newTarget prototype in the - // prototype side table instead. Honor that chain for user class ids. - if is_class_id_registered(class_id) { - let addr = value_addr(value); - if addr != 0 && super::prototype_chain::object_static_prototype(addr).is_some() { - let constructor = super::class_constructor_ref_value(class_id); - return if ordinary_has_instance_prototype_walk(value, constructor) { - true_val - } else { - false_val - }; - } - } - - // Special handling for Uint8Array/Buffer (class_id 0xFFFF0004) - // Perry buffers are raw BufferHeader pointers bitcast to f64 (not NaN-boxed), - // so the normal POINTER_TAG check doesn't work for them. - // We use a thread-local buffer registry to identify buffer pointers. - if class_id == crate::buffer::BUFFER_TYPE_ID { - // Check if NaN-boxed pointer - if jsval.is_pointer() { - let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; - if crate::buffer::is_registered_buffer(addr) { - return true_val; - } - } - // Check if raw pointer (buffer values are bitcast, not NaN-boxed) - let top16 = (bits >> 48) as u16; - if top16 == 0 && bits >= 0x1000 && crate::buffer::is_registered_buffer(bits as usize) { - return true_val; - } - return false_val; - } - - // ArrayBuffer — Perry models ArrayBuffer storage with BufferHeader values - // marked in a side registry. They can arrive either NaN-boxed or as raw - // buffer pointers, matching the Buffer/Uint8Array path above. - const CLASS_ID_ARRAY_BUFFER: u32 = 0xFFFF0025; - const CLASS_ID_SHARED_ARRAY_BUFFER: u32 = 0xFFFF002E; - if class_id == CLASS_ID_ARRAY_BUFFER || class_id == CLASS_ID_SHARED_ARRAY_BUFFER { - let addr = if jsval.is_pointer() { - (bits & 0x0000_FFFF_FFFF_FFFF) as usize - } else { - let top16 = (bits >> 48) as u16; - if top16 == 0 && bits >= 0x1000 { - bits as usize - } else { - 0 - } - }; - let matches_brand = if class_id == CLASS_ID_SHARED_ARRAY_BUFFER { - crate::buffer::is_shared_array_buffer(addr) - } else { - crate::buffer::is_array_buffer(addr) - }; - if addr != 0 && crate::buffer::is_registered_buffer(addr) && matches_brand { - return true_val; - } - return false_val; - } - - // #1545: Web Streams `instanceof ReadableStream` / `instanceof - // WritableStream`. Stream handles are numeric `id as f64`, so consult the - // stdlib kind-probe (1 = readable, 2 = writable) rather than the class - // chain. Covers `ts.readable instanceof ReadableStream`, - // `rs.pipeThrough(ts) instanceof ReadableStream`, etc. - // kind probe values: 1 = readable, 2 = writable, 5 = transform - // (3 = reader, 4 = writer — not user-facing instanceof targets here). - const CLASS_ID_READABLE_STREAM: u32 = 0xFFFF0060; - const CLASS_ID_WRITABLE_STREAM: u32 = 0xFFFF0061; - const CLASS_ID_TRANSFORM_STREAM: u32 = 0xFFFF0062; - if class_id == CLASS_ID_READABLE_STREAM - || class_id == CLASS_ID_WRITABLE_STREAM - || class_id == CLASS_ID_TRANSFORM_STREAM - { - if value.is_finite() && value > 0.0 && value.fract() == 0.0 { - if let Some(probe) = crate::object::stream_handle_kind_probe() { - let kind = unsafe { probe(value as usize) }; - let want = match class_id { - CLASS_ID_READABLE_STREAM => 1, - CLASS_ID_WRITABLE_STREAM => 2, - _ => 5, // CLASS_ID_TRANSFORM_STREAM - }; - if kind == want { - return true_val; - } - } - } - return false_val; - } - - // WHATWG fetch: `instanceof Response` / `Request` / `Headers` / `Blob` / - // `File`. - // These are pointer-tagged small-integer handles (stdlib fetch registries), - // not heap objects, so consult the stdlib fetch kind-probe rather than the - // class chain. Without this, Hono's `res instanceof Response` route-fallback - // guard sees `false` and skips the fallback, escaping a bare sentinel. - const CLASS_ID_RESPONSE: u32 = 0xFFFF0028; - const CLASS_ID_REQUEST: u32 = 0xFFFF0029; - const CLASS_ID_HEADERS: u32 = 0xFFFF002A; - const CLASS_ID_BLOB: u32 = 0xFFFF0026; - const CLASS_ID_FILE: u32 = 0xFFFF002F; - if class_id == CLASS_ID_RESPONSE - || class_id == CLASS_ID_REQUEST - || class_id == CLASS_ID_HEADERS - || class_id == CLASS_ID_BLOB - || class_id == CLASS_ID_FILE - { - let want = match class_id { - CLASS_ID_RESPONSE => 1u8, - CLASS_ID_REQUEST => 2, - CLASS_ID_HEADERS => 3, - CLASS_ID_BLOB => 4, - _ => 5, // CLASS_ID_FILE - }; - if let Some(handle) = small_native_handle_id(value) { - if let Some(probe) = crate::object::fetch_handle_kind_probe() { - let kind = unsafe { probe(handle as usize) }; - // File inherits Blob, so a File handle satisfies both brands. - if kind == want || (class_id == CLASS_ID_BLOB && kind == 5) { - return true_val; - } - } - } - // `class X extends Request/Response` instance: a heap object that - // stashes the underlying native fetch handle id under - // `__perry_fetch_handle__`. Unwrap and probe so `sub instanceof - // Request` is true, matching a bare handle. - if jsval.is_pointer() { - let raw = jsval.as_pointer::() as usize; - if let Some(id) = unsafe { crate::object::fetch_subclass_handle_id(raw) } { - if let Some(probe) = crate::object::fetch_handle_kind_probe() { - let kind = unsafe { probe(id as usize) }; - if kind == want || (class_id == CLASS_ID_BLOB && kind == 5) { - return true_val; - } - } - } - } - // A Blob can also be a real heap object allocated with CLASS_ID_BLOB - // (e.g. `stream/consumers`.`blob()` and `blob_value_from_bytes`), not - // just a small fetch-registry handle. Match it by its own class id so - // `blob instanceof Blob` is true for that representation too. - if class_id == CLASS_ID_BLOB && jsval.is_pointer() { - let obj = jsval.as_pointer::(); - if crate::value::addr_class::is_above_handle_band(obj as usize) - && unsafe { (*obj).class_id } == CLASS_ID_BLOB - { - return true_val; - } - } - return false_val; - } - - // Built-in JS types Map / Set / RegExp / Date — Perry doesn't define - // user classes for these, so we use reserved class IDs and detect via - // the per-type registries (MAP_REGISTRY / SET_REGISTRY / REGEX_POINTERS) - // or, for Date, by checking that the value is a finite f64 timestamp. - const CLASS_ID_DATE: u32 = 0xFFFF0020; - const CLASS_ID_REGEXP: u32 = 0xFFFF0021; - const CLASS_ID_MAP: u32 = 0xFFFF0022; - const CLASS_ID_SET: u32 = 0xFFFF0023; - if class_id == CLASS_ID_DATE { - // A Perry Date is a NaN-boxed pointer to a `DateCell` (#2089). Its - // identity is the cell's `GcHeader` type, so `new Date(NaN)` (an - // Invalid Date — a cell whose time value is NaN) matches just like - // any other Date, and a plain number never matches. - if crate::date::is_date_value(value) { - return true_val; - } - return false_val; - } - if class_id == CLASS_ID_MAP { - if jsval.is_pointer() { - let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; - if crate::map::is_registered_map(addr) { - return true_val; - } - } - return false_val; - } - if class_id == CLASS_ID_SET { - if jsval.is_pointer() { - let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; - if crate::set::is_registered_set(addr) { - return true_val; - } - } - return false_val; - } - // #5834: `x instanceof WeakMap`/`WeakSet` for a REAL instance. These - // reserved ids (kept in sync with perry-codegen/src/expr/instance_misc1.rs) - // are distinct from the runtime `CLASS_ID_WEAKMAP`/`CLASS_ID_WEAKSET` - // stamped on actual instances (weakref.rs) — the subclass-chain walk above - // only matches a `class S extends WeakMap {}` instance (whose chain reaches - // this reserved id), so a genuine `new WeakMap()` still needs its own probe - // here, same shape as Map/Set above. - const CLASS_ID_WEAKMAP_RESERVED: u32 = 0xFFFF002C; - const CLASS_ID_WEAKSET_RESERVED: u32 = 0xFFFF002D; - if class_id == CLASS_ID_WEAKMAP_RESERVED { - return if crate::object::weak_class_id_from_receiver(value) - == Some(crate::weakref::CLASS_ID_WEAKMAP) - { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_WEAKSET_RESERVED { - return if crate::object::weak_class_id_from_receiver(value) - == Some(crate::weakref::CLASS_ID_WEAKSET) - { - true_val - } else { - false_val - }; - } - if class_id == CLASS_ID_REGEXP { - if jsval.is_pointer() { - let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; - if crate::regex::is_regex_pointer(addr as *const u8) { - return true_val; - } - } - return false_val; - } - if class_id == CLASS_ID_PROMISE { - if let Some(matches) = recorded_prototype_instanceof_builtin(value, "Promise") { - return if matches { true_val } else { false_val }; - } - return if crate::promise::js_value_is_promise(value) != 0 { - true_val - } else { - false_val - }; - } - - // `Object` — ECMAScript spec: `x instanceof Object` is true for any - // non-primitive (every object/array/function/Map/Set/Buffer/RegExp/ - // Date/typed-array/Promise/etc.). The codegen maps `Object` to this - // reserved id (#585 follow-up: pre-#585 fix this case worked by - // accident because the codegen produced `class_id = 0` and the - // runtime returned true via `0 == 0` on the obj_class_id check). - const CLASS_ID_OBJECT: u32 = 0xFFFF0050; - if class_id == CLASS_ID_OBJECT { - if jsval.is_pointer() { - // A Symbol is a POINTER_TAG heap allocation but a PRIMITIVE, not an - // object, so `Symbol() instanceof Object` is false (the comment - // above says "any non-primitive"). Every other primitive is - // non-pointer-tagged and already falls through below. #6587 review. - if unsafe { crate::symbol::js_is_symbol(value) != 0 } { - return false_val; - } - // Covers every heap object, including a Date (now a NaN-boxed - // `DateCell` pointer — #2089) and an Invalid Date. - return true_val; - } - let top16 = (bits >> 48) as u16; - if top16 == 0 && bits >= 0x1000 { - let addr = bits as usize; - if crate::buffer::is_registered_buffer(addr) - || crate::set::is_registered_set(addr) - || crate::map::is_registered_map(addr) - || crate::typedarray::lookup_typed_array_kind(addr).is_some() - { - return true_val; - } - } - return false_val; - } - - // Array — Perry arrays are heap allocations with `GC_TYPE_ARRAY` in - // their gc_header (one byte at obj-8). Pointer can arrive NaN-boxed - // (POINTER_TAG) or as a raw bitcast f64; handle both. Lazy arrays - // (Phase 5 JSON.parse result) are also arrays from the user's - // perspective — must return true without force-materializing. - const CLASS_ID_ARRAY: u32 = 0xFFFF0024; - if class_id == CLASS_ID_ARRAY { - // A POINTER_TAG handle id (fetch/zlib/stdlib registries) is not a heap - // address; the canonical header read rejects it instead of probing the - // byte below it. - let is_array = unsafe { crate::value::addr_class::try_read_gc_header(value_addr(value)) } - .is_some_and(|header| { - header.obj_type == crate::gc::GC_TYPE_ARRAY - || header.obj_type == crate::gc::GC_TYPE_LAZY_ARRAY - }); - return if is_array { true_val } else { false_val }; - } - - // Typed arrays — Int8Array..Float16Array reserved IDs (0xFFFF0030..3B). - // The pointer can arrive as either a NaN-boxed POINTER_TAG value or a - // raw bitcast f64, so handle both forms. - if (0xFFFF0030..=0xFFFF003B).contains(&class_id) { - let addr = if jsval.is_pointer() { - (bits & 0x0000_FFFF_FFFF_FFFF) as usize - } else { - let top16 = (bits >> 48) as u16; - if top16 == 0 && bits >= 0x1000 { - bits as usize - } else { - 0 - } - }; - if addr != 0 { - if let Some(actual_kind) = crate::typedarray::lookup_typed_array_kind(addr) { - let want_id = crate::typedarray::class_id_for_kind(actual_kind); - if want_id == class_id { - return true_val; - } - } - } - return false_val; - } - - // Only objects (pointers) can be instances of classes - if !jsval.is_pointer() { - return false_val; - } - - // Get the object pointer - let obj_ptr = jsval.as_pointer::(); - if obj_ptr.is_null() { - return false_val; - } - - // Refs #421: NaN-boxed POINTER_TAG values whose unboxed payload is a - // small registry id (Web Fetch handles, sockets, DB connections, etc.) - // are NOT real ObjectHeader pointers — reading the GC header at - // `obj_ptr - 8` would SIGSEGV on unmapped memory. They aren't instances - // of any user-defined class either, so return false unconditionally. - if crate::value::addr_class::is_handle_band(obj_ptr as usize) { - return false_val; - } - - unsafe { - // Special handling for built-in Error and its subclasses (TypeError, RangeError, etc.). - // ErrorHeader uses GC_TYPE_ERROR; we match by error_kind against the requested CLASS_ID_*. - let gc_header = - (obj_ptr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - let gc_type = (*gc_header).obj_type; - if gc_type == crate::gc::GC_TYPE_ERROR { - let err_ptr = obj_ptr as *const crate::error::ErrorHeader; - let kind = (*err_ptr).error_kind; - if class_id == crate::event_target::CLASS_ID_DOM_EXCEPTION { - return if crate::event_target::is_dom_exception_error(err_ptr) { - true_val - } else { - false_val - }; - } - let builtin_name = match class_id { - crate::error::CLASS_ID_ERROR => Some("Error"), - crate::error::CLASS_ID_TYPE_ERROR => Some("TypeError"), - crate::error::CLASS_ID_RANGE_ERROR => Some("RangeError"), - crate::error::CLASS_ID_REFERENCE_ERROR => Some("ReferenceError"), - crate::error::CLASS_ID_SYNTAX_ERROR => Some("SyntaxError"), - crate::error::CLASS_ID_EVAL_ERROR => Some("EvalError"), - crate::error::CLASS_ID_URI_ERROR => Some("URIError"), - crate::error::CLASS_ID_AGGREGATE_ERROR => Some("AggregateError"), - _ => None, - }; - if let Some(name) = builtin_name { - if let Some(matches) = recorded_prototype_instanceof_builtin(value, name) { - return if matches { true_val } else { false_val }; - } - } - return match class_id { - crate::error::CLASS_ID_ERROR => true_val, - crate::error::CLASS_ID_TYPE_ERROR => { - if kind == crate::error::ERROR_KIND_TYPE_ERROR { - true_val - } else { - false_val - } - } - crate::error::CLASS_ID_RANGE_ERROR => { - if kind == crate::error::ERROR_KIND_RANGE_ERROR { - true_val - } else { - false_val - } - } - crate::error::CLASS_ID_REFERENCE_ERROR => { - if kind == crate::error::ERROR_KIND_REFERENCE_ERROR { - true_val - } else { - false_val - } - } - crate::error::CLASS_ID_SYNTAX_ERROR => { - if kind == crate::error::ERROR_KIND_SYNTAX_ERROR { - true_val - } else { - false_val - } - } - crate::error::CLASS_ID_EVAL_ERROR => { - if kind == crate::error::ERROR_KIND_EVAL_ERROR { - true_val - } else { - false_val - } - } - crate::error::CLASS_ID_URI_ERROR => { - if kind == crate::error::ERROR_KIND_URI_ERROR { - true_val - } else { - false_val - } - } - crate::error::CLASS_ID_AGGREGATE_ERROR => { - if kind == crate::error::ERROR_KIND_AGGREGATE_ERROR { - true_val - } else { - false_val - } - } - _ => false_val, - }; - } - - if gc_type == crate::gc::GC_TYPE_OBJECT { - if let Some(matches) = - crate::perf_hooks::is_perf_hooks_shape_instance_of(value, class_id) - { - return if matches { true_val } else { false_val }; - } - if let Some(matches) = - crate::perf_hooks::is_perf_entry_object_instance_of(obj_ptr, class_id) - { - return if matches { true_val } else { false_val }; - } - } - - // For user-defined classes that extend Error: `myErr instanceof Error` should be true. - if class_id == crate::error::CLASS_ID_ERROR { - // #9940: a function-local class declaration gets a fresh class - // object on every evaluation, but all evaluations share its - // compile-time class id. A constructor factory can therefore - // evaluate `class Definition extends Error {}`, then later - // evaluate the same declaration with an Object parent. The class - // registry is keyed by the shared id and is necessarily - // last-wins; the instance's recorded evaluation prototype is the - // authoritative chain. Zod's `$constructor` has exactly this - // shape, and its later schema classes made an earlier ZodError - // fail `instanceof Error` even though getPrototypeOf still showed - // `ZodError -> Error -> Object`. - if let Some(matches) = recorded_prototype_instanceof_builtin(value, "Error") { - return if matches { true_val } else { false_val }; - } - } - - // Everything below reads `ObjectHeader::class_id`, which only a - // genuine `GC_TYPE_OBJECT` has. Every other GC type keeps something - // else in that word — an array's `length`, a closure's function - // pointer, a Map's `size` — so `[1, 2] instanceof C` was true whenever - // the length equalled (or chained to) `C`'s class id. - if gc_type != crate::gc::GC_TYPE_OBJECT { - return false_val; - } - - if class_id == crate::error::CLASS_ID_ERROR { - let obj_class_id = (*obj_ptr).class_id; - if extends_builtin_error(obj_class_id) { - return true_val; - } - } - - // Check if the object's class_id matches directly - let obj_class_id = (*obj_ptr).class_id; - if class_id == crate::event_target::CLASS_ID_EVENT - && obj_class_id == crate::event_target::CLASS_ID_CUSTOM_EVENT - { - return true_val; - } - // Walk up the inheritance chain using the class registry. #7575: the - // walk also follows the generic-origin edge, so a dynamic RHS holding a - // generic class (`const C = Gen; x instanceof C`) matches an instance of - // one of its specializations. - if class_chain_reaches(obj_class_id, class_id) { - return true_val; - } - - false_val - } -} #[cfg(test)] mod null_lhs_tests { diff --git a/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs b/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs new file mode 100644 index 0000000000..f768a52cbb --- /dev/null +++ b/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs @@ -0,0 +1,406 @@ +//! `js_instanceof_dynamic` — the dynamic (runtime-class-ref) form of +//! `instanceof`, resolving a value/class-ref RHS pair rather than a +//! compile-time-known class id. +//! +//! Split out of `instanceof.rs` for the file-size cap. Pure relocation — +//! no logic changes; see `super::*` for every helper this calls. + +use super::*; + +#[no_mangle] +pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { + const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; + // `proxy instanceof C` uses the proxy's `[[GetPrototypeOf]]`, which (absent a + // trap) forwards to the target — so it is equivalent to `target instanceof + // C`. The proxy itself is a small registered id with no class chain, so + // without this it always returned false. Unwrap nested proxies (drizzle + // aliases columns as `new Proxy(column, …)` and its `is(value, type)` brand + // check relies on `value instanceof type`). Bounded to guard a cycle. + let mut value = value; + { + let mut depth = 0; + while depth < 16 && crate::proxy::js_proxy_is_proxy(value) != 0 { + value = crate::proxy::js_proxy_target(value); + depth += 1; + } + } + // `temporalValue instanceof Temporal.` — Temporal values dispatch via + // brand arms (not a real prototype chain), so resolve the constructor to + // its kind and compare against the value's brand. A non-Temporal value, or + // a Temporal value of a different kind, yields `false`. + if let Some(kind) = super::global_this::temporal_ctor_kind(type_ref) { + if crate::temporal::temporal_kind(value) == Some(kind) { + return f64::from_bits(crate::value::TAG_TRUE); + } + // `class X extends Temporal.` instance: a plain heap object whose + // [[Prototype]] chain reaches `Temporal..prototype`. It carries + // the brand via a stashed cell rather than the Temporal-cell tag, so + // recover that cell and compare its kind. The receiver reaches here both + // NaN-boxed (top16 == 0x7FFD) and as a raw-I64 heap pointer (top16 == 0, + // how module-level object vars are stored) — accept both. (#5587) + #[cfg(feature = "temporal")] + { + let bits = value.to_bits(); + let top16 = bits >> 48; + let raw = if top16 == 0x7FFD { + (bits & crate::value::POINTER_MASK) as usize + } else if top16 == 0 { + bits as usize + } else { + 0 + }; + if raw != 0 { + if let Some(cell) = unsafe { crate::object::temporal_subclass_cell(raw) } { + if crate::temporal::temporal_kind(cell) == Some(kind) { + return f64::from_bits(crate::value::TAG_TRUE); + } + } + } + } + return f64::from_bits(TAG_FALSE); + } + // Spec step (InstanceofOperator): an OWN user-defined `@@hasInstance` + // overrides even native constructor brand checks. The native generic hook + // lives on Function.prototype, so the own-property gate distinguishes an + // explicit override from that inherited default without recursion. + { + let hi_sym = crate::symbol::well_known_symbol("hasInstance"); + if !hi_sym.is_null() { + let hi_f64 = f64::from_bits(crate::value::JSValue::pointer(hi_sym as *const u8).bits()); + if unsafe { crate::symbol::js_object_has_own_symbol(type_ref, hi_f64) } { + let cb = unsafe { crate::symbol::js_object_get_symbol_property(type_ref, hi_f64) }; + if let HasInstanceOutcome::Result(result) = dispatch_own_has_instance(cb, value) { + return result; + } + } + } + } + // Native http(s).Agent handles have no heap prototype chain. After any own + // override above has had first refusal, retain their native brand check. + if let Some((module, method)) = unsafe { bound_native_callable_module_and_method(type_ref) } { + if matches!(module.as_str(), "http" | "https") && method == "Agent" { + let matched = small_native_handle_id(value) + .zip(crate::object::http_agent_handle_probe()) + .is_some_and(|(handle, probe)| unsafe { probe(handle) }); + return f64::from_bits(if matched { + crate::value::TAG_TRUE + } else { + TAG_FALSE + }); + } + } + let bits = type_ref.to_bits(); + // `class_ref_id` requires `is_class_id_registered`, not just the tag — + // a user-crafted NaN payload sharing the 0x7FFE band (a real JS number + // constructed via `DataView.setFloat64`, not a codegen-emitted class + // ref) must fall through to the unresolved-RHS `TypeError` below + // instead of being dispatched into `js_instanceof` as a bogus class id. + if let Some(class_id) = class_ref_id(type_ref) { + return js_instanceof(value, class_id); + } + // #9502: a heap class object's template id identifies its code, not its + // evaluation. Compare the actual prototype objects so sibling evaluations + // remain distinct and a chain through earlier evaluations still matches. + if is_class_object_value(type_ref) { + // Static/forward `new C()` sites can still construct by template id + // without attaching an evaluated prototype. Retain that representation's + // class-id check; recorded individual chains are authoritative. + if !super::prototype_chain::object_has_prototype_divergence(value_addr(value)) { + let obj = crate::JSValue::from_bits(bits).as_pointer::(); + return js_instanceof(value, js_object_get_class_id(obj)); + } + return f64::from_bits(if ordinary_has_instance_prototype_walk(value, type_ref) { + crate::value::TAG_TRUE + } else { + TAG_FALSE + }); + } + // A builtin constructor held in a VARIABLE — `const RS = ReadableStream; body + // instanceof RS` — arrives here as the ClosureHeader-backed function installed + // on `globalThis`, so none of the class-id paths above match and the prototype + // walk below returns false. Codegen only special-cases the *static identifier* + // form (`body instanceof ReadableStream`), where it hands the builtin class id + // straight to `js_instanceof`, which brand-checks these natively-backed values + // via the stream / fetch kind probes (their instances are handles, not heap + // objects with a real prototype chain). + // + // Minified bundles almost always alias constructors into locals, so the + // variable form is the common one in the wild: `x instanceof ` for + // ReadableStream / Response / Headers silently returned `false` while Node + // returns `true`. That made a large esbuild-bundled CLI app mis-detect its + // `fetch()` body, throw "The first argument must be a Readable, a + // ReadableStream, or an async iterable", and abort its background + // tar-stream downloads entirely. + // + // Recover the builtin's name from the constructor closure (recorded by + // `set_bound_native_closure_name` when globalThis is populated) and reuse the + // static path's class id, so both spellings agree. + if let Some(class_id) = builtin_ctor_class_id_from_value(type_ref) { + return js_instanceof(value, class_id); + } + // #6558: `e instanceof WebAssembly.CompileError` (and LinkError / + // RuntimeError). These constructors live on the WebAssembly NAMESPACE — + // not on `globalThis`, so the builtin-name path above never resolves + // them — and their instances are ErrorHeader-backed values with no + // prototype chain reaching the namespace ctor's `.prototype`, so the + // ordinary prototype walk below can't brand them either. Identify the + // ctor by its dedicated thunk func_ptr (GC-move-safe) and brand-check + // the instance by its error `.name`. + if let Some(matches) = super::global_this::webassembly_error_ctor_instanceof(value, type_ref) { + return f64::from_bits(if matches { + crate::value::TAG_TRUE + } else { + crate::value::TAG_FALSE + }); + } + // #6558 sibling: `mod instanceof WebAssembly.Module` for the wasm-host + // module wrapper. Its `[[Prototype]]` does not reach the namespace ctor's + // `.prototype`, so brand-check its GC-aware internal wrapper identity. + // Only a positive match short-circuits here; a miss returns `None` so the + // value still flows to the prototype walk below (how `WebAssembly.Memory` + // instances resolve, and how a foreign object answers `false`). + if let Some(true) = super::global_this::webassembly_value_ctor_instanceof(value, type_ref) { + return f64::from_bits(crate::value::TAG_TRUE); + } + if let Some((module, method)) = unsafe { bound_native_callable_module_and_method(type_ref) } { + if module == "stream" + && matches!( + method.as_str(), + "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" | "Stream" + ) + && (crate::node_stream::is_classic_stream_instance_of(value, method.as_str()) + || super::tls_constructor_prototype_is_instance_of(value, method.as_str())) + { + return f64::from_bits(crate::value::TAG_TRUE); + } + if module == "events" && method == "EventEmitter" { + // #10556: a genuine subclass instance (`class Sub extends + // EventEmitter {}`) is a real ObjectHeader carrying Sub's own + // class id, not a handle and not prototype-linked to the real + // `EventEmitter.prototype` — so it is invisible to the + // handle/prototype probes below. Delegate to the static path + // first: `js_instanceof` walks the class-chain parent edge that + // codegen registers for `extends EventEmitter` + // (`builtin_parent_reserved_class_id` in + // perry-codegen/src/expr/instance_misc1.rs), and its own + // `CLASS_ID_EVENT_EMITTER` branch already covers the direct + // handle/`util.inherits` cases. Keep the general prototype walk + // as a fallback for shapes neither path reaches. + return f64::from_bits( + if js_instanceof(value, CLASS_ID_EVENT_EMITTER).to_bits() == crate::value::TAG_TRUE + || ordinary_has_instance_prototype_walk(value, type_ref) + { + crate::value::TAG_TRUE + } else { + TAG_FALSE + }, + ); + } + if module == "events" + && method == "EventEmitterAsyncResource" + && is_event_emitter_async_resource_instance_value(value) + { + return f64::from_bits(crate::value::TAG_TRUE); + } + if module == "async_hooks" + && matches!(method.as_str(), "AsyncLocalStorage" | "AsyncResource") + { + let raw = value_addr(value); + let matched = if method == "AsyncResource" { + crate::async_hooks::resolve_async_resource_handle(raw as i64).is_some() + || (crate::value::addr_class::is_plausible_heap_addr(raw) + && ordinary_has_instance_prototype_walk(value, type_ref)) + } else { + let candidate = small_native_handle_id(value).unwrap_or(raw as i64); + let native = (candidate != 0) && { + super::class_handles::handle_property_dispatch().is_some_and(|dispatch| { + let property = b"getStore"; + let result = + unsafe { dispatch(candidate, property.as_ptr(), property.len()) }; + value_is_callable(result) + }) + }; + native + || (crate::value::addr_class::is_plausible_heap_addr(raw) + && ordinary_has_instance_prototype_walk(value, type_ref)) + }; + return f64::from_bits(if matched { + crate::value::TAG_TRUE + } else { + TAG_FALSE + }); + } + if module == "tty" + && matches!(method.as_str(), "ReadStream" | "WriteStream") + && crate::tty::is_tty_stream_instance(value, method.as_str()) + { + return f64::from_bits(crate::value::TAG_TRUE); + } + if module == "fs" { + let matched = match method.as_str() { + "Stats" => crate::fs::is_fs_stats_instance_value(value), + "Dir" => crate::fs::is_fs_dir_instance_value(value), + "Dirent" => crate::fs::is_fs_dirent_instance_value(value), + "ReadStream" | "FileReadStream" | "WriteStream" | "FileWriteStream" + | "Utf8Stream" => crate::fs::is_fs_stream_instance_value(value, method.as_str()), + _ => false, + }; + if matched { + return f64::from_bits(crate::value::TAG_TRUE); + } + } + if module == "tls" + && method == "SecureContext" + && crate::tls::is_secure_context_instance(value) + { + return f64::from_bits(crate::value::TAG_TRUE); + } + if module == "tls" && matches!(method.as_str(), "Server" | "TLSSocket") { + let want = if method == "Server" { 1 } else { 2 }; + if let (Some(handle), Some(probe)) = ( + small_native_handle_id(value), + crate::object::tls_handle_kind_probe(), + ) { + return f64::from_bits(if unsafe { probe(handle) } == want { + crate::value::TAG_TRUE + } else { + TAG_FALSE + }); + } + } + if module == "wasi" && method == "WASI" && crate::wasi::is_wasi_instance(value) { + return f64::from_bits(crate::value::TAG_TRUE); + } + if module == "repl" { + let matched = match method.as_str() { + "Recoverable" => crate::node_repl::is_recoverable_value(value), + "REPLServer" => crate::node_repl::is_repl_server_value(value), + _ => false, + }; + if matched { + return f64::from_bits(crate::value::TAG_TRUE); + } + } + // #2689: `net.Stream` is an alias for `net.Socket`; both should match + // a live socket handle via the runtime probe. + if module == "net" && matches!(method.as_str(), "Socket" | "Stream") { + if let Some(handle) = small_native_handle_id(value) { + let net_socket = crate::object::net_socket_handle_probe() + .map(|probe| unsafe { probe(handle) }) + .unwrap_or(false); + let tls_socket = crate::object::tls_handle_kind_probe() + .map(|probe| unsafe { probe(handle) == 2 }) + .unwrap_or(false); + if net_socket || tls_socket { + return f64::from_bits(crate::value::TAG_TRUE); + } + } + } + if module == "console" + && method == "Console" + && crate::builtins::is_console_instance_value(value) + { + return f64::from_bits(crate::value::TAG_TRUE); + } + if module == "crypto" && method == "KeyObject" { + let addr = value_addr(value); + return if addr != 0 + && (crate::buffer::is_secret_key(addr) + || crate::buffer::asymmetric_key_meta(addr).is_some()) + { + f64::from_bits(crate::value::TAG_TRUE) + } else { + f64::from_bits(TAG_FALSE) + }; + } + if module == "perf_hooks" { + let class_id = match method.as_str() { + "Performance" => crate::perf_hooks::CLASS_ID_PERFORMANCE, + "PerformanceEntry" => crate::perf_hooks::CLASS_ID_PERFORMANCE_ENTRY, + "PerformanceMark" => crate::perf_hooks::CLASS_ID_PERFORMANCE_MARK, + "PerformanceMeasure" => crate::perf_hooks::CLASS_ID_PERFORMANCE_MEASURE, + "PerformanceObserverEntryList" => { + crate::perf_hooks::CLASS_ID_PERFORMANCE_OBSERVER_ENTRY_LIST + } + "PerformanceResourceTiming" => { + crate::perf_hooks::CLASS_ID_PERFORMANCE_RESOURCE_TIMING + } + _ => 0, + }; + if class_id != 0 { + return js_instanceof(value, class_id); + } + } + } + if is_buffer_constructor_value(type_ref) { + return js_instanceof(value, crate::buffer::BUFFER_TYPE_ID); + } + if let Some(name) = identify_global_builtin_constructor(type_ref) { + match name { + "Crypto" => { + return if is_native_module_namespace_value(value, "crypto.webcrypto") { + f64::from_bits(crate::value::TAG_TRUE) + } else { + f64::from_bits(TAG_FALSE) + }; + } + "SubtleCrypto" => { + return if is_native_module_namespace_value(value, "crypto.subtle") { + f64::from_bits(crate::value::TAG_TRUE) + } else { + f64::from_bits(TAG_FALSE) + }; + } + "CryptoKey" => { + let addr = value_addr(value); + return if addr != 0 && crate::buffer::crypto_key_meta(addr).is_some() { + f64::from_bits(crate::value::TAG_TRUE) + } else { + f64::from_bits(TAG_FALSE) + }; + } + _ => {} + } + let class_id = global_builtin_constructor_class_id(name); + if class_id != 0 { + let r = js_instanceof(value, class_id); + if r.to_bits() == crate::value::TAG_TRUE { + return r; + } + // #5989: an object that inherits a builtin's prototype via + // `Fn.prototype = Object.create(Builtin.prototype)` is `instanceof + // Builtin` per the spec even though it carries no builtin class id — + // react-server-dom's flight Chunk inherits `Promise.prototype` this + // way, so `chunk instanceof Promise` must be true. Walk the real + // [[Prototype]] chain against `Builtin.prototype` before answering + // false. + if ordinary_has_instance_prototype_walk(value, type_ref) { + return f64::from_bits(crate::value::TAG_TRUE); + } + return f64::from_bits(TAG_FALSE); + } + } + if crate::node_submodules::is_diagnostics_channel_constructor_value(type_ref) { + return if crate::node_submodules::diagnostics_channel_is_channel_instance_value(value) { + f64::from_bits(crate::value::TAG_TRUE) + } else { + f64::from_bits(TAG_FALSE) + }; + } + // `inst instanceof Intl.`: Intl instances are plain heap objects whose + // `[[Prototype]]` is `Intl..prototype` but carry no class-id, so the + // arms above can't match them. Walk their static-prototype chain. + // `Intl.*` brand checks. Behind `intl-namespace`: with the feature off no + // Intl constructor value can exist (the namespace install is a no-op), so + // the probe could never match — and skipping it keeps this always-live + // dispatcher from statically pinning every Intl constructor thunk (~204 KB). + #[cfg(feature = "intl-namespace")] + if let Some(is_inst) = crate::intl::intl_instanceof(value, type_ref) { + return if is_inst { + f64::from_bits(crate::value::TAG_TRUE) + } else { + f64::from_bits(TAG_FALSE) + }; + } + js_instanceof_dynamic_tail(value, type_ref) +} diff --git a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs new file mode 100644 index 0000000000..7e512bb302 --- /dev/null +++ b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs @@ -0,0 +1,738 @@ +//! `js_instanceof` — the static (compile-time-known-class-id) form of +//! `instanceof`, and the per-built-in dispatch ladder behind it. +//! +//! Split out of `instanceof.rs` for the file-size cap. Pure relocation — +//! no logic changes; see `super::*` for every helper this calls. + +use super::*; + +#[no_mangle] +pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { + const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; + const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; + let true_val = f64::from_bits(TAG_TRUE); + let false_val = f64::from_bits(TAG_FALSE); + + if class_id == 0 { + return false_val; + } + // `proxy instanceof C` follows the proxy's prototype chain, which forwards + // to the target (absent a `getPrototypeOf` trap) — so unwrap to the target + // before walking the class chain. The proxy is a small id with no chain of + // its own. (drizzle's aliased-column proxies + `is(value, type)`.) + let mut value = value; + { + let mut depth = 0; + while depth < 16 && crate::proxy::js_proxy_is_proxy(value) != 0 { + value = crate::proxy::js_proxy_target(value); + depth += 1; + } + } + // User-defined `Symbol.hasInstance` takes precedence over the built-in + // prototype-chain walk — and over the ordinary class-chain fast path below. + // `new C() instanceof C` must run a class-level `@@hasInstance` rather than + // short-circuit on the chain (the hook can return `false` for a real + // instance), so both hook forms are consulted here, ahead of that walk. + // + // Form 1: the HIR lifts `static [Symbol.hasInstance](v)` to a top-level + // function `__perry_wk_hasinstance_` and the LLVM backend registers a + // pointer to it against the class id at module init. + if let Some(func_ptr) = lookup_has_instance_hook(class_id) { + let hook: extern "C" fn(f64) -> f64 = unsafe { std::mem::transmute(func_ptr as *const u8) }; + let result = hook(value); + // Normalize: any truthy NaN-boxed bool stays as the TAG_TRUE/FALSE + // sentinel. User-written `return typeof v === "number" && ...` + // already returns a NaN-boxed bool, so this is usually a no-op. + let rbits = result.to_bits(); + if rbits == TAG_TRUE || rbits == TAG_FALSE { + return result; + } + // Fallback: treat as truthy → TRUE, zero/undefined → FALSE. + if result.is_nan() && rbits & 0xFFFF_0000_0000_0000 == 0x7FFC_0000_0000_0000 { + return false_val; + } + if result == 0.0 || result.is_nan() { + return false_val; + } + return true_val; + } + + // Form 2: the `Object.defineProperty(C, Symbol.hasInstance, { value: fn })` + // form (zod 4) stores the closure in the class static-symbol table. Read it + // off the class id (OWN lookup only — never resolves Function.prototype's + // default @@hasInstance thunk, so no recursion). A present-but-non-callable + // value throws; only `null`/`undefined` falls through to the chain. + // + // The latch check is what keeps `well_known_symbol("hasInstance")` — a + // string-keyed interning probe — off the path entirely in the (dominant) + // case where no class in the program declares any static Symbol member. + if crate::symbol::CLASS_STATIC_SYMBOLS_LATCH.is_armed() { + let hi_sym = crate::symbol::well_known_symbol("hasInstance"); + if !hi_sym.is_null() { + let hi_f64 = f64::from_bits(crate::value::JSValue::pointer(hi_sym as *const u8).bits()); + if let Some(vb) = crate::symbol::class_static_symbol_lookup(class_id, hi_f64) { + let cb = f64::from_bits(vb); + if let HasInstanceOutcome::Result(r) = dispatch_own_has_instance(cb, value) { + return r; + } + } + } + } + + // Subclass-of-built-in: see `subclass_of_builtin_reaches`. + if subclass_of_builtin_reaches(value, class_id) { + return true_val; + } + // Temporal reference types (`d instanceof Temporal.Duration`, …). A Temporal + // value is a NaN-boxed pointer to a brand-tagged cell, not an ObjectHeader + // with a class chain, so probe the cell's brand kind directly. Keep the band + // in sync with perry-runtime/src/temporal/mod.rs. + if (crate::temporal::CLASS_ID_TEMPORAL_FIRST..=crate::temporal::CLASS_ID_TEMPORAL_LAST) + .contains(&class_id) + { + return if crate::temporal::temporal_value_matches_class_id(value, class_id) { + true_val + } else { + false_val + }; + } + // `value instanceof Function` — true for any callable value. Per + // `OrdinaryHasInstance`, every Perry function (declaration, expression, + // arrow, method, bound function, native handle, built-in constructor) + // has `Function.prototype` in its prototype chain. Keep `CLASS_ID_FUNCTION` + // in sync with perry-codegen/src/expr/instance_misc1.rs. + if class_id == CLASS_ID_FUNCTION { + return if value_is_callable(value) { + true_val + } else { + false_val + }; + } + // Keep in sync with perry-codegen/src/expr/instance_misc1.rs. + let classic_stream_name = match class_id { + 0xFFFF0070 => Some("Stream"), + 0xFFFF0071 => Some("Readable"), + 0xFFFF0072 => Some("Writable"), + 0xFFFF0073 => Some("Duplex"), + 0xFFFF0074 => Some("Transform"), + 0xFFFF0075 => Some("PassThrough"), + _ => None, + }; + if let Some(name) = classic_stream_name { + return if crate::node_stream::is_classic_stream_instance_of(value, name) + || super::tls_constructor_prototype_is_instance_of(value, name) + { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_EVENT_EMITTER { + return if is_event_emitter_instance_value(value) + || super::tls_constructor_prototype_is_instance_of(value, "EventEmitter") + { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE { + return if is_event_emitter_async_resource_instance_value(value) { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_ASYNC_RESOURCE { + return if crate::async_hooks::resolve_async_resource_handle(value_addr(value) as i64) + .is_some() + { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_ASYNC_LOCAL_STORAGE { + let candidate = small_native_handle_id(value).unwrap_or(value_addr(value) as i64); + let matched = candidate != 0 && { + super::class_handles::handle_property_dispatch().is_some_and(|dispatch| { + let property = b"getStore"; + let result = unsafe { dispatch(candidate, property.as_ptr(), property.len()) }; + value_is_callable(result) + }) + }; + return if matched { true_val } else { false_val }; + } + if class_id == CLASS_ID_NET_SOCKET { + return if let Some(handle) = small_native_handle_id(value) { + let net_socket = crate::object::net_socket_handle_probe() + .map(|probe| unsafe { probe(handle) }) + .unwrap_or(false); + let tls_socket = crate::object::tls_handle_kind_probe() + .map(|probe| unsafe { probe(handle) == 2 }) + .unwrap_or(false); + if net_socket || tls_socket { + true_val + } else { + false_val + } + } else { + false_val + }; + } + if class_id == crate::fs::CLASS_ID_FS_STATS_EXPORT { + return if crate::fs::is_fs_stats_instance_value(value) { + true_val + } else { + false_val + }; + } + if class_id == crate::fs::CLASS_ID_FS_DIR { + return if crate::fs::is_fs_dir_instance_value(value) { + true_val + } else { + false_val + }; + } + if class_id == crate::fs::CLASS_ID_FS_DIRENT { + return if crate::fs::is_fs_dirent_instance_value(value) { + true_val + } else { + false_val + }; + } + if class_id == crate::fs::CLASS_ID_FS_READ_STREAM { + return if crate::fs::is_fs_stream_instance_value(value, "ReadStream") { + true_val + } else { + false_val + }; + } + if class_id == crate::fs::CLASS_ID_FS_WRITE_STREAM { + return if crate::fs::is_fs_stream_instance_value(value, "WriteStream") { + true_val + } else { + false_val + }; + } + if class_id == crate::fs::CLASS_ID_FS_UTF8_STREAM { + return if crate::fs::is_fs_stream_instance_value(value, "Utf8Stream") { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_CRYPTO { + return if is_native_module_namespace_value(value, "crypto.webcrypto") { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_SUBTLE_CRYPTO { + return if is_native_module_namespace_value(value, "crypto.subtle") { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_CRYPTO_KEY { + let addr = value_addr(value); + return if addr != 0 && crate::buffer::crypto_key_meta(addr).is_some() { + true_val + } else { + false_val + }; + } + + let bits = value.to_bits(); + let jsval = crate::JSValue::from_bits(bits); + + // Native/exotic subclass instances (typed arrays, ArrayBuffers, boxed + // primitives, Dates, …) do not carry a Perry `ObjectHeader.class_id`. + // Their constructor records the distinct newTarget prototype in the + // prototype side table instead. Honor that chain for user class ids. + if is_class_id_registered(class_id) { + let addr = value_addr(value); + if addr != 0 && super::prototype_chain::object_static_prototype(addr).is_some() { + let constructor = super::class_constructor_ref_value(class_id); + return if ordinary_has_instance_prototype_walk(value, constructor) { + true_val + } else { + false_val + }; + } + } + + // Special handling for Uint8Array/Buffer (class_id 0xFFFF0004) + // Perry buffers are raw BufferHeader pointers bitcast to f64 (not NaN-boxed), + // so the normal POINTER_TAG check doesn't work for them. + // We use a thread-local buffer registry to identify buffer pointers. + if class_id == crate::buffer::BUFFER_TYPE_ID { + // Check if NaN-boxed pointer + if jsval.is_pointer() { + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::buffer::is_registered_buffer(addr) { + return true_val; + } + } + // Check if raw pointer (buffer values are bitcast, not NaN-boxed) + let top16 = (bits >> 48) as u16; + if top16 == 0 && bits >= 0x1000 && crate::buffer::is_registered_buffer(bits as usize) { + return true_val; + } + return false_val; + } + + // ArrayBuffer — Perry models ArrayBuffer storage with BufferHeader values + // marked in a side registry. They can arrive either NaN-boxed or as raw + // buffer pointers, matching the Buffer/Uint8Array path above. + const CLASS_ID_ARRAY_BUFFER: u32 = 0xFFFF0025; + const CLASS_ID_SHARED_ARRAY_BUFFER: u32 = 0xFFFF002E; + if class_id == CLASS_ID_ARRAY_BUFFER || class_id == CLASS_ID_SHARED_ARRAY_BUFFER { + let addr = if jsval.is_pointer() { + (bits & 0x0000_FFFF_FFFF_FFFF) as usize + } else { + let top16 = (bits >> 48) as u16; + if top16 == 0 && bits >= 0x1000 { + bits as usize + } else { + 0 + } + }; + let matches_brand = if class_id == CLASS_ID_SHARED_ARRAY_BUFFER { + crate::buffer::is_shared_array_buffer(addr) + } else { + crate::buffer::is_array_buffer(addr) + }; + if addr != 0 && crate::buffer::is_registered_buffer(addr) && matches_brand { + return true_val; + } + return false_val; + } + + // #1545: Web Streams `instanceof ReadableStream` / `instanceof + // WritableStream`. Stream handles are numeric `id as f64`, so consult the + // stdlib kind-probe (1 = readable, 2 = writable) rather than the class + // chain. Covers `ts.readable instanceof ReadableStream`, + // `rs.pipeThrough(ts) instanceof ReadableStream`, etc. + // kind probe values: 1 = readable, 2 = writable, 5 = transform + // (3 = reader, 4 = writer — not user-facing instanceof targets here). + const CLASS_ID_READABLE_STREAM: u32 = 0xFFFF0060; + const CLASS_ID_WRITABLE_STREAM: u32 = 0xFFFF0061; + const CLASS_ID_TRANSFORM_STREAM: u32 = 0xFFFF0062; + if class_id == CLASS_ID_READABLE_STREAM + || class_id == CLASS_ID_WRITABLE_STREAM + || class_id == CLASS_ID_TRANSFORM_STREAM + { + if value.is_finite() && value > 0.0 && value.fract() == 0.0 { + if let Some(probe) = crate::object::stream_handle_kind_probe() { + let kind = unsafe { probe(value as usize) }; + let want = match class_id { + CLASS_ID_READABLE_STREAM => 1, + CLASS_ID_WRITABLE_STREAM => 2, + _ => 5, // CLASS_ID_TRANSFORM_STREAM + }; + if kind == want { + return true_val; + } + } + } + return false_val; + } + + // WHATWG fetch: `instanceof Response` / `Request` / `Headers` / `Blob` / + // `File`. + // These are pointer-tagged small-integer handles (stdlib fetch registries), + // not heap objects, so consult the stdlib fetch kind-probe rather than the + // class chain. Without this, Hono's `res instanceof Response` route-fallback + // guard sees `false` and skips the fallback, escaping a bare sentinel. + const CLASS_ID_RESPONSE: u32 = 0xFFFF0028; + const CLASS_ID_REQUEST: u32 = 0xFFFF0029; + const CLASS_ID_HEADERS: u32 = 0xFFFF002A; + const CLASS_ID_BLOB: u32 = 0xFFFF0026; + const CLASS_ID_FILE: u32 = 0xFFFF002F; + if class_id == CLASS_ID_RESPONSE + || class_id == CLASS_ID_REQUEST + || class_id == CLASS_ID_HEADERS + || class_id == CLASS_ID_BLOB + || class_id == CLASS_ID_FILE + { + let want = match class_id { + CLASS_ID_RESPONSE => 1u8, + CLASS_ID_REQUEST => 2, + CLASS_ID_HEADERS => 3, + CLASS_ID_BLOB => 4, + _ => 5, // CLASS_ID_FILE + }; + if let Some(handle) = small_native_handle_id(value) { + if let Some(probe) = crate::object::fetch_handle_kind_probe() { + let kind = unsafe { probe(handle as usize) }; + // File inherits Blob, so a File handle satisfies both brands. + if kind == want || (class_id == CLASS_ID_BLOB && kind == 5) { + return true_val; + } + } + } + // `class X extends Request/Response` instance: a heap object that + // stashes the underlying native fetch handle id under + // `__perry_fetch_handle__`. Unwrap and probe so `sub instanceof + // Request` is true, matching a bare handle. + if jsval.is_pointer() { + let raw = jsval.as_pointer::() as usize; + if let Some(id) = unsafe { crate::object::fetch_subclass_handle_id(raw) } { + if let Some(probe) = crate::object::fetch_handle_kind_probe() { + let kind = unsafe { probe(id as usize) }; + if kind == want || (class_id == CLASS_ID_BLOB && kind == 5) { + return true_val; + } + } + } + } + // A Blob can also be a real heap object allocated with CLASS_ID_BLOB + // (e.g. `stream/consumers`.`blob()` and `blob_value_from_bytes`), not + // just a small fetch-registry handle. Match it by its own class id so + // `blob instanceof Blob` is true for that representation too. + if class_id == CLASS_ID_BLOB && jsval.is_pointer() { + let obj = jsval.as_pointer::(); + if crate::value::addr_class::is_above_handle_band(obj as usize) + && unsafe { (*obj).class_id } == CLASS_ID_BLOB + { + return true_val; + } + } + return false_val; + } + + // Built-in JS types Map / Set / RegExp / Date — Perry doesn't define + // user classes for these, so we use reserved class IDs and detect via + // the per-type registries (MAP_REGISTRY / SET_REGISTRY / REGEX_POINTERS) + // or, for Date, by checking that the value is a finite f64 timestamp. + const CLASS_ID_DATE: u32 = 0xFFFF0020; + const CLASS_ID_REGEXP: u32 = 0xFFFF0021; + const CLASS_ID_MAP: u32 = 0xFFFF0022; + const CLASS_ID_SET: u32 = 0xFFFF0023; + if class_id == CLASS_ID_DATE { + // A Perry Date is a NaN-boxed pointer to a `DateCell` (#2089). Its + // identity is the cell's `GcHeader` type, so `new Date(NaN)` (an + // Invalid Date — a cell whose time value is NaN) matches just like + // any other Date, and a plain number never matches. + if crate::date::is_date_value(value) { + return true_val; + } + return false_val; + } + if class_id == CLASS_ID_MAP { + if jsval.is_pointer() { + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::map::is_registered_map(addr) { + return true_val; + } + } + return false_val; + } + if class_id == CLASS_ID_SET { + if jsval.is_pointer() { + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::set::is_registered_set(addr) { + return true_val; + } + } + return false_val; + } + // #5834: `x instanceof WeakMap`/`WeakSet` for a REAL instance. These + // reserved ids (kept in sync with perry-codegen/src/expr/instance_misc1.rs) + // are distinct from the runtime `CLASS_ID_WEAKMAP`/`CLASS_ID_WEAKSET` + // stamped on actual instances (weakref.rs) — the subclass-chain walk above + // only matches a `class S extends WeakMap {}` instance (whose chain reaches + // this reserved id), so a genuine `new WeakMap()` still needs its own probe + // here, same shape as Map/Set above. + const CLASS_ID_WEAKMAP_RESERVED: u32 = 0xFFFF002C; + const CLASS_ID_WEAKSET_RESERVED: u32 = 0xFFFF002D; + if class_id == CLASS_ID_WEAKMAP_RESERVED { + return if crate::object::weak_class_id_from_receiver(value) + == Some(crate::weakref::CLASS_ID_WEAKMAP) + { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_WEAKSET_RESERVED { + return if crate::object::weak_class_id_from_receiver(value) + == Some(crate::weakref::CLASS_ID_WEAKSET) + { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_REGEXP { + if jsval.is_pointer() { + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::regex::is_regex_pointer(addr as *const u8) { + return true_val; + } + } + return false_val; + } + if class_id == CLASS_ID_PROMISE { + if let Some(matches) = recorded_prototype_instanceof_builtin(value, "Promise") { + return if matches { true_val } else { false_val }; + } + return if crate::promise::js_value_is_promise(value) != 0 { + true_val + } else { + false_val + }; + } + + // `Object` — ECMAScript spec: `x instanceof Object` is true for any + // non-primitive (every object/array/function/Map/Set/Buffer/RegExp/ + // Date/typed-array/Promise/etc.). The codegen maps `Object` to this + // reserved id (#585 follow-up: pre-#585 fix this case worked by + // accident because the codegen produced `class_id = 0` and the + // runtime returned true via `0 == 0` on the obj_class_id check). + const CLASS_ID_OBJECT: u32 = 0xFFFF0050; + if class_id == CLASS_ID_OBJECT { + if jsval.is_pointer() { + // A Symbol is a POINTER_TAG heap allocation but a PRIMITIVE, not an + // object, so `Symbol() instanceof Object` is false (the comment + // above says "any non-primitive"). Every other primitive is + // non-pointer-tagged and already falls through below. #6587 review. + if unsafe { crate::symbol::js_is_symbol(value) != 0 } { + return false_val; + } + // Covers every heap object, including a Date (now a NaN-boxed + // `DateCell` pointer — #2089) and an Invalid Date. + return true_val; + } + let top16 = (bits >> 48) as u16; + if top16 == 0 && bits >= 0x1000 { + let addr = bits as usize; + if crate::buffer::is_registered_buffer(addr) + || crate::set::is_registered_set(addr) + || crate::map::is_registered_map(addr) + || crate::typedarray::lookup_typed_array_kind(addr).is_some() + { + return true_val; + } + } + return false_val; + } + + // Array — Perry arrays are heap allocations with `GC_TYPE_ARRAY` in + // their gc_header (one byte at obj-8). Pointer can arrive NaN-boxed + // (POINTER_TAG) or as a raw bitcast f64; handle both. Lazy arrays + // (Phase 5 JSON.parse result) are also arrays from the user's + // perspective — must return true without force-materializing. + const CLASS_ID_ARRAY: u32 = 0xFFFF0024; + if class_id == CLASS_ID_ARRAY { + // A POINTER_TAG handle id (fetch/zlib/stdlib registries) is not a heap + // address; the canonical header read rejects it instead of probing the + // byte below it. + let is_array = unsafe { crate::value::addr_class::try_read_gc_header(value_addr(value)) } + .is_some_and(|header| { + header.obj_type == crate::gc::GC_TYPE_ARRAY + || header.obj_type == crate::gc::GC_TYPE_LAZY_ARRAY + }); + return if is_array { true_val } else { false_val }; + } + + // Typed arrays — Int8Array..Float16Array reserved IDs (0xFFFF0030..3B). + // The pointer can arrive as either a NaN-boxed POINTER_TAG value or a + // raw bitcast f64, so handle both forms. + if (0xFFFF0030..=0xFFFF003B).contains(&class_id) { + let addr = if jsval.is_pointer() { + (bits & 0x0000_FFFF_FFFF_FFFF) as usize + } else { + let top16 = (bits >> 48) as u16; + if top16 == 0 && bits >= 0x1000 { + bits as usize + } else { + 0 + } + }; + if addr != 0 { + if let Some(actual_kind) = crate::typedarray::lookup_typed_array_kind(addr) { + let want_id = crate::typedarray::class_id_for_kind(actual_kind); + if want_id == class_id { + return true_val; + } + } + } + return false_val; + } + + // Only objects (pointers) can be instances of classes + if !jsval.is_pointer() { + return false_val; + } + + // Get the object pointer + let obj_ptr = jsval.as_pointer::(); + if obj_ptr.is_null() { + return false_val; + } + + // Refs #421: NaN-boxed POINTER_TAG values whose unboxed payload is a + // small registry id (Web Fetch handles, sockets, DB connections, etc.) + // are NOT real ObjectHeader pointers — reading the GC header at + // `obj_ptr - 8` would SIGSEGV on unmapped memory. They aren't instances + // of any user-defined class either, so return false unconditionally. + if crate::value::addr_class::is_handle_band(obj_ptr as usize) { + return false_val; + } + + unsafe { + // Special handling for built-in Error and its subclasses (TypeError, RangeError, etc.). + // ErrorHeader uses GC_TYPE_ERROR; we match by error_kind against the requested CLASS_ID_*. + let gc_header = + (obj_ptr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + let gc_type = (*gc_header).obj_type; + if gc_type == crate::gc::GC_TYPE_ERROR { + let err_ptr = obj_ptr as *const crate::error::ErrorHeader; + let kind = (*err_ptr).error_kind; + if class_id == crate::event_target::CLASS_ID_DOM_EXCEPTION { + return if crate::event_target::is_dom_exception_error(err_ptr) { + true_val + } else { + false_val + }; + } + let builtin_name = match class_id { + crate::error::CLASS_ID_ERROR => Some("Error"), + crate::error::CLASS_ID_TYPE_ERROR => Some("TypeError"), + crate::error::CLASS_ID_RANGE_ERROR => Some("RangeError"), + crate::error::CLASS_ID_REFERENCE_ERROR => Some("ReferenceError"), + crate::error::CLASS_ID_SYNTAX_ERROR => Some("SyntaxError"), + crate::error::CLASS_ID_EVAL_ERROR => Some("EvalError"), + crate::error::CLASS_ID_URI_ERROR => Some("URIError"), + crate::error::CLASS_ID_AGGREGATE_ERROR => Some("AggregateError"), + _ => None, + }; + if let Some(name) = builtin_name { + if let Some(matches) = recorded_prototype_instanceof_builtin(value, name) { + return if matches { true_val } else { false_val }; + } + } + return match class_id { + crate::error::CLASS_ID_ERROR => true_val, + crate::error::CLASS_ID_TYPE_ERROR => { + if kind == crate::error::ERROR_KIND_TYPE_ERROR { + true_val + } else { + false_val + } + } + crate::error::CLASS_ID_RANGE_ERROR => { + if kind == crate::error::ERROR_KIND_RANGE_ERROR { + true_val + } else { + false_val + } + } + crate::error::CLASS_ID_REFERENCE_ERROR => { + if kind == crate::error::ERROR_KIND_REFERENCE_ERROR { + true_val + } else { + false_val + } + } + crate::error::CLASS_ID_SYNTAX_ERROR => { + if kind == crate::error::ERROR_KIND_SYNTAX_ERROR { + true_val + } else { + false_val + } + } + crate::error::CLASS_ID_EVAL_ERROR => { + if kind == crate::error::ERROR_KIND_EVAL_ERROR { + true_val + } else { + false_val + } + } + crate::error::CLASS_ID_URI_ERROR => { + if kind == crate::error::ERROR_KIND_URI_ERROR { + true_val + } else { + false_val + } + } + crate::error::CLASS_ID_AGGREGATE_ERROR => { + if kind == crate::error::ERROR_KIND_AGGREGATE_ERROR { + true_val + } else { + false_val + } + } + _ => false_val, + }; + } + + if gc_type == crate::gc::GC_TYPE_OBJECT { + if let Some(matches) = + crate::perf_hooks::is_perf_hooks_shape_instance_of(value, class_id) + { + return if matches { true_val } else { false_val }; + } + if let Some(matches) = + crate::perf_hooks::is_perf_entry_object_instance_of(obj_ptr, class_id) + { + return if matches { true_val } else { false_val }; + } + } + + // For user-defined classes that extend Error: `myErr instanceof Error` should be true. + if class_id == crate::error::CLASS_ID_ERROR { + // #9940: a function-local class declaration gets a fresh class + // object on every evaluation, but all evaluations share its + // compile-time class id. A constructor factory can therefore + // evaluate `class Definition extends Error {}`, then later + // evaluate the same declaration with an Object parent. The class + // registry is keyed by the shared id and is necessarily + // last-wins; the instance's recorded evaluation prototype is the + // authoritative chain. Zod's `$constructor` has exactly this + // shape, and its later schema classes made an earlier ZodError + // fail `instanceof Error` even though getPrototypeOf still showed + // `ZodError -> Error -> Object`. + if let Some(matches) = recorded_prototype_instanceof_builtin(value, "Error") { + return if matches { true_val } else { false_val }; + } + } + + // Everything below reads `ObjectHeader::class_id`, which only a + // genuine `GC_TYPE_OBJECT` has. Every other GC type keeps something + // else in that word — an array's `length`, a closure's function + // pointer, a Map's `size` — so `[1, 2] instanceof C` was true whenever + // the length equalled (or chained to) `C`'s class id. + if gc_type != crate::gc::GC_TYPE_OBJECT { + return false_val; + } + + if class_id == crate::error::CLASS_ID_ERROR { + let obj_class_id = (*obj_ptr).class_id; + if extends_builtin_error(obj_class_id) { + return true_val; + } + } + + // Check if the object's class_id matches directly + let obj_class_id = (*obj_ptr).class_id; + if class_id == crate::event_target::CLASS_ID_EVENT + && obj_class_id == crate::event_target::CLASS_ID_CUSTOM_EVENT + { + return true_val; + } + // Walk up the inheritance chain using the class registry. #7575: the + // walk also follows the generic-origin edge, so a dynamic RHS holding a + // generic class (`const C = Gen; x instanceof C`) matches an instance of + // one of its specializations. + if class_chain_reaches(obj_class_id, class_id) { + return true_val; + } + + false_val + } +} From 5a3c3e6b59b4e34b89cbbdf8c346c97dfdac3075 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 18:24:01 +0000 Subject: [PATCH 27/30] chore: update addr-class gates for the instanceof.rs split A pure file-relocation moves grandfathered addr_class findings to a new path, and both of this audit's mechanisms are path-keyed: - scripts/addr_class_ratchet_baseline.txt's handle-floor count for instanceof.rs (6) redistributes to instanceof.rs (2, still there) and the new instanceof/static_dispatch.rs (4, moved with js_instanceof) -- regenerated via --write-baseline and diffed to confirm no other file's baseline changed. - scripts/addr_class_allowlist.txt gets a new instanceof/static_dispatch.rs entry for the one GcHeader cast that moved there, following the same precedent already recorded for array/indexing_keyed.rs and array/indexing_proto_chain.rs's own 2,000-line-cap splits. Also cargo fmt: a double blank line left behind by the extraction. --- crates/perry-runtime/src/object/instanceof.rs | 1 - scripts/addr_class_allowlist.txt | 1 + scripts/addr_class_ratchet_baseline.txt | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 525ee24b15..6a638a6fe9 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -175,7 +175,6 @@ fn builtin_ctor_class_id_from_value(type_ref: f64) -> Option { Some(class_id) } - /// Runtime class id for a globalThis built-in constructor *name*. /// /// Reference-type global constructors used as runtime values (e.g. diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index ce58bea327..5999b574ff 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -95,6 +95,7 @@ crates/perry-runtime/src/object/field_get_set.rs | * | pre-existing GcHeader pro crates/perry-runtime/src/object/field_set_by_name | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/object/global_this.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/object/instanceof.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up +crates/perry-runtime/src/object/instanceof/static_dispatch.rs | * | same pre-existing GcHeader probe, moved from instanceof.rs by the 2,000-line file split (js_instanceof); migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/object/mod.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/object/native_call_method.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/object/object_ops.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index 50de5d427e..4a9ee37fb0 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -125,7 +125,8 @@ handle-floor | crates/perry-runtime/src/object/field_set_by_name/write_helpers.r handle-floor | crates/perry-runtime/src/object/global_this/array_error.rs | 1 handle-floor | crates/perry-runtime/src/object/global_this/ctor_thunks.rs | 1 handle-floor | crates/perry-runtime/src/object/global_this/typed_array.rs | 4 -handle-floor | crates/perry-runtime/src/object/instanceof.rs | 6 +handle-floor | crates/perry-runtime/src/object/instanceof.rs | 2 +handle-floor | crates/perry-runtime/src/object/instanceof/static_dispatch.rs | 4 handle-floor | crates/perry-runtime/src/object/mod.rs | 1 handle-floor | crates/perry-runtime/src/object/native_call_method.rs | 3 handle-floor | crates/perry-runtime/src/object/native_call_method/collection_methods.rs | 2 From 6ce4f4714e14f0d55b4e81711a56722d4424028e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:47:31 +0200 Subject: [PATCH 28/30] docs(changelog): add fragment for #10603 (CJS factory entry outlining) --- .../10603-cjs-factory-entry-outline.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 changelog.d/10603-cjs-factory-entry-outline.md diff --git a/changelog.d/10603-cjs-factory-entry-outline.md b/changelog.d/10603-cjs-factory-entry-outline.md new file mode 100644 index 0000000000..660269e605 --- /dev/null +++ b/changelog.d/10603-cjs-factory-entry-outline.md @@ -0,0 +1,86 @@ +perf(size): outline the CommonJS factory body, not just `hir.init` (#10575). +#8595's entry outliner only ever split `hir.init`, so a CommonJS module's body +was never outlined at all. `cjs_wrap::wrap_commonjs_for_target` wraps a CJS body +as *text* inside `function __perry_cjs_factory() { ... }`, nested in an +anonymous IIFE, before the parse/lower pipeline ever sees it. Lowering +represents that lexically-nested declaration the way it does any function +*expression* — a `Stmt::Let` naming an `Expr::Closure` inside `hir.init`'s own +expression tree, not a `hir.functions` entry — leaving `hir.init` itself with +only the handful of statements the wrapper adds at module scope (the `_cjs` +binding, `export default`, …). Automatic admission needs 1,000 top-level +statements or 4,000 estimated safepoints, so it could never fire on CJS no +matter how large the module was. On `typescript@5.9.3`'s `lib/_tsc.js` the real +body therefore stayed a single 463,716-instruction / 6.30 MB closure — past the +100,000-instruction optimized machine-pipeline budget, so the whole unit was +emitted through LLVM's O0 machine pipeline — and the linked binary contained +zero `__perry_entry_chunk_*` symbols. + +`find_cjs_factory_closure`/`find_cjs_factory_closure_mut` locate that closure by +walking `hir.init`'s statement/expression tree. `outline_entry_module` now tries +`hir.init` first (unchanged #8595 behaviour) and, only if that is not a +candidate, falls back to the factory's body using the *identical* +`chunk_statements`/`analyze_stmts_outlining` machinery: same admission +thresholds, same fail-safe gates (top-level await, TDZ preallocation), same +`__perry_entry_chunk_*` naming, and each chunk still carries its origin body's +strictness. A module is only ever outlined from one origin per compile, +`hir.init` or the factory, never both. The factory is treated as a virtual +module entry only when it has the exact wrap-generated shape — no params, not +async, not a generator, and every id it captures resolving to a `Let` directly +in the enclosing IIFE — so failing the shape check is a defensive exit rather +than an expected one. + +The one new concept is must-stay ids. The factory always captures exactly one id +from its enclosing scope: its own name. The preamble that opens the factory's +own body builds `const __cjs_module = { …, __perry_cjs_factory: +__perry_cjs_factory, … }`, a load-bearing self-reference that `perry-runtime`'s +`module_require.rs` reads back off the CJS record and invokes with +`js_closure_call0` on an already-loaded re-require path. A chunk is a plain, +non-capturing `hir.functions` entry and cannot read a captured id the way the +original closure could, so `classify_for_chunking` keeps any statement +referencing a captured id inline in the residual body, never relocating it into +a chunk, preserving the exact closure-capture codegen already emitted for it. +This is deliberately *not* solved by promoting the captured id to a module +global the way a cross-chunk `hir.init` let is: a global is one program-wide +instance, but a closure capture is fresh per invocation, so promoting it would +silently change re-invocation semantics on that recovery path. `hir.init`'s own +outlining passes an empty must-stay set, which short-circuits, so this is a +no-op for every non-CJS module. Alongside it, `emit_module_globals` folds an +outlined factory's logical statements into the same cross-chunk promotion it +already does for `hir.init`, so a `var` shared across the factory's new chunks +gets the same `@perry_global_*` treatment a cross-chunk `hir.init` `let` gets. + +Measured on the issue's own repro, a full `typescript@5.9.3` build: the linked +binary now carries real chunk-derived symbols (`…_entry_chunk_…_0` through +`_19`, plus wrapper trampolines) where none existed before, and the single +463,716-instruction closure no longer appears anywhere in the build log. +Behaviour is unchanged — `--noEmit` over a type-error fixture is byte-identical +to `node node_modules/typescript/lib/_tsc.js` with the same exit code, and +`--version` still reports 5.9.3. + +Honest caveat on size for this particular input: two individual chunks are still +around 190k and 396k instructions (down from one 463,716-instruction +whole-factory function), because a few of `_tsc.js`'s top-level statements are +themselves large enough to remain oversized even isolated into their own chunk — +sub-statement granularity is not attempted here — and the linked binary grew +from 86.4 MB to 100.0 MB, since ~20 function prologues and their GC-safepoint +scaffolding are not fully offset while a couple of chunks still hit the O0 +fallback. Correctness is unaffected either way. A synthetic CJS fixture without +`_tsc.js`'s size-outlier statements went 11.0 MB → 9.2 MB and escaped the O0 +fallback entirely. + +Validated with `cargo test -p perry-codegen --lib` (1,598 passed), including +five new `entry_outline` tests: factory-shape matching and rejection +(param/async/generator/unresolvable-capture/wrong-name/no-wrapper), factory +outlining when `hir.init` is not a candidate, `hir.init` still winning when both +sides independently qualify, the self-reference-capture-stays-inline invariant, +and declining cleanly when neither side qualifies. The regression was proved +reachable rather than assumed: against pristine `origin/main`, a synthetic +1,200-statement CJS-factory-shaped module returned `Skipped("below automatic +outlining threshold")`. A synthetic 2,107-statement CJS fixture (cross-chunk +`var`s plus a closure created early and invoked from far-later statements) +produced output matching Node's own execution of the same file exactly, both +with outlining forced on and under `PERRY_OUTLINE_ENTRY=0` — where the disable +path reproduces the original 211,164-instruction / O0 pathology, confirming the +kill switch still works. `cjs_wrap_builtin_require` and +`issue_4872_barrel_default_reexports` pass unchanged, confirming small and +ordinary CJS modules are unaffected. From 2722e6839c4b60672dd2af92a38ff20ae6ab3d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 13:08:42 +0000 Subject: [PATCH 29/30] changelog: add fragment for #10620 --- .../10620-timer-refresh-ref-and-immediate-primitive.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/10620-timer-refresh-ref-and-immediate-primitive.md diff --git a/changelog.d/10620-timer-refresh-ref-and-immediate-primitive.md b/changelog.d/10620-timer-refresh-ref-and-immediate-primitive.md new file mode 100644 index 0000000000..35790bacd7 --- /dev/null +++ b/changelog.d/10620-timer-refresh-ref-and-immediate-primitive.md @@ -0,0 +1,7 @@ +**`timeout.refresh()` no longer re-refs an `unref()`'d timer, and `+setImmediate(...)` is `NaN` again** (#10541, #10542). + +`js_timer_refresh` called `set_timer_ref_state(id, true)` unconditionally after rescheduling both the Timeout and the Interval branch. Node's `Timeout.refresh()` resets the start time and reschedules using the original delay, but never touches ref state — an `unref()`'d timer that gets `refresh()`'d (a common idle-timeout/debounce pattern in HTTP agents, database pools and caches) is supposed to stay `unref()`'d. Perry's forced write meant `hasRef()` flipped back to `true` on refresh, so the timer kept the process alive and eventually ran a callback the program had deliberately detached from the event loop. The fix drops both forced writes; the id's ref-state entry (set at schedule time, updated by any `ref()`/`unref()` call since, and pinned by `ScheduledTimerId` while the timer is queued, per #10447) is left alone. + +`js_number_coerce`'s timer-handle numeric-conversion shortcut (`+t` → the handle's internal id) was gated only on `is_known_timer_id`, which is true for both `Timeout` and `Immediate` handles. Node gives `Timeout` (`setTimeout`/`setInterval`) a numeric conversion but not `Immediate` (`setImmediate`) — `+setImmediate(...)` is `NaN` in Node, a number in Perry. Added `crate::timer::is_immediate_timer_id`, a thin wrapper over the existing kind registry, and gated the shortcut on it; an `Immediate` now falls through to the pre-existing generic `toPrimitive`/`toString` path, which already stringifies to `"[object Object]"` and coerces to `NaN` — that path was simply unreachable for timer handles before. + +Validation: new gap test `test_gap_10541_10542_timer_refresh_ref_immediate_primitive` matches Node 26.5.1 byte for byte and reproduces both bugs on the pre-fix build (an unref'd timer re-refs after `refresh()` and its detached callback fires; `+immediate` is a number, not `NaN`). New `perry-runtime` unit tests cover `js_timer_refresh` preserving ref state for a Timeout, a ref'd Timeout, and an Interval, and `is_immediate_timer_id` distinguishing all three handle kinds. `RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --tests timer`: 32/32 pass. Instructions on a refresh()+`+timeout`-coercion churn loop (2,000,000 iterations): −0.34% vs the pre-fix build (both touched functions do slightly less work — one fewer ref-state write per `refresh()`, one added but cold kind check in `js_number_coerce`). From d0905b8eeb1ec2bd3fba44ddb1ea21adbbad1114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 08:25:56 +0200 Subject: [PATCH 30/30] chore: release merge train 219 as v0.5.1597 --- 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 d055ea528a..dd0386da48 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.1596 +**Current Version:** 0.5.1597 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 0894a6f6d7..7693bb21b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1596" +version = "0.5.1597" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1596" +version = "0.5.1597" [[package]] name = "perry-parser" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1596" +version = "0.5.1597" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1596" +version = "0.5.1597" [[package]] name = "perry-ui-tvos" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1596" +version = "0.5.1597" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 32c668115e..baed25af0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1596" +version = "0.5.1597" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"