diff --git a/crates/perry-codegen/src/expr/call_spread.rs b/crates/perry-codegen/src/expr/call_spread.rs index f4f765b5e9..d11d3f2bcb 100644 --- a/crates/perry-codegen/src/expr/call_spread.rs +++ b/crates/perry-codegen/src/expr/call_spread.rs @@ -305,6 +305,61 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } + // Computed-member method call with spread: `recv[key](...args)`. + // The literal `recv.method(...args)` shape is handled by the + // PropertyGet arm above; the computed sibling lowers to a + // `Call`/`CallSpread` with an `IndexGet` callee. Without this arm it + // fell through to the closure-callee path below, which lowers + // `recv[key]` to a bare method VALUE and calls it with no `this` — + // so the method observed `this` = a field-less prototype stub + // (missing instance data fields AND inherited methods). This is the + // spread counterpart of the non-spread `js_native_call_method_{str_key, + // value}` routing in `lower_call/early_branches.rs`. Bundle every + // regular + spread arg into one array, then dispatch through + // `js_native_call_method_value_apply`, which resolves the method by + // the runtime key and binds `this = recv`. Skip a numeric index on a + // non-class receiver (`arr[i](...)` array-element call), mirroring + // the non-spread path, so element-call semantics are unchanged. + if let Expr::IndexGet { object, index } = callee.as_ref() { + let object_is_class_ref = matches!(object.as_ref(), Expr::ClassRef(_)) + || matches!(object.as_ref(), Expr::ExternFuncRef { name, .. } if ctx.class_ids.contains_key(name)); + if !(crate::type_analysis::is_numeric_expr(ctx, index) && !object_is_class_ref) { + let recv_box = lower_expr(ctx, object)?; + let key_box = lower_expr(ctx, index)?; + let mut acc_handle = ctx.block().call(I64, "js_array_alloc", &[(I32, "0")]); + for a in args { + match a { + CallArg::Expr(e) => { + let v = lower_expr(ctx, e)?; + acc_handle = ctx.block().call( + I64, + "js_array_push_f64", + &[(I64, &acc_handle), (DOUBLE, &v)], + ); + } + CallArg::Spread(e) => { + let part_box = lower_expr(ctx, e)?; + let part_handle = ctx.block().call( + I64, + "js_array_like_to_array", + &[(DOUBLE, &part_box)], + ); + acc_handle = ctx.block().call( + I64, + "js_array_concat", + &[(I64, &acc_handle), (I64, &part_handle)], + ); + } + } + } + return Ok(ctx.block().call( + DOUBLE, + "js_native_call_method_value_apply", + &[(DOUBLE, &recv_box), (DOUBLE, &key_box), (I64, &acc_handle)], + )); + } + } + // Closure callee path: `cb(reg0, reg1, ..., ...spread)` where // `cb` is a closure value (not a known FuncRef). We lower the // callee to its NaN-boxed value, marshal regular args into a diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index d550d814d4..84c6d7de8b 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -197,13 +197,44 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // — the correct answer when the unknown side isn't a // string at runtime. let both_strings_check = is_string_expr(ctx, left) && is_string_expr(ctx, right); + // The non-statically-string operand collides through this + // fast path when, at runtime, it is ALSO a non-string. Both + // operands then funnel through `js_get_string_pointer_unified`, + // which returns 0 for any non-string NaN-boxed value (numbers, + // class refs / InjectionTokens, plain objects, …). The + // subsequent `js_string_equals(0, 0)` returns 1 (its + // pointer-identity / both-null branches both report "equal"), + // so two *distinct* non-string values wrongly compare `===`. + // + // This is exactly the NestJS DI `token === name` bug: + // `name` is statically `string` (the destructured + // `dependencyContext.name`) but at runtime holds a class ref + // (e.g. `AppService`), and `token` is `any` holding a + // *different* class ref (`AppController`) — both coerce to 0 + // and the inline `===` reports `true`, throwing + // `UnknownDependencies` and aborting the app. + // + // The static `string` type is therefore a lie here (like the + // #3576 number-vs-object case). When the OTHER operand is + // statically `Any` (its runtime value is unconstrained and may + // be a non-string), this fast path is unsound: route through + // `js_eq`, which content-compares real strings (SSO + heap) AND + // correctly distinguishes class refs / objects by identity. + let other_side_is_any = |other: &Expr| -> bool { + matches!( + crate::type_analysis::static_type_of(ctx, other), + Some(HirType::Any) | None + ) + }; let one_side_string = !both_strings_check && ((is_string_expr(ctx, left) && !is_numeric_expr(ctx, right) - && !is_bool_expr(ctx, right)) + && !is_bool_expr(ctx, right) + && !other_side_is_any(right)) || (is_string_expr(ctx, right) && !is_numeric_expr(ctx, left) - && !is_bool_expr(ctx, left))); + && !is_bool_expr(ctx, left) + && !other_side_is_any(left))); // Only STRICT eq/ne use this string-pointer fast path. Loose `==`/`!=` // must fall through to `js_loose_eq` below: when one side is a boxed // String/primitive *wrapper* (a POINTER_TAG object, not a STRING_TAG diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index a0173b3b25..649db26d5c 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -172,6 +172,46 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Some(slot) => ctx.block().load(DOUBLE, &slot), None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; + // `class X extends Map | Set` with a spread super (`super(...args)`, + // e.g. NestJS's `ModulesContainer`'s `super(...arguments)`) — install + // the hidden collection backing from the (possibly spread) args + // array instead of dispatching the uncallable builtin ctor. The + // first array element (if any) is the iterable; `js_map_from_iterable` + // / `js_set_from_iterable` ignore extra elements. Mirrors the + // non-spread `Expr::SuperCall` Map/Set arm. + let map_set_kind = ctx + .classes + .get(¤t_class_name) + .and_then(|c| c.extends_name.as_deref()) + .and_then(|p| match p { + "Map" => Some(0i32), + "Set" => Some(1i32), + _ => None, + }); + if let Some(kind) = map_set_kind { + let blk = ctx.block(); + let arr_box = nanbox_pointer_inline(blk, &arr); + let zero_idx = "0".to_string(); + let first = + ctx.block() + .call(DOUBLE, "js_array_get_f64", &[(I64, &arr), (I32, &zero_idx)]); + let _ = arr_box; + ctx.block().call( + DOUBLE, + "js_map_set_subclass_init", + &[ + (DOUBLE, &this_box), + (I32, &kind.to_string()), + (DOUBLE, &first), + ], + ); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } if let Some(&child_cid) = ctx.class_ids.get(¤t_class_name) { let cid_str = child_cid.to_string(); let blk = ctx.block(); @@ -500,6 +540,52 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { )?; return Ok(result); } + // `class X extends Map` / `extends Set` — `super(iterable?)` + // allocates a real Map/Set backing store, stashes it on + // `this` under a hidden field, and installs the collection + // method surface (`has`/`get`/`set`/`delete`/`clear`/ + // `forEach`/`keys`/`values`/`entries`/`size`/`Symbol.iterator`) + // so a source-compiled subclass (e.g. NestJS's + // `ModulesContainer extends Map`) actually behaves as a Map. + // Perry models the instance as a plain object (not a real + // exotic Map), so without this `super()` was a no-op and + // `m.has(...)` threw "has is not a function". + let map_set_kind = match parent_name.as_str() { + "Map" => Some(0i32), + "Set" => Some(1i32), + _ => None, + }; + if let Some(kind) = map_set_kind { + let iterable = if let Some(first) = super_args.first() { + lower_expr(ctx, first)? + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }; + for a in super_args.iter().skip(1) { + let _ = lower_expr(ctx, a)?; + } + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; + ctx.block().call( + DOUBLE, + "js_map_set_subclass_init", + &[ + (DOUBLE, &this_box), + (I32, &kind.to_string()), + (DOUBLE, &iterable), + ], + ); + let current_class_name = + ctx.class_stack.last().cloned().unwrap_or_default(); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } // #5137: `class X extends EventEmitter` (node:events) — // `super()` installs the bare EventEmitter listener/emit // surface onto `this` (see `lower_event_emitter_subclass_init`). diff --git a/crates/perry-codegen/src/lower_call/console_promise.rs b/crates/perry-codegen/src/lower_call/console_promise.rs index c49134a114..5b9da88bec 100644 --- a/crates/perry-codegen/src/lower_call/console_promise.rs +++ b/crates/perry-codegen/src/lower_call/console_promise.rs @@ -737,12 +737,32 @@ pub fn try_lower_native_method_str_dispatch( // missing method as a non-callable property read and throw. | "__perry_using_check__" ); + // A `class X extends Map | Set` instance's collection methods + // (`has`/`get`/`set`/`delete`/`clear`/`forEach`/`keys`/`values`/ + // `entries` and the Set composition methods) are NOT class methods — + // they live on the hidden runtime backing installed by + // `js_map_set_subclass_init`. The static class-dispatch tower would read + // them as a non-callable property and throw "value is not a function", + // so route them through `js_native_call_method` (whose `dispatch_map_set` + // redirects onto the backing collection). Mirrors the + // `is_well_known_proto_method` carve-out. + // Only Map/Set subclasses get a runtime backing installed at `super()` + // (via `js_map_set_subclass_init`); WeakMap/WeakSet subclasses have NO + // backing, so leave their method calls on the NORMAL class-dispatch path + // instead of suppressing it toward a non-existent backing (which would + // also shadow a user's own override on a WeakMap/WeakSet subclass). + let is_collection_subclass_method = class_name_opt + .as_deref() + .and_then(|n| class_builtin_collection_kind(ctx, n)) + .filter(|kind| matches!(*kind, "Map" | "Set")) + .is_some_and(|kind| is_collection_method_for_kind(kind, property.as_str())); let skip_native = matches!(object.as_ref(), Expr::GlobalGet(_)) || matches!(object.as_ref(), Expr::NativeModuleRef(_)) || (class_name_opt.is_some() && !is_buffer_class && !class_unknown_to_codegen - && !is_well_known_proto_method); + && !is_well_known_proto_method + && !is_collection_subclass_method); if !skip_native { // Issue #92 fast path: intrinsify Buffer numeric reads // (`buf.readInt32BE(off)` etc.) when the receiver is a tracked @@ -853,6 +873,79 @@ fn is_message_port_closure_method(object: &Expr, property: &str) -> bool { /// call (those miss because the name lives in CLASS_STATIC_ACCESSORS, not /// CLASS_STATIC_METHODS). Returns `None` when `prop` is not a static accessor on /// the chain. Refs test262 language/arguments-object cls-*-static-* getter calls. +// +/// Returns WHICH builtin collection the class ultimately extends — i.e. a +/// source-compiled `class X extends Map {}` (instances get a hidden Map/Set +/// backing at `super()` via `js_map_set_subclass_init` and their collection +/// methods dispatch through the runtime, not the class vtable). The result is +/// (`"Map"`/`"Set"`/`"WeakMap"`/`"WeakSet"`), or `None`. Method routing is +/// kind-specific: `class M extends Map` +/// calling `.add()`/`.union()` (Set methods) must NOT be forced through the Map +/// backing (which returns `undefined`) — it should fall through to the normal +/// missing-method / user-method path. +pub fn class_builtin_collection_kind(ctx: &FnCtx<'_>, cls_name: &str) -> Option<&'static str> { + fn normalize(name: &str) -> Option<&'static str> { + match name { + "Map" => Some("Map"), + "Set" => Some("Set"), + "WeakMap" => Some("WeakMap"), + "WeakSet" => Some("WeakSet"), + _ => None, + } + } + let mut cur = Some(cls_name.to_string()); + let mut depth = 0usize; + while let Some(c) = cur { + if depth > 32 { + break; + } + let Some(ci) = ctx.classes.get(&c) else { + // The chain reached a name codegen doesn't track — it may be the + // builtin `Map`/`Set` heritage itself. + return normalize(c.as_str()); + }; + if let Some(parent) = ci.extends_name.as_deref() { + if let Some(kind) = normalize(parent) { + return Some(kind); + } + } + cur = ci.extends_name.clone(); + depth += 1; + } + None +} + +/// True when `method` is a backing-store collection method for `kind` (the +/// builtin a class extends). Map/WeakMap-only vs Set/WeakSet-only methods are +/// kept distinct so `class M extends Map` calling a Set method (`.add`, +/// `.union`, …) is NOT mis-routed onto the Map backing. +fn is_collection_method_for_kind(kind: &str, method: &str) -> bool { + let shared = matches!(method, "has" | "delete" | "clear" | "forEach"); + match kind { + "Map" => shared || matches!(method, "get" | "set" | "keys" | "values" | "entries"), + "WeakMap" => matches!(method, "has" | "get" | "set" | "delete"), + "Set" => { + shared + || matches!( + method, + "add" + | "keys" + | "values" + | "entries" + | "union" + | "intersection" + | "difference" + | "symmetricDifference" + | "isSubsetOf" + | "isSupersetOf" + | "isDisjointFrom" + ) + } + "WeakSet" => matches!(method, "has" | "add" | "delete"), + _ => false, + } +} + pub fn try_lower_class_static_accessor_call( ctx: &mut FnCtx<'_>, cls_name: &str, diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs index 11d23958fa..f88fac63c9 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs @@ -10,6 +10,7 @@ pub(crate) fn declare_streams_events(module: &mut LlModule) { // ========== node:stream stubs (issue #631) ========== module.declare_function("js_event_emitter_subclass_init", DOUBLE, &[DOUBLE]); // #5137 EE subclass init module.declare_function("js_array_subclass_init", DOUBLE, &[DOUBLE, DOUBLE]); // class extends Array + module.declare_function("js_map_set_subclass_init", DOUBLE, &[DOUBLE, I32, DOUBLE]); // class extends Map/Set module.declare_function("js_node_stream_readable_new", DOUBLE, &[DOUBLE]); module.declare_function( "js_node_stream_readable_subclass_init", diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 0c80f2c304..d70d728aac 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -848,6 +848,15 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { DOUBLE, &[DOUBLE, DOUBLE, PTR, I64], ); + // Apply form of obj[key](...args): runtime-value key + args as a JS array + // handle. Materialises the array and forwards to js_native_call_method_value + // (binds `this = obj`). Used by `Expr::CallSpread` for the computed-member + // `recv[prop](...args)` shape, which otherwise dropped `this`. + module.declare_function( + "js_native_call_method_value_apply", + DOUBLE, + &[DOUBLE, DOUBLE, I64], + ); module.declare_function("js_promise_resolve", VOID, &[I64, DOUBLE]); module.declare_function("js_promise_reject", VOID, &[I64, DOUBLE]); module.declare_function("js_promise_resolved", I64, &[DOUBLE]); diff --git a/crates/perry-ext-http-server/src/dispatch_ext.rs b/crates/perry-ext-http-server/src/dispatch_ext.rs new file mode 100644 index 0000000000..4f6d3d28c4 --- /dev/null +++ b/crates/perry-ext-http-server/src/dispatch_ext.rs @@ -0,0 +1,500 @@ +//! Runtime handle-dispatch EXTENSION registration for HTTP-server handles. +//! +//! ## Why this exists +//! +//! perry-stdlib's `js_handle_property_dispatch` / `js_handle_method_dispatch` +//! (and the property-SET twin) carry the HTTP-server handle arms behind the +//! `external-http-server-pump` Cargo feature (see +//! `perry-stdlib/src/common/dispatch/{property,method}_dispatch.rs`). That +//! feature is only compiled in when the *workspace* auto-optimize rebuilds +//! perry-stdlib for a program that imports `node:http`. +//! +//! An out-of-tree install — and `PERRY_NO_AUTO_OPTIMIZE=1` — instead links the +//! prebuilt `full` `libperry_stdlib.a`, which is built **without** +//! `external-http-server-pump`. In that build those dispatch arms are compiled +//! OUT, so an erased-receiver `req.url` / `res.end(...)` (the handler params are +//! `any`, so codegen emits a generic property-get / method-call that routes +//! through the runtime's `HANDLE_*_DISPATCH` slow path) finds no HTTP-server arm +//! and silently reads `undefined` / no-ops. The server binds and the handler +//! fires (the pump is already kept alive out-of-tree via the +//! `js_register_aux_pump` mechanism, #2532), but the request object is empty and +//! the response never flushes — the Wall-10 symptom. +//! +//! ## The fix +//! +//! perry-runtime exposes `js_register_handle_{property,method,property_set}_dispatch_extension` +//! (the same mechanism perry-ext-net uses, see `perry-ext-net/src/dispatch.rs`). +//! Registered extensions are consulted by the runtime's composite dispatcher +//! *before* the stdlib primary, regardless of which perry-stdlib features were +//! compiled. We register one extension per dispatch kind here; each probes the +//! handle against our registry and, for a name that is a genuine native member +//! of that handle type, forwards to the existing `js_ext_http_*_dispatch_*` +//! entry points. +//! +//! ## CRITICAL: gate on the native member-name lists +//! +//! A server-side `ServerResponse` / `IncomingMessage` handle is routinely +//! wrapped by a user prototype that ADDS methods — Express augments the response +//! prototype with `res.send` / `res.json` / `res.status` / … and the request +//! with `req.fresh` / `req.accepts` / …. Those methods live on a JS prototype +//! object in the chain above the native handle. If this extension claimed EVERY +//! name once the handle type matched, it would shadow `res.send` (returning +//! `undefined` instead of letting the prototype's real `send` run) and Express's +//! `res.send('...')` would silently no-op — exactly the bug this caused on the +//! first cut. So each name is matched against the SAME `matches!` vocabularies +//! perry-stdlib's gated arms use; an unrecognised name returns "not claimed" (0) +//! so the runtime falls through to the prototype-chain / user-method resolution. +//! Keep these lists in sync with +//! `perry-stdlib/src/common/dispatch/{method,property}_dispatch.rs`. + +use std::sync::Once; + +extern "C" { + fn js_register_handle_method_dispatch_extension( + f: unsafe extern "C" fn(i64, *const u8, usize, *const f64, usize, *mut f64) -> i32, + ); + fn js_register_handle_property_dispatch_extension( + f: unsafe extern "C" fn(i64, *const u8, usize, *mut f64) -> i32, + ); + fn js_register_handle_property_set_dispatch_extension( + f: unsafe extern "C" fn(i64, *const u8, usize, f64) -> i32, + ); +} + +/// Register the three HTTP-server handle-dispatch extensions with perry-runtime. +/// Idempotent (`Once` here + the runtime de-dupes by fn pointer). Called from +/// `ensure_gc_scanner_registered`, so it runs the first time any HTTP/HTTPS/HTTP2 +/// server is created — before any request handler can fire. +pub(crate) fn ensure_dispatch_extensions_registered() { + static REGISTER: Once = Once::new(); + REGISTER.call_once(|| unsafe { + js_register_handle_method_dispatch_extension(http_server_method_dispatch_ext); + js_register_handle_property_dispatch_extension(http_server_property_dispatch_ext); + js_register_handle_property_set_dispatch_extension(http_server_property_set_dispatch_ext); + }); +} + +// ---- native member-name vocabularies (mirror perry-stdlib's gated arms) ---- + +fn is_http_server_method(name: &str) -> bool { + matches!( + name, + "listen" + | "close" + | "address" + | "on" + | "addListener" + | "setTimeout" + | "closeAllConnections" + | "closeIdleConnections" + | "removeAllListeners" + | "removeListener" + | "off" + | "ref" + | "unref" + | "@@__perry_wk_asyncDispose" + ) +} + +fn is_http_server_property(name: &str) -> bool { + is_http_server_method(name) + || matches!( + name, + "@@kConnectionsCheckingInterval" + | "listening" + | "headersTimeout" + | "keepAliveTimeout" + | "keepAliveTimeoutBuffer" + | "requestTimeout" + | "timeout" + | "maxHeadersCount" + | "maxRequestsPerSocket" + ) +} + +fn is_incoming_message_member(name: &str) -> bool { + matches!( + name, + "on" | "addListener" + | "setEncoding" + | "setTimeout" + | "pause" + | "resume" + | "destroy" + | "read" + | "_addHeaderLine" + | "__set_socket" + | "__set_connection" + ) || matches!( + name, + "method" + | "url" + | "rawBody" + | "httpVersion" + | "httpVersionMajor" + | "httpVersionMinor" + | "headers" + | "rawHeaders" + | "headersDistinct" + | "trailers" + | "rawTrailers" + | "trailersDistinct" + | "complete" + | "aborted" + | "destroyed" + | "readable" + | "readableEnded" + | "socket" + | "connection" + | "signal" + | "remoteAddress" + | "remotePort" + ) || matches!( + name, + "__get_method" + | "__get_url" + | "__get_httpVersion" + | "__get_headers" + | "__get_headersDistinct" + | "__get_trailers" + | "__get_rawHeaders" + | "__get_rawTrailers" + | "__get_trailersDistinct" + | "__get_complete" + | "__get_aborted" + | "__get_destroyed" + | "__get_socket" + | "__get_connection" + | "__get_signal" + | "__get_remoteAddress" + | "__get_remotePort" + | "constructor" + ) +} + +fn is_server_response_member(name: &str) -> bool { + matches!( + name, + "setHeader" + | "getHeader" + | "removeHeader" + | "hasHeader" + | "getHeaders" + | "getHeaderNames" + | "appendHeader" + | "setHeaders" + | "writeHead" + | "write" + | "addTrailers" + | "end" + | "flushHeaders" + | "cork" + | "uncork" + | "destroy" + | "pipe" + | "setTimeout" + | "writeEarlyHints" + | "writeContinue" + | "writeProcessing" + | "assignSocket" + | "detachSocket" + | "on" + | "addListener" + | "setStatus" + | "getStatus" + ) || matches!( + name, + "statusCode" + | "statusMessage" + | "headersSent" + | "writableEnded" + | "writableFinished" + | "finished" + | "writableCorked" + | "writableHighWaterMark" + | "writableLength" + | "writableObjectMode" + | "writableNeedDrain" + | "sendDate" + | "strictContentLength" + | "req" + | "socket" + | "connection" + | "constructor" + ) || matches!( + name, + "__get_statusCode" + | "__get_statusMessage" + | "__set_statusCode" + | "__set_statusMessage" + | "__get_headersSent" + | "__get_writableEnded" + | "__get_writableFinished" + | "__get_finished" + | "__get_sendDate" + | "__set_sendDate" + | "__get_strictContentLength" + | "__set_strictContentLength" + | "__get_req" + | "__get_socket" + | "__get_connection" + ) +} + +fn is_h2_session_member(name: &str) -> bool { + matches!( + name, + "request" + | "on" + | "addListener" + | "close" + | "destroy" + | "ref" + | "unref" + | "setTimeout" + | "setLocalWindowSize" + | "ping" + | "settings" + | "goaway" + | "type" + | "encrypted" + | "connecting" + | "closed" + | "destroyed" + | "alpnProtocol" + | "pendingSettingsAck" + | "localSettings" + | "remoteSettings" + | "state" + | "socket" + ) +} + +fn is_h2_stream_member(name: &str) -> bool { + matches!( + name, + "on" | "addListener" + | "setEncoding" + | "respond" + | "end" + | "close" + | "setTimeout" + | "priority" + | "additionalHeaders" + | "pushStream" + | "respondWithFD" + | "respondWithFile" + | "sendTrailers" + | "id" + | "pending" + | "closed" + | "destroyed" + | "aborted" + | "rstCode" + | "headersSent" + | "sentHeaders" + | "session" + | "state" + | "bufferSize" + | "endAfterHeaders" + ) +} + +#[inline] +unsafe fn name_str<'a>(ptr: *const u8, len: usize) -> &'a str { + if ptr.is_null() || len == 0 { + "" + } else { + std::str::from_utf8(std::slice::from_raw_parts(ptr, len)).unwrap_or("") + } +} + +/// Method-call extension. Claims (returns 1, sets `*out`) ONLY when `handle` +/// belongs to one of our registries AND `method` is a native member of that +/// type. Returns 0 for any other name so user-prototype-augmented methods +/// (Express `res.send`, etc.) resolve through the prototype chain. +unsafe extern "C" fn http_server_method_dispatch_ext( + handle: i64, + method_ptr: *const u8, + method_len: usize, + args_ptr: *const f64, + args_len: usize, + out: *mut f64, +) -> i32 { + let name = name_str(method_ptr, method_len); + if name.is_empty() { + return 0; + } + let value = if is_http_server_method(name) + && crate::handle_dispatch::js_ext_http_server_is_handle(handle) != 0 + { + Some(crate::handle_dispatch::js_ext_http_server_dispatch_method( + handle, method_ptr, method_len, args_ptr, args_len, + )) + } else if is_incoming_message_member(name) + && crate::handle_dispatch::js_ext_http_incoming_message_is_handle(handle) != 0 + { + Some( + crate::handle_dispatch::js_ext_http_incoming_message_dispatch_method( + handle, method_ptr, method_len, args_ptr, args_len, + ), + ) + } else if is_server_response_member(name) + && crate::handle_dispatch::js_ext_http_server_response_is_handle(handle) != 0 + { + Some( + crate::handle_dispatch::js_ext_http_server_response_dispatch_method( + handle, method_ptr, method_len, args_ptr, args_len, + ), + ) + } else if is_h2_session_member(name) + && crate::http2_server::dispatch::js_ext_http2_session_is_handle(handle) != 0 + { + Some( + crate::http2_server::dispatch::js_ext_http2_session_dispatch_method( + handle, method_ptr, method_len, args_ptr, args_len, + ), + ) + } else if is_h2_stream_member(name) + && crate::http2_server::dispatch::js_ext_http2_stream_is_handle(handle) != 0 + { + Some( + crate::http2_server::dispatch::js_ext_http2_stream_dispatch_method( + handle, method_ptr, method_len, args_ptr, args_len, + ), + ) + } else { + None + }; + match value { + Some(v) => { + if !out.is_null() { + *out = v; + } + 1 + } + None => 0, + } +} + +/// Property-read extension. Same name-gating discipline as the method path. +unsafe extern "C" fn http_server_property_dispatch_ext( + handle: i64, + property_ptr: *const u8, + property_len: usize, + out: *mut f64, +) -> i32 { + let name = name_str(property_ptr, property_len); + if name.is_empty() { + return 0; + } + let value = if is_http_server_property(name) + && crate::handle_dispatch::js_ext_http_server_is_handle(handle) != 0 + { + Some( + crate::handle_dispatch::js_ext_http_server_dispatch_property( + handle, + property_ptr, + property_len, + ), + ) + } else if is_incoming_message_member(name) + && crate::handle_dispatch::js_ext_http_incoming_message_is_handle(handle) != 0 + { + Some( + crate::handle_dispatch::js_ext_http_incoming_message_dispatch_property( + handle, + property_ptr, + property_len, + ), + ) + } else if is_server_response_member(name) + && crate::handle_dispatch::js_ext_http_server_response_is_handle(handle) != 0 + { + Some( + crate::handle_dispatch::js_ext_http_server_response_dispatch_property( + handle, + property_ptr, + property_len, + ), + ) + } else if is_h2_session_member(name) + && crate::http2_server::dispatch::js_ext_http2_session_is_handle(handle) != 0 + { + Some( + crate::http2_server::dispatch::js_ext_http2_session_dispatch_property( + handle, + property_ptr, + property_len, + ), + ) + } else if is_h2_stream_member(name) + && crate::http2_server::dispatch::js_ext_http2_stream_is_handle(handle) != 0 + { + Some( + crate::http2_stream_props::js_ext_http2_stream_dispatch_property( + handle, + property_ptr, + property_len, + ), + ) + } else { + None + }; + match value { + Some(v) => { + if !out.is_null() { + *out = v; + } + 1 + } + None => 0, + } +} + +/// Property-write extension. Claims only the writable NATIVE keys of each handle +/// type — `res.statusCode` / `res.statusMessage` / `res.sendDate` / +/// `res.strictContentLength` / `res.socket` / `res.connection`, and +/// `req.socket` / `req.connection`. Express's `res.status(n)` lowers to +/// `this.statusCode = n`, so the response set MUST route to the native handle; +/// without it Express responses keep status 200 but, more importantly, the set +/// would silently no-op. Every OTHER set (Express stashing `res.locals`, +/// `req.app`, `req.baseUrl`, …) returns 0 so the user expando lands on the +/// object as usual. The underlying dispatch returns 1/0 itself, so an +/// unrecognised key on a matched handle still falls through. +unsafe extern "C" fn http_server_property_set_dispatch_ext( + handle: i64, + property_ptr: *const u8, + property_len: usize, + value: f64, +) -> i32 { + let name = name_str(property_ptr, property_len); + if matches!( + name, + "statusCode" + | "statusMessage" + | "sendDate" + | "strictContentLength" + | "socket" + | "connection" + ) && crate::handle_dispatch::js_ext_http_server_response_is_handle(handle) != 0 + { + return crate::handle_dispatch::js_ext_http_server_response_dispatch_property_set( + handle, + property_ptr, + property_len, + value, + ); + } + if matches!(name, "socket" | "connection") + && crate::handle_dispatch::js_ext_http_incoming_message_is_handle(handle) != 0 + { + return crate::handle_dispatch::js_ext_http_incoming_message_dispatch_property_set( + handle, + property_ptr, + property_len, + value, + ); + } + 0 +} diff --git a/crates/perry-ext-http-server/src/handle_dispatch.rs b/crates/perry-ext-http-server/src/handle_dispatch.rs index 598db848ef..139ef33d4c 100644 --- a/crates/perry-ext-http-server/src/handle_dispatch.rs +++ b/crates/perry-ext-http-server/src/handle_dispatch.rs @@ -111,6 +111,7 @@ extern "C" { fn js_node_http_im_complete(handle: i64) -> i32; fn js_node_http_im_aborted(handle: i64) -> i32; fn js_node_http_im_destroyed(handle: i64) -> i32; + fn js_node_http_im_readable(handle: i64) -> i32; fn js_node_http_im_add_header_line(handle: i64, field: f64, value: f64, dest: f64); fn js_node_http_im_signal(handle: i64) -> f64; fn js_node_http_im_remote_address(handle: i64) -> *mut StringHeader; @@ -869,6 +870,15 @@ pub unsafe extern "C" fn js_ext_http_incoming_message_dispatch_property( "complete" => bool_value(js_node_http_im_complete(handle) != 0), "aborted" => bool_value(js_node_http_im_aborted(handle) != 0), "destroyed" => bool_value(js_node_http_im_destroyed(handle) != 0), + // `req.readable` — true while the request stream can still yield body + // bytes (not yet complete, destroyed, or aborted). Node exposes this on + // the Readable; `req.socket` aliases the request handle, so on-finished's + // `isFinished()` reads `socket.readable` here. Without it `readable` was + // `undefined`, making `!socket.readable` true → on-finished reported the + // request finished → express body-parser skipped JSON/urlencoded parsing + // and `@Body()` resolved to `undefined`. + "readable" => bool_value(js_node_http_im_readable(handle) != 0), + "readableEnded" => bool_value(js_node_http_im_complete(handle) != 0), "socket" | "connection" => crate::request::incoming_socket_override(handle) .unwrap_or_else(|| handle_to_pointer_f64(handle)), "signal" => js_node_http_im_signal(handle), diff --git a/crates/perry-ext-http-server/src/http2_server.rs b/crates/perry-ext-http-server/src/http2_server.rs index e895b1e7c8..2d331b19e9 100644 --- a/crates/perry-ext-http-server/src/http2_server.rs +++ b/crates/perry-ext-http-server/src/http2_server.rs @@ -64,7 +64,7 @@ extern "C" { } mod controls; -mod dispatch; +pub(crate) mod dispatch; mod pump; mod session; diff --git a/crates/perry-ext-http-server/src/lib.rs b/crates/perry-ext-http-server/src/lib.rs index c897bd147f..d949a3f481 100644 --- a/crates/perry-ext-http-server/src/lib.rs +++ b/crates/perry-ext-http-server/src/lib.rs @@ -53,6 +53,7 @@ use std::sync::Once; use perry_ffi::{gc_register_mutable_root_scanner_named, iter_handles_of_mut, GcRootVisitor}; mod cluster_bind; +mod dispatch_ext; // Unit-test binaries do not link the host stdlib/runtime archive that // provides the perry_ffi async bridge; without these the test link is at the // mercy of --gc-sections keeping/dropping the perry-ffi references pulled in @@ -113,6 +114,13 @@ pub(crate) fn ensure_gc_scanner_registered() { js_register_aux_pump(crate::server::js_node_http_server_process_pending); js_register_aux_has_active(crate::server::js_node_http_server_has_active); } + // Wall 10 — register the handle property/method/property-set dispatch + // extensions so erased-receiver `req.url` / `res.end(...)` etc. route to + // our handles even when the linked perry-stdlib was built WITHOUT + // `external-http-server-pump` (the prebuilt `full` stdlib used by + // out-of-tree installs and `PERRY_NO_AUTO_OPTIMIZE=1`). See + // `dispatch_ext.rs`. + crate::dispatch_ext::ensure_dispatch_extensions_registered(); }); } diff --git a/crates/perry-ext-http-server/src/request.rs b/crates/perry-ext-http-server/src/request.rs index 32da6a75a9..c122853fec 100644 --- a/crates/perry-ext-http-server/src/request.rs +++ b/crates/perry-ext-http-server/src/request.rs @@ -420,6 +420,25 @@ pub extern "C" fn js_node_http_im_destroyed(handle: i64) -> i32 { .unwrap_or(0) } +/// `req.readable` — `true` while the request body stream can still be consumed: +/// not yet fully received (`complete`), not destroyed, not aborted. Node's +/// `Readable.readable` flips to `false` once the stream ends. `req.socket` +/// aliases the request handle, so `on-finished`'s `isFinished()` consults +/// `socket.readable` through this; returning `true` up front keeps express +/// body-parser from treating an unread POST body as already finished. +#[no_mangle] +pub extern "C" fn js_node_http_im_readable(handle: i64) -> i32 { + get_handle::(handle) + .map(|im| { + if !im.complete && !im.destroyed && !im.aborted { + 1 + } else { + 0 + } + }) + .unwrap_or(0) +} + /// `req.rawBody` — the fully-collected request body as a `Buffer`. /// /// Perry's HTTP server buffers the entire request body before invoking the diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 52ade4a925..fcfb8bf5e5 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -137,6 +137,7 @@ impl LoweringContext { namespace_import_sources: std::collections::HashMap::new(), generator_func_names: HashSet::new(), async_generator_func_names: HashSet::new(), + nested_generator_forward_referenced: HashSet::new(), iterator_func_for_class: std::collections::HashMap::new(), proxy_locals: HashSet::new(), builtin_proto_method_locals: HashMap::new(), diff --git a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs index 70a53cf348..6eba59ed6f 100644 --- a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs @@ -241,6 +241,91 @@ fn chain_roots_at_iterator_from(expr: &ast::Expr) -> bool { use super::super::{lower_expr, LoweringContext}; +/// Wall 17 (nestjs / iterare): is `expr` statically KNOWN to be an Array? +/// +/// Used by the chained-call array-method fold to decide whether a +/// `recv.(...)` whose `recv` is itself a call (`inner.(...)`) is a +/// genuine array chain (`[1,2,3].map(f).map(g)`) or a USER iterator chain +/// whose `.map`/`.filter` are user methods returning a class instance +/// (iterare's `IteratorWithOperators`, RxJS-likes). The AST-only inner-method- +/// name check (`map`/`filter` "produce arrays") was OPTIMISTIC and mis-folded +/// `iterate(x).map(f).map(g)` into `Expr::ArrayMap`, so the outer `new +/// IteratorWithOperators(...)` was constructed by `js_array_map` (returning an +/// empty/garbage array, GC-typed ARRAY) → "neither Iterator nor Iterable". +/// +/// We only return `true` when the chain provably ROOTS at an array: an array +/// literal, an array-typed local, `Array.from/of(...)`, `Object.entries/keys/ +/// values(...)`, or recursively another array-rooted producer call. An unknown +/// / `Any` / user-class root yields `false`, routing the outer method to the +/// runtime's GC-type-checked dynamic dispatch — which correctly runs +/// `js_array_map` for real arrays and the user method for class instances. +fn chain_roots_at_array(ctx: &LoweringContext, expr: &ast::Expr) -> bool { + match unwrap_transparent_expr(expr) { + // `[...]` literal. + ast::Expr::Array(_) => true, + // A local declared/inferred as `T[]` / tuple. + ast::Expr::Ident(ident) => matches!( + ctx.lookup_local_type(ident.sym.as_ref()), + Some(Type::Array(_)) | Some(Type::Tuple(_)) + ), + // A producer call. + ast::Expr::Call(call) => call_roots_at_array(ctx, call), + _ => false, + } +} + +/// `CallExpr` form of [`chain_roots_at_array`] (the inner-call arm hands us a +/// `&CallExpr`, not an `&Expr`). +fn call_roots_at_array(ctx: &LoweringContext, call: &ast::CallExpr) -> bool { + let ast::Callee::Expr(callee) = &call.callee else { + return false; + }; + let ast::Expr::Member(m) = unwrap_transparent_expr(callee.as_ref()) else { + return false; + }; + let ast::MemberProp::Ident(prop) = &m.prop else { + return false; + }; + let method = prop.sym.as_ref(); + let recv = unwrap_transparent_expr(m.obj.as_ref()); + // `Array.from(x)` / `Array.of(x)` always yield a fresh array. + if matches!(method, "from" | "of") + && matches!(recv, ast::Expr::Ident(i) if i.sym.as_ref() == "Array") + { + return true; + } + // `Object.entries/keys/values(x)` always yield a fresh array. + if matches!(method, "entries" | "keys" | "values") + && matches!(recv, ast::Expr::Ident(i) if i.sym.as_ref() == "Object") + { + return true; + } + // An array-producing prototype method roots at an array only if ITS OWN + // receiver provably roots at an array (recurse). `map` / `filter` / + // `flatMap` are intentionally included here ONLY when so rooted — that is + // what distinguishes `[1].map(f).map(g)` (root is a literal) from + // `iterate(x).map(f).map(g)` (root is a user call). + let array_producing = matches!( + method, + "map" + | "filter" + | "slice" + | "concat" + | "flat" + | "flatMap" + | "splice" + | "sort" + | "reverse" + | "fill" + | "copyWithin" + | "toReversed" + | "toSorted" + | "toSpliced" + | "with" + ); + array_producing && chain_roots_at_array(ctx, m.obj.as_ref()) +} + pub(super) fn try_array_only_methods( ctx: &mut LoweringContext, call: &ast::CallExpr, @@ -478,46 +563,29 @@ pub(super) fn try_array_only_methods( if !is_overlapping { false } else { - // Look up the inner call's method name. If it's - // one of the known array-producing builtins, the - // chained fold IS safe — keep the ident-receiver - // optimistic behaviour for `arr.filter(p).find(q)` - // shapes. - let inner_method: Option<&str> = match &inner_call.callee { - ast::Callee::Expr(e) => match e.as_ref() { - ast::Expr::Member(m) => match &m.prop { - ast::MemberProp::Ident(i) => Some(i.sym.as_ref()), - _ => None, - }, - _ => None, - }, - _ => None, - }; - let inner_returns_array = inner_method - .map(|m| { - matches!( - m, - "map" - | "filter" - | "slice" - | "concat" - | "flat" - | "flatMap" - | "splice" - | "sort" - | "reverse" - | "fill" - | "copyWithin" - | "toReversed" - | "toSorted" - | "toSpliced" - | "with" - ) - }) - .unwrap_or(false); - // recv_is_class = true means BAIL. Bail when the - // inner call is NOT a known array-producing method. - !inner_returns_array + // Wall 17 (nestjs / iterare): the previous heuristic + // looked ONLY at the inner call's METHOD NAME — if it + // was `map`/`filter`/`slice`/… it optimistically + // assumed an array chain and folded the outer call to + // `Expr::ArrayMap`. But `map`/`filter`/`flatMap` are + // ALSO common USER iterator methods: iterare's + // `iterate(x).map(f).map(g)` returns an + // `IteratorWithOperators`, not an array, so the fold + // built the outer wrapper via `js_array_map` → + // empty/garbage ARRAY-typed object → downstream + // `Array.from(this)` saw "neither Iterator nor + // Iterable" and `getNonTransientInstances` died in + // `onModuleInit`. + // + // Fold to the array op ONLY when the inner chain + // PROVABLY roots at an array (literal / array-typed + // local / `Array.from`/`Object.entries` / a nested + // array-producer over such a root). Otherwise BAIL + // (recv_is_class = true) to the runtime's + // GC-type-checked dynamic dispatch, which runs + // `js_array_map` for real arrays and the user method + // for class instances — correct for BOTH. + !call_roots_at_array(ctx, inner_call) } } _ => false, diff --git a/crates/perry-hir/src/lower/expr_call/native_module.rs b/crates/perry-hir/src/lower/expr_call/native_module.rs index ea9d661b62..714d248fb2 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -1097,21 +1097,34 @@ pub(super) fn try_native_module_methods( } } - // Check for Symbol static methods: Symbol.for / Symbol.keyFor + // Check for Symbol static methods: Symbol.for / Symbol.keyFor. + // Accept BOTH the dot form (`Symbol.for(...)`) and the + // computed-string form (`Symbol['for'](...)`) — the latter is what + // the userland `buffer` package writes (`Symbol['for']('nodejs.util. + // inspect.custom')`). Previously only `MemberProp::Ident` matched, so + // `Symbol['for'](...)` fell through to generic dispatch, which dropped + // the `Symbol` receiver and lowered the callee as `globalThis.for` + // (undefined) → `TypeError: value is not a function` at buffer's + // module eval (the safer-buffer/iconv-lite/body-parser/express chain). if obj_name == "Symbol" { - if let ast::MemberProp::Ident(method_ident) = &member.prop { - let method_name = method_ident.sym.as_ref(); - match method_name { - "for" => { - let key = args.into_iter().next().unwrap_or(Expr::Undefined); - return Ok(Ok(Expr::SymbolFor(Box::new(key)))); - } - "keyFor" => { - let sym = args.into_iter().next().unwrap_or(Expr::Undefined); - return Ok(Ok(Expr::SymbolKeyFor(Box::new(sym)))); - } - _ => {} // Fall through to generic handling + let method_name: Option<&str> = match &member.prop { + ast::MemberProp::Ident(method_ident) => Some(method_ident.sym.as_ref()), + ast::MemberProp::Computed(c) => match c.expr.as_ref() { + ast::Expr::Lit(ast::Lit::Str(s)) => s.value.as_str(), + _ => None, + }, + _ => None, + }; + match method_name { + Some("for") => { + let key = args.into_iter().next().unwrap_or(Expr::Undefined); + return Ok(Ok(Expr::SymbolFor(Box::new(key)))); } + Some("keyFor") => { + let sym = args.into_iter().next().unwrap_or(Expr::Undefined); + return Ok(Ok(Expr::SymbolKeyFor(Box::new(sym)))); + } + _ => {} // Fall through to generic handling } } @@ -1533,6 +1546,26 @@ pub(super) fn try_native_module_methods( // This is a call on a native module (e.g., mysql.createConnection) if let ast::MemberProp::Ident(method_ident) = &member.prop { let method_name = method_ident.sym.to_string(); + // A destructured/named DATA member of a native module + // (`const { METHODS } = require('node:http')` → + // `imported_method = Some("METHODS")`) holds a real + // Array/Object value. An Array/Object prototype method on it + // (`METHODS.map(...)`, `STATUS_CODES.hasOwnProperty(...)`) is + // a call on that VALUE — NOT a `module.method` native call. + // Bail to generic dynamic dispatch so it runs on the member's + // resolved value (express's `router` does + // `METHODS.map((m) => m.toLowerCase())`). Without this the + // call lowered to `NativeMethodCall { module: "http", method: + // "map" }`, which returned undefined / a deferred throw. + if imported_method.is_some() + && (super::super::array_fold::is_known_array_prototype_method( + &method_name, + ) || super::super::array_fold::is_known_object_prototype_method( + &method_name, + )) + { + return Ok(Err(args)); + } if module_name == "worker_threads" && method_name == "workerData" { return Ok(Err(args)); } @@ -1634,6 +1667,23 @@ pub(super) fn try_native_module_methods( && !super::super::array_fold::is_known_string_prototype_method( &method_name, ) + // A destructured/named DATA member of a native module + // (`const { METHODS } = require('node:http')`, + // registered with `imported_method = Some("METHODS")`) + // holds a real Array/Object value, not a callable + // namespace. An Array/Object prototype method on it + // (`METHODS.map(...)`, `STATUS_CODES.hasOwnProperty(...)`) + // is a call on that VALUE — gating it as + // `http.map`/`http.hasOwnProperty` (#463) compiled it to + // a deferred "not implemented" throw, breaking express's + // `router` (`METHODS.map((m) => m.toLowerCase())`). Fall + // through to dynamic dispatch on the real member value. + && !(imported_method.is_some() + && (super::super::array_fold::is_known_array_prototype_method( + &method_name, + ) || super::super::array_fold::is_known_object_prototype_method( + &method_name, + ))) { // #925: this is the gate that fires // for `crypto.hmacSha256(data, key)`. diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 8872b43def..0041389abd 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -683,6 +683,21 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul // repopulates it for this body. let saved_annexb_block_fn_var_ids = std::mem::take(&mut ctx.annexb_block_fn_var_ids); let saved_annexb_block_fn_names_all = std::mem::take(&mut ctx.annexb_block_fn_names_all); + // Nested `function*` declarations forward-referenced by an earlier sibling + // in this function-expression body (the cjs_wrap IIFE: `pathToRegexp` calls + // `flatten`, declared below it) must use the closure-lowering path. Scope + // the set to this body and restore on exit. + let saved_nested_gen_fwd = std::mem::take(&mut ctx.nested_generator_forward_referenced); + ctx.nested_generator_forward_referenced = fn_expr + .function + .body + .as_ref() + .map(|b| { + crate::lower_decl::forward_referenced_nested_generators(&b.stmts) + .into_iter() + .collect() + }) + .unwrap_or_default(); // Generate Let statements for destructuring patterns BEFORE lowering body let mut destructuring_stmts = Vec::new(); @@ -799,8 +814,27 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul } for stmt in &block.stmts { if let ast::Stmt::Decl(ast::Decl::Fn(fn_decl)) = stmt { - if fn_decl.function.body.is_some() && !fn_decl.function.is_generator { - let name = fn_decl.ident.sym.to_string(); + let name = fn_decl.ident.sym.to_string(); + // Non-generator fn-decls hoist as before. GENERATOR fn-decls + // only need a pre-defined+hoisted local when they take the + // closure-lowering path AND are forward-referenced by an earlier + // sibling (path-to-regexp's `pathToRegexp` → `flatten`): the + // closure path emits `let = Closure`, which must be + // pre-defined so the earlier reference resolves to the local and + // hoisted (in `hoisted_id_set`) so the binding runs before that + // reference. A generator that takes the TOP-LEVEL path (the + // common, non-forward-referenced case) must NOT be pre-defined + // here — boxing/hoisting its `FuncRef` binding would make its own + // recursive self-call read a boxed slot instead of the callable + // `FuncRef` (`TypeError: value is not a function`). + let take = if fn_decl.function.body.is_none() { + false + } else if fn_decl.function.is_generator { + ctx.nested_generator_forward_referenced.contains(&name) + } else { + true + }; + if take { let existing_in_scope = ctx .locals .lookup_index_in_scope(&name, outer_locals_len) @@ -1091,12 +1125,69 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul } else { Vec::new() }; + + // Mirror `lower_fn_body_block_stmt`'s (block.rs) end-of-body class-capture + // re-registration for FUNCTION-EXPRESSION bodies — which previously skipped + // it (arrow / fn-decl bodies already do this; function expressions, incl. + // the cjs_wrap IIFE `(function() { … })()`, did not). The decl-site + // `RegisterClassCaptures` snapshot is taken at the class's declaration + // position, which runs BEFORE later statements assign captured vars: the + // ubiquitous tsc computed-member emit `var _a; class C { [_a]=… }; _a = + // Symbol.for(…)` assigns `_a` AFTER the class, so the decl-site snapshot + // recorded `undefined`. A statically-resolved `new C(…)` appends the live + // value and is fine, but a DYNAMICALLY-resolved construct (`new ns.C(…)` + // cross-module — how NestJS builds `InstanceWrapper`) appends no cap arg and + // falls back to that stale snapshot → captured field reads `undefined`. + // Refresh the snapshot with the FINAL values at body end (inserted before a + // trailing `return`). + if let Some(ref block) = fn_expr.function.body { + let mut re_regs: Vec = Vec::new(); + for stmt in &block.stmts { + if let ast::Stmt::Decl(ast::Decl::Class(class_decl)) = stmt { + // A colliding `class X` may have been renamed during body + // lowering; captures + `new` are registered under the resolved + // name, so use it here too (the raw AST name would miss). + let cname = ctx.resolve_class_name(class_decl.ident.sym.as_str()); + if let Some(captured) = ctx.lookup_class_captures(&cname) { + if !captured.is_empty() { + let captures: Vec = + captured.iter().map(|id| Expr::LocalGet(*id)).collect(); + let cap_args: Vec<(LocalId, LocalId)> = + captured.iter().map(|id| (*id, *id)).collect(); + for s in body.iter_mut() { + crate::lower_decl::append_new_args_stmt(s, &cname, &cap_args, true); + } + re_regs.push(Stmt::Expr(Expr::RegisterClassCaptures { + class_name: cname, + captures, + })); + } + } + } + } + if !re_regs.is_empty() { + // Refresh the snapshot before EVERY reachable `return` in the body + // (not only a trailing one): an EARLY `return ` after the + // captured locals are assigned would otherwise return a class with + // the stale declaration-time snapshot. The walk descends statement + // children (if/loops/try/switch/labeled) but NOT into nested + // closures — their `return`s belong to a different function. + insert_class_capture_refresh_before_returns(&mut body, &re_regs); + // Fallthrough (implicit return at body end). When the body already + // ends in a `return`, the walk above handled it; otherwise append so + // a no-early-return fallthrough path also records the final values. + if !matches!(body.last(), Some(Stmt::Return(_))) { + body.extend(re_regs.iter().cloned()); + } + } + } ctx.current_strict = outer_strict; ctx.annexb_block_fn_var_ids = saved_annexb_block_fn_var_ids; ctx.annexb_block_fn_names_all = saved_annexb_block_fn_names_all; ctx.forward_class_names = saved_forward_class_names; ctx.forward_class_decl_depth = saved_forward_class_decl_depth; ctx.class_renames = saved_class_renames; + ctx.nested_generator_forward_referenced = saved_nested_gen_fwd; // Prepend destructuring statements to body if !destructuring_stmts.is_empty() { @@ -1162,6 +1253,72 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul }) } +/// Insert a copy of `re_regs` (class-capture refresh statements) immediately +/// before EVERY reachable `Stmt::Return` in `stmts`, descending into nested +/// statement bodies (if/loops/try/switch/labeled) but NOT into nested closures +/// — a closure's `return` exits a different function and must keep its own +/// snapshot. Each return path then records the live capture values at that +/// point. See the call site in `lower_fn_expr_anon` (CodeRabbit #5739). +fn insert_class_capture_refresh_before_returns(stmts: &mut Vec, re_regs: &[Stmt]) { + let mut i = 0; + while i < stmts.len() { + insert_class_capture_refresh_into_stmt(&mut stmts[i], re_regs); + if matches!(&stmts[i], Stmt::Return(_)) { + for (j, s) in re_regs.iter().cloned().enumerate() { + stmts.insert(i + j, s); + } + i += re_regs.len(); + } + i += 1; + } +} + +/// Recurse into a single statement's child statement lists for +/// [`insert_class_capture_refresh_before_returns`]. +fn insert_class_capture_refresh_into_stmt(stmt: &mut Stmt, re_regs: &[Stmt]) { + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + insert_class_capture_refresh_before_returns(then_branch, re_regs); + if let Some(eb) = else_branch { + insert_class_capture_refresh_before_returns(eb, re_regs); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + insert_class_capture_refresh_before_returns(body, re_regs); + } + Stmt::For { init, body, .. } => { + if let Some(init) = init { + insert_class_capture_refresh_into_stmt(init, re_regs); + } + insert_class_capture_refresh_before_returns(body, re_regs); + } + Stmt::Labeled { body, .. } => insert_class_capture_refresh_into_stmt(body, re_regs), + Stmt::Try { + body, + catch, + finally, + } => { + insert_class_capture_refresh_before_returns(body, re_regs); + if let Some(c) = catch { + insert_class_capture_refresh_before_returns(&mut c.body, re_regs); + } + if let Some(f) = finally { + insert_class_capture_refresh_before_returns(f, re_regs); + } + } + Stmt::Switch { cases, .. } => { + for c in cases { + insert_class_capture_refresh_before_returns(&mut c.body, re_regs); + } + } + _ => {} + } +} + /// Shared closure-capture analysis used by both `lower_arrow` and /// `lower_fn_expr`. Walks the lowered body, collects every LocalId /// referenced anywhere, intersects with the outer-scope locals (minus diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 62d14c3aa0..6970bc76ad 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -517,6 +517,16 @@ pub struct LoweringContext { /// the for-of generator-call path so it can wrap `__iter.next()` in /// `await` (async generators always return `Promise<{value, done}>`). pub(crate) async_generator_func_names: HashSet, + /// Names of nested `function*` declarations that are referenced by an + /// EARLIER sibling statement in the same enclosing function/IIFE body + /// (forward reference). Such a generator cannot use the top-level-Function + /// hoist path — its `FuncRef` name binding is only registered while lowering + /// its own declaration, too late for the earlier reference, which would fall + /// through to a `globalThis` read (`ReferenceError: is not defined`). + /// Populated by the Phase-1 pre-pass in `lower_fn_body_block_stmt` / + /// `lower_fn_expr_anon`; consumed in `lower_body_stmt`'s FnDecl arm to route + /// the generator through the closure path instead. + pub(crate) nested_generator_forward_referenced: HashSet, /// Classes that define `*[Symbol.iterator]()`. Maps class name → /// `FuncId` of the synthesized top-level generator function that /// takes `this` as its first parameter. Consumed by `for...of` to diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index d37c1bed33..57a1c21b90 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -904,6 +904,14 @@ pub fn lower_fn_body_block_stmt( // repopulates it for this body below. let saved_annexb_block_fn_var_ids = std::mem::take(&mut ctx.annexb_block_fn_var_ids); let saved_annexb_block_fn_names_all = std::mem::take(&mut ctx.annexb_block_fn_names_all); + // Nested `function*` declarations forward-referenced by an earlier sibling + // in THIS body must use the closure-lowering path (see `lower_body_stmt`'s + // FnDecl arm). Scope the set to this body and restore on every exit. + let saved_nested_gen_fwd = std::mem::take(&mut ctx.nested_generator_forward_referenced); + ctx.nested_generator_forward_referenced = + crate::lower_decl::forward_referenced_nested_generators(&block.stmts) + .into_iter() + .collect(); // Boundary between outer-scope locals (+ this function's params, defined by // the caller before entry) and locals defined while lowering THIS body. // Used by the Phase 1.6 forward `let`/`const` pre-registration so a const @@ -1012,6 +1020,7 @@ pub fn lower_fn_body_block_stmt( ctx.forward_class_decl_depth = saved_forward_class_decl_depth; ctx.class_renames = saved_class_renames; ctx.annexb_block_fn_var_ids = saved_annexb_block_fn_var_ids; + ctx.nested_generator_forward_referenced = saved_nested_gen_fwd; ctx.annexb_block_fn_names_all = saved_annexb_block_fn_names_all; return Err(err); } @@ -1093,6 +1102,7 @@ pub fn lower_fn_body_block_stmt( ctx.current_strict = parent_strict; ctx.annexb_block_fn_var_ids = saved_annexb_block_fn_var_ids; ctx.annexb_block_fn_names_all = saved_annexb_block_fn_names_all; + ctx.nested_generator_forward_referenced = saved_nested_gen_fwd; let mut result = var_slot_lets; result.extend(body); return Ok(result); @@ -1150,6 +1160,7 @@ pub fn lower_fn_body_block_stmt( ctx.current_strict = parent_strict; ctx.annexb_block_fn_var_ids = saved_annexb_block_fn_var_ids; ctx.annexb_block_fn_names_all = saved_annexb_block_fn_names_all; + ctx.nested_generator_forward_referenced = saved_nested_gen_fwd; Ok(result) } diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index f8d530176e..5b5394bca9 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -20,8 +20,11 @@ use super::*; mod detect; mod for_await; +pub(crate) mod gen_capture_scan; mod nested_fn_decl; +use gen_capture_scan::nested_generator_references_outer_locals; + use detect::{ insert_iterator_return_before_abrupts, is_fs_dir_for_await_target, is_node_readable_expr, is_readline_interface_for_await_target, is_web_readable_stream_expr, @@ -425,18 +428,54 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { // Inner function declarations are compiled as closures and assigned to local variables. - // EXCEPTION: nested **generator** declarations (`function*` / - // `async function*`) cannot be lowered as closures because the - // generator-state-machine transform in `perry-transform/src/ - // generator.rs` only operates on top-level `Function`s in - // `hir.functions`. Closures with `yield` in their body would - // never run through the transform and would silently call the - // raw IR (returning 0). Hoist them to top-level via - // `lower_fn_decl` + `pending_functions` and register the local - // as a FuncRef so the for-of / Array.fromAsync iterator path - // detects them via `generator_func_names`. + // NESTED **generator** declarations (`function*` / `async + // function*`) need the generator-state-machine transform. Two routes: + // + // (a) DEFAULT: hoist it to a top-level `Function` in + // `hir.functions` (via `lower_fn_decl` + `pending_functions`) + // and bind the local to a `FuncRef` so the for-of / + // Array.fromAsync iterator path detects it via + // `generator_func_names`. This is the historical path; it + // works for generators that are NOT referenced before their + // own declaration by a sibling (the `FuncRef` name binding is + // registered while lowering this declaration, too late for an + // earlier sibling's reference). + // + // (b) The generator REFERENCES outer-scope free variables (e.g. a + // `function* lexer()` nested in a CJS-wrap IIFE that reads + // module-scope `SIMPLE_TOKENS`/`ID_START`) OR is referenced by + // an EARLIER sibling in the same enclosing body (e.g. + // path-to-regexp's `pathToRegexp` calls `flatten`, declared + // below it, inside the CJS-wrap IIFE). The top-level `Function` + // has no capture environment — free vars forward into the + // step closures as nullish (`Cannot convert undefined or null + // to object`), and an earlier sibling's forward reference falls + // through to a `globalThis` read (`ReferenceError: is + // not defined`). Lower it as a generator `Expr::Closure` + // instead (via `lower_nested_fn_decl`, which computes the real + // `captures` and emits a hoisted `Stmt::Let { init: Closure }` + // that the IIFE/fn-body hoisting moves ahead of executable + // statements). The closure-aware generator transform + // (`transform_generator_closures_in_stmts`) threads the + // captures — including a boxed self-capture for recursion — + // into the step closures. Register the name in + // `generator_func_names` so iteration still detects it. if fn_decl.function.body.is_some() && fn_decl.function.is_generator { let func_name = fn_decl.ident.sym.to_string(); + let use_closure_path = + nested_generator_references_outer_locals(ctx, &fn_decl.function, &func_name) + || ctx.nested_generator_forward_referenced.contains(&func_name); + if use_closure_path { + if ctx.lookup_local(&func_name).is_none() { + ctx.define_local(func_name.clone(), Type::Any); + } + ctx.generator_func_names.insert(func_name.clone()); + if fn_decl.function.is_async { + ctx.async_generator_func_names.insert(func_name.clone()); + } + nested_fn_decl::lower_nested_fn_decl(ctx, fn_decl, &mut result)?; + return Ok(result); + } let func = lower_fn_decl(ctx, fn_decl)?; let func_id = func.id; ctx.register_func(func_name.clone(), func_id); diff --git a/crates/perry-hir/src/lower_decl/body_stmt/gen_capture_scan.rs b/crates/perry-hir/src/lower_decl/body_stmt/gen_capture_scan.rs new file mode 100644 index 0000000000..f13441df57 --- /dev/null +++ b/crates/perry-hir/src/lower_decl/body_stmt/gen_capture_scan.rs @@ -0,0 +1,404 @@ +//! Conservative free-variable scan for nested generator declarations. +//! +//! A nested `function*` declaration that references any enclosing-scope local +//! must be lowered as a generator `Expr::Closure` (so the closure-aware +//! generator transform threads its captures into the synthesized step +//! closures) rather than hoisted to a capture-less top-level `Function`. +//! Otherwise the free variables forward into the step closures as nullish +//! values (`Cannot convert undefined or null to object` when the body indexes +//! them) — the path-to-regexp `lexer`/`SIMPLE_TOKENS` failure. +//! +//! This walker collects every identifier reference in the function body +//! (over-approximating: collecting an extra name only routes through the +//! closure path, which is always correct), then asks `ctx.lookup_local` whether +//! any of them resolves to a live enclosing local. Identifiers bound *inside* +//! the body (params, inner declarations) are not in the enclosing scope, so +//! `lookup_local` only returns `Some` for genuine outer captures. + +use super::*; + +/// Scan an enclosing function/IIFE body's statements and return the names of +/// nested `function*` declarations that are referenced by an EARLIER sibling +/// statement (a forward reference). Such generators must be lowered via the +/// closure path (see `lower_body_stmt`'s FnDecl arm), because the top-level +/// hoist path registers the `FuncRef` name binding too late for the earlier +/// reference. Statements are scanned in source order: a generator name is +/// "forward referenced" if it appears in any identifier position before its own +/// declaration statement. +pub(crate) fn forward_referenced_nested_generators(stmts: &[ast::Stmt]) -> Vec { + // Collect declaration order of nested generator fn-decls. + let mut gen_decl_index: std::collections::HashMap = + std::collections::HashMap::new(); + for (i, stmt) in stmts.iter().enumerate() { + if let ast::Stmt::Decl(ast::Decl::Fn(fd)) = stmt { + if fd.function.is_generator && fd.function.body.is_some() { + gen_decl_index.entry(fd.ident.sym.to_string()).or_insert(i); + } + } + } + if gen_decl_index.is_empty() { + return Vec::new(); + } + let mut forward: std::collections::HashSet = std::collections::HashSet::new(); + for (i, stmt) in stmts.iter().enumerate() { + // The generator's own declaration statement is not a forward reference + // to itself (self-recursion is handled inside its body either way). + let mut names: Vec = Vec::new(); + collect_idents_stmt(stmt, &mut names); + for n in names { + if let Some(&decl_i) = gen_decl_index.get(&n) { + if i < decl_i { + forward.insert(n); + } + } + } + } + forward.into_iter().collect() +} + +/// Does this nested generator body reference any identifier bound in an +/// enclosing (outer) scope? +pub(super) fn nested_generator_references_outer_locals( + ctx: &LoweringContext, + func: &ast::Function, + own_name: &str, +) -> bool { + let Some(body) = func.body.as_ref() else { + return false; + }; + // The generator's own name (for self-recursion) and its parameters bind + // inside the function, not the enclosing scope — exclude them so a purely + // self-recursive generator (e.g. path-to-regexp `flatten`) is NOT treated + // as capturing. (Function declarations are hoisted, so a pre-scan may have + // already registered the own name as a local; without this exclusion the + // self-reference would force the closure path and lose hoisting.) + let mut bound: std::collections::HashSet = std::collections::HashSet::new(); + bound.insert(own_name.to_string()); + for p in &func.params { + collect_pat_bound_names(&p.pat, &mut bound); + } + + let mut names: Vec = Vec::new(); + for stmt in &body.stmts { + collect_idents_stmt(stmt, &mut names); + } + names + .iter() + .any(|n| !bound.contains(n) && ctx.lookup_local(n).is_some()) +} + +fn collect_pat_bound_names(pat: &ast::Pat, out: &mut std::collections::HashSet) { + match pat { + ast::Pat::Ident(i) => { + out.insert(i.id.sym.to_string()); + } + ast::Pat::Array(a) => { + for e in a.elems.iter().flatten() { + collect_pat_bound_names(e, out); + } + } + ast::Pat::Rest(r) => collect_pat_bound_names(&r.arg, out), + ast::Pat::Object(o) => { + for p in &o.props { + match p { + ast::ObjectPatProp::KeyValue(kv) => collect_pat_bound_names(&kv.value, out), + ast::ObjectPatProp::Assign(a) => { + out.insert(a.key.sym.to_string()); + } + ast::ObjectPatProp::Rest(r) => collect_pat_bound_names(&r.arg, out), + } + } + } + ast::Pat::Assign(a) => collect_pat_bound_names(&a.left, out), + _ => {} + } +} + +fn push_ident(name: &str, out: &mut Vec) { + out.push(name.to_string()); +} + +fn collect_idents_expr(expr: &ast::Expr, out: &mut Vec) { + use ast::Expr; + match expr { + Expr::Ident(i) => push_ident(i.sym.as_ref(), out), + Expr::This(_) | Expr::Lit(_) | Expr::Invalid(_) => {} + Expr::Array(a) => { + for e in a.elems.iter().flatten() { + collect_idents_expr(&e.expr, out); + } + } + Expr::Object(o) => { + for p in &o.props { + match p { + ast::PropOrSpread::Spread(s) => collect_idents_expr(&s.expr, out), + ast::PropOrSpread::Prop(prop) => collect_idents_prop(prop, out), + } + } + } + Expr::Fn(_) | Expr::Arrow(_) => { + // Nested functions/arrows form their own scopes. Their free + // references could still be outer captures of THIS generator, so + // recurse to over-approximate. + collect_idents_nested_callable(expr, out); + } + Expr::Unary(u) => collect_idents_expr(&u.arg, out), + Expr::Update(u) => collect_idents_expr(&u.arg, out), + Expr::Bin(b) => { + collect_idents_expr(&b.left, out); + collect_idents_expr(&b.right, out); + } + Expr::Assign(a) => { + collect_idents_assign_target(&a.left, out); + collect_idents_expr(&a.right, out); + } + Expr::Member(m) => { + collect_idents_expr(&m.obj, out); + if let ast::MemberProp::Computed(c) = &m.prop { + collect_idents_expr(&c.expr, out); + } + } + Expr::SuperProp(s) => { + if let ast::SuperProp::Computed(c) = &s.prop { + collect_idents_expr(&c.expr, out); + } + } + Expr::Cond(c) => { + collect_idents_expr(&c.test, out); + collect_idents_expr(&c.cons, out); + collect_idents_expr(&c.alt, out); + } + Expr::Call(c) => { + if let ast::Callee::Expr(e) = &c.callee { + collect_idents_expr(e, out); + } + for a in &c.args { + collect_idents_expr(&a.expr, out); + } + } + Expr::New(n) => { + collect_idents_expr(&n.callee, out); + if let Some(args) = &n.args { + for a in args { + collect_idents_expr(&a.expr, out); + } + } + } + Expr::Seq(s) => { + for e in &s.exprs { + collect_idents_expr(e, out); + } + } + Expr::Tpl(t) => { + for e in &t.exprs { + collect_idents_expr(e, out); + } + } + Expr::TaggedTpl(t) => { + collect_idents_expr(&t.tag, out); + for e in &t.tpl.exprs { + collect_idents_expr(e, out); + } + } + Expr::Paren(p) => collect_idents_expr(&p.expr, out), + Expr::Yield(y) => { + if let Some(a) = &y.arg { + collect_idents_expr(a, out); + } + } + Expr::Await(a) => collect_idents_expr(&a.arg, out), + Expr::OptChain(o) => collect_idents_opt_chain(&o.base, out), + Expr::TsAs(t) => collect_idents_expr(&t.expr, out), + Expr::TsConstAssertion(t) => collect_idents_expr(&t.expr, out), + Expr::TsNonNull(t) => collect_idents_expr(&t.expr, out), + Expr::TsTypeAssertion(t) => collect_idents_expr(&t.expr, out), + Expr::TsSatisfies(t) => collect_idents_expr(&t.expr, out), + Expr::TsInstantiation(t) => collect_idents_expr(&t.expr, out), + // Class expressions / JSX / others: conservatively skip. A class + // expression's body could capture, but these are rare in generator + // bodies; if missed, the worst case is the old (top-level Function) + // path, which is exactly the prior behavior. + _ => {} + } +} + +fn collect_idents_opt_chain(base: &ast::OptChainBase, out: &mut Vec) { + match base { + ast::OptChainBase::Member(m) => { + collect_idents_expr(&m.obj, out); + if let ast::MemberProp::Computed(c) = &m.prop { + collect_idents_expr(&c.expr, out); + } + } + ast::OptChainBase::Call(c) => { + collect_idents_expr(&c.callee, out); + for a in &c.args { + collect_idents_expr(&a.expr, out); + } + } + } +} + +fn collect_idents_prop(prop: &ast::Prop, out: &mut Vec) { + match prop { + ast::Prop::Shorthand(i) => push_ident(i.sym.as_ref(), out), + ast::Prop::KeyValue(kv) => { + if let ast::PropName::Computed(c) = &kv.key { + collect_idents_expr(&c.expr, out); + } + collect_idents_expr(&kv.value, out); + } + ast::Prop::Assign(a) => collect_idents_expr(&a.value, out), + ast::Prop::Getter(_) | ast::Prop::Setter(_) | ast::Prop::Method(_) => { + // Method bodies are their own scopes; over-approximating recursion + // is unnecessary for the common cases — skip. + } + } +} + +fn collect_idents_nested_callable(expr: &ast::Expr, out: &mut Vec) { + match expr { + ast::Expr::Fn(f) => { + if let Some(b) = &f.function.body { + for s in &b.stmts { + collect_idents_stmt(s, out); + } + } + } + ast::Expr::Arrow(a) => match &*a.body { + ast::BlockStmtOrExpr::BlockStmt(b) => { + for s in &b.stmts { + collect_idents_stmt(s, out); + } + } + ast::BlockStmtOrExpr::Expr(e) => collect_idents_expr(e, out), + }, + _ => {} + } +} + +fn collect_idents_assign_target(t: &ast::AssignTarget, out: &mut Vec) { + match t { + ast::AssignTarget::Simple(s) => match s { + ast::SimpleAssignTarget::Ident(i) => push_ident(i.id.sym.as_ref(), out), + ast::SimpleAssignTarget::Member(m) => { + collect_idents_expr(&m.obj, out); + if let ast::MemberProp::Computed(c) = &m.prop { + collect_idents_expr(&c.expr, out); + } + } + _ => {} + }, + ast::AssignTarget::Pat(_) => {} + } +} + +fn collect_idents_var_decl(decl: &ast::VarDecl, out: &mut Vec) { + for d in &decl.decls { + if let Some(init) = &d.init { + collect_idents_expr(init, out); + } + } +} + +fn collect_idents_stmt(stmt: &ast::Stmt, out: &mut Vec) { + use ast::Stmt; + match stmt { + Stmt::Expr(e) => collect_idents_expr(&e.expr, out), + Stmt::Decl(ast::Decl::Var(v)) => collect_idents_var_decl(v, out), + Stmt::Decl(ast::Decl::Fn(f)) => { + if let Some(b) = &f.function.body { + for s in &b.stmts { + collect_idents_stmt(s, out); + } + } + } + Stmt::Decl(_) => {} + Stmt::Block(b) => { + for s in &b.stmts { + collect_idents_stmt(s, out); + } + } + Stmt::Return(r) => { + if let Some(a) = &r.arg { + collect_idents_expr(a, out); + } + } + Stmt::If(i) => { + collect_idents_expr(&i.test, out); + collect_idents_stmt(&i.cons, out); + if let Some(alt) = &i.alt { + collect_idents_stmt(alt, out); + } + } + Stmt::While(w) => { + collect_idents_expr(&w.test, out); + collect_idents_stmt(&w.body, out); + } + Stmt::DoWhile(w) => { + collect_idents_stmt(&w.body, out); + collect_idents_expr(&w.test, out); + } + Stmt::For(f) => { + match &f.init { + Some(ast::VarDeclOrExpr::Expr(e)) => collect_idents_expr(e, out), + Some(ast::VarDeclOrExpr::VarDecl(v)) => collect_idents_var_decl(v, out), + None => {} + } + if let Some(t) = &f.test { + collect_idents_expr(t, out); + } + if let Some(u) = &f.update { + collect_idents_expr(u, out); + } + collect_idents_stmt(&f.body, out); + } + Stmt::ForIn(f) => { + collect_idents_expr(&f.right, out); + if let ast::ForHead::VarDecl(v) = &f.left { + collect_idents_var_decl(v, out); + } + collect_idents_stmt(&f.body, out); + } + Stmt::ForOf(f) => { + collect_idents_expr(&f.right, out); + if let ast::ForHead::VarDecl(v) = &f.left { + collect_idents_var_decl(v, out); + } + collect_idents_stmt(&f.body, out); + } + Stmt::Switch(s) => { + collect_idents_expr(&s.discriminant, out); + for case in &s.cases { + if let Some(t) = &case.test { + collect_idents_expr(t, out); + } + for st in &case.cons { + collect_idents_stmt(st, out); + } + } + } + Stmt::Throw(t) => collect_idents_expr(&t.arg, out), + Stmt::Try(t) => { + for s in &t.block.stmts { + collect_idents_stmt(s, out); + } + if let Some(h) = &t.handler { + for s in &h.body.stmts { + collect_idents_stmt(s, out); + } + } + if let Some(f) = &t.finalizer { + for s in &f.stmts { + collect_idents_stmt(s, out); + } + } + } + Stmt::Labeled(l) => collect_idents_stmt(&l.body, out), + Stmt::With(w) => { + collect_idents_expr(&w.obj, out); + collect_idents_stmt(&w.body, out); + } + Stmt::Break(_) | Stmt::Continue(_) | Stmt::Empty(_) | Stmt::Debugger(_) => {} + } +} diff --git a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs index 67675529e1..6e0bcc27d2 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs @@ -144,6 +144,7 @@ pub(super) fn lower_nested_fn_decl( let stmts = generate_param_destructuring_stmts(ctx, pat, *param_id)?; destructuring_stmts.extend(stmts); } + let destructuring_prologue_len = destructuring_stmts.len(); ctx.current_strict = is_strict; @@ -175,6 +176,17 @@ pub(super) fn lower_nested_fn_decl( // dropped `options = {}`). Defaults run before any destructuring, so // prepend after the destructuring block (ending up first in the body). let default_stmts = build_default_param_stmts(¶ms); + // Record the combined param-prologue length for generator function decls + // (`function* g([x] = d) {}`) so the generator transform runs param binding + // synchronously at call time (spec FunctionDeclarationInstantiation order). + // Mirrors `lower_fn_expr_anon`; without it generator bodies skip their + // default/destructuring param setup. See `Module.gen_param_prologue_len`. + if fn_decl.function.is_generator { + let prologue_len = default_stmts.len() + destructuring_prologue_len; + if prologue_len > 0 { + ctx.gen_param_prologue_len.insert(func_id, prologue_len); + } + } if !default_stmts.is_empty() { let mut new_body = default_stmts; new_body.append(&mut body); @@ -260,7 +272,7 @@ pub(super) fn lower_nested_fn_decl( enclosing_class: None, is_arrow: false, is_async: fn_decl.function.is_async, - is_generator: false, + is_generator: fn_decl.function.is_generator, is_strict, }; result.push(Stmt::Let { diff --git a/crates/perry-hir/src/lower_decl/mod.rs b/crates/perry-hir/src/lower_decl/mod.rs index d11d9fc978..d7d75f4b78 100644 --- a/crates/perry-hir/src/lower_decl/mod.rs +++ b/crates/perry-hir/src/lower_decl/mod.rs @@ -34,8 +34,9 @@ pub(crate) use block::{ compute_prealloc_for_hoisted_closures, lower_block_stmt, lower_block_stmt_scoped, lower_fn_body_block_stmt, lower_stmts_using_aware, pre_register_forward_captured_lets, }; +pub(crate) use body_stmt::gen_capture_scan::forward_referenced_nested_generators; pub(crate) use body_stmt::{find_native_return_in_stmts, lower_body_stmt}; -pub(crate) use class_captures::synthesize_class_captures; +pub(crate) use class_captures::{append_new_args_stmt, synthesize_class_captures}; pub(crate) use class_computed::class_computed_member_registration_expr; pub(crate) use class_decl::{lower_class_decl, lower_class_from_ast}; pub(crate) use class_members::{ diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index 59d4b17157..8e4cc6e589 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -220,9 +220,75 @@ unsafe fn throw_if_typed_array_proto(arr: *const ArrayHeader, method: &str) { } } +/// If `arr` (the already-unwrapped receiver pointer from the +/// `Expr::Array{Values,Keys,Entries}` fold) is actually a Map/Set — registered +/// directly OR a `class X extends Map|Set` instance carrying the hidden backing +/// collection — return the matching COLLECTION iterator object instead of +/// treating it as an array. The any-typed `.values()/.keys()/.entries()` fold +/// (`array_only_methods.rs`, #597) routes every dynamic-receiver call through +/// `js_array_*_iter_obj`; without this a Map/Set (or subclass) receiver was +/// iterated as a (non-)array and yielded an EMPTY iterator — NestJS's +/// `[...modulesContainer.values()]` (a `class ModulesContainer extends Map`) +/// returned 0 entries, so the injector never instantiated controllers/providers +/// and route handlers saw a field-less stub `this` (#wall14). `kind`: 0 = +/// values, 1 = keys, 2 = entries. +unsafe fn collection_iter_obj_for_receiver(arr: *const ArrayHeader, kind: u8) -> Option { + let raw = arr as usize; + if raw < 0x10000 { + return None; + } + if crate::map::is_registered_map(raw) { + let m = raw as *const crate::map::MapHeader; + return Some(match kind { + 1 => crate::collection_iter_object::js_map_keys_iter_obj(m), + 2 => crate::collection_iter_object::js_map_entries_iter_obj(m), + _ => crate::collection_iter_object::js_map_values_iter_obj(m), + }); + } + if crate::set::is_registered_set(raw) { + let s = raw as *const crate::set::SetHeader; + return Some(match kind { + 1 => crate::collection_iter_object::js_set_keys_iter_obj(s), + 2 => crate::collection_iter_object::js_set_entries_iter_obj(s), + _ => crate::collection_iter_object::js_set_values_iter_obj(s), + }); + } + // `class X extends Map|Set` instance — probe the hidden backing field via + // the reconstructed NaN-boxed pointer value. + let boxed = f64::from_bits(JSValue::pointer(arr as *const u8).bits()); + match crate::object::map_set_subclass::subclass_backing_of(boxed) { + Some(crate::object::map_set_subclass::CollectionBacking::Map(m)) => Some(match kind { + 1 => crate::collection_iter_object::js_map_keys_iter_obj( + m as *const crate::map::MapHeader, + ), + 2 => crate::collection_iter_object::js_map_entries_iter_obj( + m as *const crate::map::MapHeader, + ), + _ => crate::collection_iter_object::js_map_values_iter_obj( + m as *const crate::map::MapHeader, + ), + }), + Some(crate::object::map_set_subclass::CollectionBacking::Set(s)) => Some(match kind { + 1 => crate::collection_iter_object::js_set_keys_iter_obj( + s as *const crate::set::SetHeader, + ), + 2 => crate::collection_iter_object::js_set_entries_iter_obj( + s as *const crate::set::SetHeader, + ), + _ => crate::collection_iter_object::js_set_values_iter_obj( + s as *const crate::set::SetHeader, + ), + }), + None => None, + } +} + #[no_mangle] pub extern "C" fn js_array_values_iter_obj(arr: *const ArrayHeader) -> i64 { unsafe { + if let Some(it) = collection_iter_obj_for_receiver(arr, 0) { + return it; + } guard_coercible_this(arr, "values"); throw_if_typed_array_proto(arr, "values"); array_iter_obj_raw(typed_array_iter_arr(arr), KIND_VALUES) @@ -232,6 +298,9 @@ pub extern "C" fn js_array_values_iter_obj(arr: *const ArrayHeader) -> i64 { #[no_mangle] pub extern "C" fn js_array_keys_iter_obj(arr: *const ArrayHeader) -> i64 { unsafe { + if let Some(it) = collection_iter_obj_for_receiver(arr, 1) { + return it; + } guard_coercible_this(arr, "keys"); throw_if_typed_array_proto(arr, "keys"); array_iter_obj_raw(typed_array_iter_arr(arr), KIND_KEYS) @@ -241,6 +310,9 @@ pub extern "C" fn js_array_keys_iter_obj(arr: *const ArrayHeader) -> i64 { #[no_mangle] pub extern "C" fn js_array_entries_iter_obj(arr: *const ArrayHeader) -> i64 { unsafe { + if let Some(it) = collection_iter_obj_for_receiver(arr, 2) { + return it; + } guard_coercible_this(arr, "entries"); throw_if_typed_array_proto(arr, "entries"); array_iter_obj_raw(typed_array_iter_arr(arr), KIND_ENTRIES) diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index d345574fbf..7b9075d179 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -42,6 +42,23 @@ pub extern "C" fn js_for_of_to_array(val_f64: f64) -> f64 { return js_nanbox_pointer(entries as i64); } + // `class X extends Map | Set` instance — iterate its hidden backing + // collection (`for (const [k,v] of mapSubclass)` / `for (const v of + // setSubclass)`). Map yields `[k, v]` pairs (=== `.entries()`), Set yields + // values, matching the builtins' default `[Symbol.iterator]`. Skipped when + // the subclass overrides `[Symbol.iterator]` so the override drives `for…of`. + match crate::object::map_set_subclass::subclass_backing_for_default_iteration(val_f64) { + Some(crate::object::map_set_subclass::CollectionBacking::Map(m)) => { + let arr = js_map_entries_for_for_of(m as i64); + return js_nanbox_pointer(arr as i64); + } + Some(crate::object::map_set_subclass::CollectionBacking::Set(s)) => { + let arr = js_set_to_array_for_for_of(s as i64); + return js_nanbox_pointer(arr as i64); + } + None => {} + } + // Strings: iterate by code point. `is_any_string` covers both heap // STRING_TAG and inline SSO short strings. `js_get_string_pointer_unified` // returns a real `*const StringHeader` for either representation @@ -673,6 +690,22 @@ pub(crate) fn array_from_spread_value(value: f64) -> *mut ArrayHeader { if crate::map::is_registered_map(raw_ptr) { return crate::map::js_map_entries(raw_ptr as *const crate::map::MapHeader); } + // `class X extends Map | Set` instance — spread (`[...container]`, + // `Array.from(container)`, `fn(...container)`) over the hidden backing + // collection's default iterator (Map → entries, Set → values), matching + // the builtins. The `is_registered_*` checks above only match a real + // Map/Set value, so a subclass instance (a plain object with a backing + // field) falls through to here. Skipped when the subclass overrides + // `[Symbol.iterator]` so the override drives the spread. + match crate::object::map_set_subclass::subclass_backing_for_default_iteration(value) { + Some(crate::object::map_set_subclass::CollectionBacking::Map(m)) => { + return crate::map::js_map_entries(m as *const crate::map::MapHeader); + } + Some(crate::object::map_set_subclass::CollectionBacking::Set(s)) => { + return crate::set::js_set_to_array(s as *const crate::set::SetHeader); + } + None => {} + } if crate::typedarray::lookup_typed_array_kind(raw_ptr).is_some() { return crate::typedarray::typed_array_to_array( raw_ptr as *const crate::typedarray::TypedArrayHeader, diff --git a/crates/perry-runtime/src/builtins/numbers.rs b/crates/perry-runtime/src/builtins/numbers.rs index 92aecda7bd..6a1a33a23d 100644 --- a/crates/perry-runtime/src/builtins/numbers.rs +++ b/crates/perry-runtime/src/builtins/numbers.rs @@ -615,46 +615,15 @@ pub extern "C" fn js_string_coerce(value: f64) -> *mut StringHeader { /// Returns true if value is NaN. #[no_mangle] pub extern "C" fn js_is_nan(value: f64) -> f64 { - let jsval = JSValue::from_bits(value.to_bits()); - - // isNaN first coerces to number, then checks for NaN - let num = if jsval.is_undefined() { - f64::NAN - } else if jsval.is_null() { - 0.0 - } else if jsval.is_bool() { - if jsval.as_bool() { - 1.0 - } else { - 0.0 - } - } else if jsval.is_string() { - // Parse string as number - let ptr = jsval.as_string_ptr(); - if ptr.is_null() { - f64::NAN - } else { - unsafe { - let len = (*ptr).byte_len as usize; - let data = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data, len); - if let Ok(s) = std::str::from_utf8(bytes) { - let trimmed = s.trim(); - if trimmed.is_empty() { - 0.0 - } else { - trimmed.parse::().unwrap_or(f64::NAN) - } - } else { - f64::NAN - } - } - } - } else { - value - }; - - // Return NaN-boxed boolean (TAG_TRUE / TAG_FALSE) + // `isNaN(x)` is `Number.isNaN(ToNumber(x))`. Delegate to the canonical + // ToNumber (`js_number_coerce`), which correctly handles SSO inline short + // strings (e.g. `isNaN("16")` → false), heap strings, int32/bigint, Date, + // arrays, and object toPrimitive. The previous hand-rolled coercion used + // `is_string()` (heap STRING_TAG only), so a 2-char SSO numeric string like + // a `content-length: "16"` header fell through to the `else` arm and + // returned the raw NaN-boxed bits → `isNaN("16")` wrongly reported `true`, + // which made express body-parser's `type-is.hasBody` skip JSON parsing. + let num = js_number_coerce(value); const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; if num.is_nan() { @@ -668,46 +637,11 @@ pub extern "C" fn js_is_nan(value: f64) -> f64 { /// Returns true if value is a finite number. #[no_mangle] pub extern "C" fn js_is_finite(value: f64) -> f64 { - let jsval = JSValue::from_bits(value.to_bits()); - - // isFinite first coerces to number, then checks for finite - let num = if jsval.is_undefined() { - f64::NAN - } else if jsval.is_null() { - 0.0 - } else if jsval.is_bool() { - if jsval.as_bool() { - 1.0 - } else { - 0.0 - } - } else if jsval.is_string() { - // Parse string as number - let ptr = jsval.as_string_ptr(); - if ptr.is_null() { - f64::NAN - } else { - unsafe { - let len = (*ptr).byte_len as usize; - let data = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data, len); - if let Ok(s) = std::str::from_utf8(bytes) { - let trimmed = s.trim(); - if trimmed.is_empty() { - 0.0 - } else { - trimmed.parse::().unwrap_or(f64::NAN) - } - } else { - f64::NAN - } - } - } - } else { - value - }; - - // Return NaN-boxed boolean (TAG_TRUE / TAG_FALSE) + // `isFinite(x)` is `Number.isFinite(ToNumber(x))`. Delegate to the canonical + // ToNumber (`js_number_coerce`) so SSO inline short strings (`isFinite("16")` + // → true) and all other types coerce correctly; the old `is_string()`-only + // path mishandled SSO strings (same root cause as `js_is_nan`). + let num = js_number_coerce(value); const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; if num.is_finite() { diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index 43c1890f68..9960076381 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -836,6 +836,16 @@ pub(crate) fn clone_closure_rebind_this(closure_bits: u64, recv_box: f64) -> u64 } unsafe { let header = ptr as *const ClosureHeader; + // Arrow functions bind `this` lexically: their `this` capture slot holds + // the enclosing instance and must NEVER be overwritten with a call-time + // receiver (proxy handler, getter receiver, method-call object, …). + // They still carry CAPTURES_THIS_FLAG (the body reads `this`), so the + // flag check below does not exclude them — guard explicitly. Without this, + // an arrow used as a proxy trap / accessor would observe the rebind + // receiver and lose its captured instance's data fields (#wall11). + if crate::closure::closure_is_arrow(header) { + return closure_bits; + } let raw_count = (*header).capture_count; // No CAPTURES_THIS_FLAG → the closure body doesn't read `this`, no rebind needed. if raw_count & CAPTURES_THIS_FLAG == 0 { diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 7279d6111b..7f95fd0420 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -1105,6 +1105,27 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera .unwrap_or_else(HeapChildSlotIterator::empty) } GcLayoutSlotKind::ObjectFields => { + // Wall 18 follow-up: a `RegExpHeader` is allocated as + // `GC_TYPE_OBJECT` but is a NATIVE struct, NOT a shaped JS object. + // The generic ObjectHeader read takes `field_count` from offset 12, + // which for a `RegExpHeader` overlaps the high 32 bits of + // `pattern_ptr` (~900 on macOS's 0x3xx_… heap) → a bogus ~900-slot + // range that scans/rewrites ADJACENT heap during evacuation (heap + // corruption; `PERRY_GC_VERIFY_EVACUATION` reports it as a stale + // forwarded pointer "inside" the regex at an offset far past its + // size). This is a latent pre-existing bug — exposed deterministically + // once Wall 18 grew the header. Detect the regex via its + // self-identifying magic and scan EXACTLY its GC-visible slots — + // `pattern_ptr`/`flags_ptr` (a 2-slot contiguous payload range) and + // `last_index` (the prefix slot). The off-heap `regex_ptr`/`fancy_ptr`, + // the bool flags, the `magic` sentinel, and any tail padding are never + // inspected, so evacuation can never touch raw native data. + if crate::regex::regex_header_has_magic(user_ptr as *const crate::regex::RegExpHeader) { + let (pattern_slot, slot_count, last_index_slot) = + crate::regex::regex_gc_slot_ptrs(user_ptr as *mut crate::regex::RegExpHeader); + let range = HeapSlotRange::new(pattern_slot, slot_count); + return HeapChildSlotIterator::new(header, Some(last_index_slot), range); + } let obj = user_ptr as *mut crate::object::ObjectHeader; let Some(range) = crate::object::gc_field_slot_range(obj) else { return HeapChildSlotIterator::empty(); diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 415d9b1a96..ffa643f0db 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -1402,6 +1402,33 @@ static KEEP_JS_MAP_FROM_ITERABLE: extern "C" fn(f64) -> *mut MapHeader = js_map_ /// when omitted at the call site. #[no_mangle] pub extern "C" fn js_map_foreach(map: *const MapHeader, callback: f64, this_arg: f64) { + js_map_foreach_impl( + map, + callback, + this_arg, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); +} + +/// `Map.prototype.forEach` for a `class … extends Map` subclass instance: the +/// 3rd callback argument and the `self === collection` identity must be the +/// SUBCLASS instance (`collection`), not the hidden backing map. The actual +/// iteration runs over `map` (the backing). `collection` is a NaN-boxed value. +pub(crate) fn js_map_foreach_with_collection( + map: *const MapHeader, + callback: f64, + this_arg: f64, + collection: f64, +) { + js_map_foreach_impl(map, callback, this_arg, collection); +} + +fn js_map_foreach_impl( + map: *const MapHeader, + callback: f64, + this_arg: f64, + collection_override: f64, +) { // ECMA-262 Map.prototype.forEach step 4: a non-callable callback throws a // TypeError *before* iterating (and before any null-map early return). // Without this, a non-function callback either silently no-ops or — for a @@ -1415,12 +1442,14 @@ pub extern "C" fn js_map_foreach(map: *const MapHeader, callback: f64, this_arg: let map_handle = scope.root_raw_const_ptr(map); let callback_handle = scope.root_nanbox_f64(callback); let this_handle = scope.root_nanbox_f64(this_arg); + // When a subclass instance is the observable receiver, root it too so it + // survives a GC triggered inside the callback. + let has_override = collection_override.to_bits() != crate::value::TAG_UNDEFINED; + let collection_handle = scope.root_nanbox_f64(collection_override); unsafe { let map = map_handle.get_raw_const_ptr::(); // The collection itself is the third callback argument and the // identity user code compares `self === m` against. - let map_value = crate::value::js_nanbox_pointer(map as i64); - // ECMA-262 24.1.3.5: forEach iterates [[MapData]] in insertion order, // re-reading the live entry count each step. Entries appended during // the callback (`map.set` inside the callback) MUST be visited, so the @@ -1434,6 +1463,14 @@ pub extern "C" fn js_map_foreach(map: *const MapHeader, callback: f64, this_arg: if i >= (*map).size as usize { break; } + // Re-derive the collection identity each step from a rooted handle + // so a GC during a prior callback (which may relocate the backing + // map or the subclass instance) never bakes in a stale pointer. + let map_value = if has_override { + collection_handle.get_nanbox_f64() + } else { + crate::value::js_nanbox_pointer(map as i64) + }; let entries = entries_ptr(map); let key = ptr::read(entries.add(i * 2)); let value = ptr::read(entries.add(i * 2 + 1)); diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 368e32303a..1a5c312c28 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -1268,7 +1268,9 @@ pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { let key_val = crate::array::js_array_get(keys, pos(i)); if hide_private { if let Some(b) = crate::string::js_string_key_bytes(key_val, &mut sso_buf) { - if b.first() == Some(&b'#') { + if b.first() == Some(&b'#') + || super::field_get_set::is_internal_runtime_key_bytes(b) + { continue; } } diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index fd61e0e239..006c608180 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -130,7 +130,8 @@ pub(crate) use crypto_key::{ }; pub(crate) use enumeration::{ canonical_array_index, descriptor_marks_non_enumerable, ecma_own_key_order, - instance_private_key_hidden, keys_contain_array_index, + instance_private_key_hidden, is_internal_runtime_key, is_internal_runtime_key_bytes, + keys_contain_array_index, }; pub use enumeration::{ js_for_in_keys_value, js_object_entries, js_object_entries_value, js_object_keys, 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 6215444937..75190e559a 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -737,7 +737,7 @@ pub extern "C" fn js_object_keys(obj: *const ObjectHeader) -> *mut ArrayHeader { Ok(s) => s, Err(_) => continue, }; - if hide_private && key_str.starts_with('#') { + if hide_private && (key_str.starts_with('#') || is_internal_runtime_key(key_str)) { continue; } // If a descriptor explicitly marks this key non-enumerable, skip it. @@ -768,10 +768,30 @@ pub(crate) unsafe fn instance_private_key_hidden( } let mut buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; crate::string::js_string_key_bytes(key_val, &mut buf) - .map(|b| b.first() == Some(&b'#')) + .map(|b| b.first() == Some(&b'#') || is_internal_runtime_key_bytes(b)) .unwrap_or(false) } +/// True for perry's hidden runtime-internal own keys — currently exactly the +/// `__perry_collection_backing__` field stashed on a `class … extends Map/Set` +/// instance. This physically lives in the instance keys_array but must NEVER +/// surface to `Object.keys` / `for…in` / `Object.getOwnPropertyNames` / +/// `JSON.stringify` / `Object.hasOwn` / `hasOwnProperty` / `propertyIsEnumerable`. +/// +/// Matches the backing key EXACTLY (an allowlist), not a broad `__perry_*` +/// prefix — a prefix test would wrongly hide legitimate user properties whose +/// name happens to begin with `__perry_` (e.g. `this.__perry_user = 1`). +#[inline] +pub(crate) fn is_internal_runtime_key_bytes(b: &[u8]) -> bool { + b == crate::object::map_set_subclass::BACKING_KEY +} + +/// `&str` form of [`is_internal_runtime_key_bytes`]. +#[inline] +pub(crate) fn is_internal_runtime_key(s: &str) -> bool { + is_internal_runtime_key_bytes(s.as_bytes()) +} + /// True when a per-property descriptor marks `key_val`'s name non-enumerable /// (`Object.defineProperty(o, k, { enumerable: false })`). Mirrors the /// slow-path filter in `js_object_keys` so `Object.values`/`Object.entries` diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 2e2236406d..6891f662f2 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -4,6 +4,24 @@ use super::*; +/// Wall 10 — read a property a framework attached to a native registry handle +/// via `Object.setPrototypeOf(handle, proto)` (Express's `res`/`req`). The link +/// is keyed by the handle id in `OBJECT_PROTOTYPES`. Returns `None` (so the +/// caller yields `undefined`) when no recorded prototype carries the key. +/// Cheap when no prototype was recorded (the common handle case). +fn handle_proto_inherited_field( + handle_id: usize, + key: *const crate::StringHeader, +) -> Option { + crate::object::prototype_chain::object_static_prototype(handle_id)?; + let v = crate::object::prototype_chain::resolve_inherited_field(handle_id, key)?; + if v.bits() == crate::value::TAG_UNDEFINED { + None + } else { + Some(v) + } +} + #[no_mangle] pub extern "C" fn js_object_get_field_by_name( obj: *const ObjectHeader, @@ -30,6 +48,48 @@ pub extern "C" fn js_object_get_field_by_name( } } } + // `class X extends Map | Set` instance — `.size` reads the hidden backing + // collection's size. A subclass CAN still define an own `size` (class field + // or `Object.defineProperty`), so check own-property precedence first and + // only fall back to the backing size when no own key exists. Other backed + // reads (`.has`/`.get`/… as METHODS) route through `js_native_call_method`. + // Guarded by `is_above_handle_band` (like the class-object probe below) so a + // native handle id in [0x10000, 0x100000) never reaches `own_key_present`'s + // ObjectHeader deref; real subclass instances are ordinary heap objects + // above the band. + if !key.is_null() + && ((obj as u64) >> 48) == 0 + && crate::value::addr_class::is_above_handle_band(obj as usize) + { + unsafe { + let name_ptr = (key as *const u8).add(std::mem::size_of::()); + let name_len = (*key).byte_len as usize; + if std::slice::from_raw_parts(name_ptr, name_len) == b"size" + && !super::super::own_key_present(obj as *mut ObjectHeader, key) + { + // A subclass may also OVERRIDE `size` on its prototype + // (`class M extends Map { get size() { return 42 } }`). Such an + // inherited getter lives in the class vtable, not as an own key, + // so check the class chain first and fall through to the normal + // class/prototype resolution when it shadows the backing size. + let class_id = super::super::js_object_get_class_id(obj); + let has_inherited_size = class_id != 0 + && super::super::native_module::class_instance_has_member(class_id, "size"); + if !has_inherited_size { + let boxed = f64::from_bits(JSValue::pointer(obj as *const u8).bits()); + match crate::object::map_set_subclass::subclass_backing_of(boxed) { + Some(crate::object::map_set_subclass::CollectionBacking::Map(m)) => { + return JSValue::number(crate::map::js_map_size(m) as f64); + } + Some(crate::object::map_set_subclass::CollectionBacking::Set(s)) => { + return JSValue::number(crate::set::js_set_size(s) as f64); + } + None => {} + } + } + } + } + } // A per-evaluation class object (`ClassExprFresh`, #1772/#1787) reaches // here as a RAW heap pointer (a real ObjectHeader, so its top 16 address // bits are 0 — distinguishing it from a `0x7FFE` class-ref value or any @@ -349,8 +409,20 @@ pub extern "C" fn js_object_get_field_by_name( } if let Some(dispatch) = handle_property_dispatch() { let bits = dispatch(raw as i64, key_ptr, key_len); + // Wall 10 — fall back to a `setPrototypeOf(handle, proto)` + // member (Express's augmented `res`/`req`) when the native + // dispatch doesn't know the key. See + // `handle_proto_inherited_field`. + if bits.to_bits() == crate::value::TAG_UNDEFINED { + if let Some(v) = handle_proto_inherited_field(raw, key) { + return v; + } + } return JSValue::from_bits(bits.to_bits()); } + if let Some(v) = handle_proto_inherited_field(raw, key) { + return v; + } } } return JSValue::undefined(); diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index ad5a607b16..9e39fdf0d1 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -446,6 +446,13 @@ unsafe fn ordinary_has_property( ) -> bool { const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; let key_name = super::super::has_own_helpers::str_from_string_header(key); + // Wall 10 follow-up: if `Object.setPrototypeOf(instance, proto)` recorded an + // explicit replacement `[[Prototype]]` for THIS instance, the class-vtable + // fallback below must be skipped — the recorded chain (walked above) is now + // authoritative, so a key that was deleted/replaced off the prototype must + // not be resurrected from the original class vtable. + let has_recorded_prototype = + super::super::prototype_chain::object_static_prototype(obj_ptr as usize).is_some(); let mut cur = obj_ptr; let mut last_valid = obj_ptr; let mut guard = 0u32; @@ -491,6 +498,21 @@ unsafe fn ordinary_has_property( None => break, } } + // Wall 10 — a class instance's prototype METHODS / GETTERS / SETTERS live in + // `CLASS_VTABLE_REGISTRY`, not as a recorded `[[Prototype]]` object with a + // `keys_array`, so the own-key + recorded-prototype walk above misses them. + // Check the class chain so `'method' in instance` is `true` (e.g. NestJS's + // app Proxy gating on `'listen' in receiver`). + if !has_recorded_prototype { + if let Some(name) = key_name { + let class_id = unsafe { (*obj_ptr).class_id }; + if class_id != 0 + && super::super::native_module::class_instance_has_member(class_id, name) + { + return true; + } + } + } // Inherited `Object.prototype` properties (`toString`, `hasOwnProperty`, …, // plus any user-assigned `Object.prototype` members). ordinary_object_prototype_property_value(last_valid, key).is_some() diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index e37f0ab825..f92163a587 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -257,7 +257,23 @@ pub extern "C" fn js_object_get_field_ic_miss( unsafe { let key_ptr = (key as *const u8).add(std::mem::size_of::()); let key_len = (*key).byte_len as usize; - return dispatch(obj as i64, key_ptr, key_len); + let bits = dispatch(obj as i64, key_ptr, key_len); + // Wall 10 — fall back to a `setPrototypeOf(handle, proto)` member + // (Express's augmented `res`/`req`) when the native dispatch + // doesn't know the key. Mirrors `js_object_get_field_by_name`. + if bits.to_bits() == crate::value::TAG_UNDEFINED { + if let Some(v) = crate::object::prototype_chain::object_static_prototype( + obj as usize, + ) + .and( + crate::object::prototype_chain::resolve_inherited_field(obj as usize, key), + ) { + if v.bits() != crate::value::TAG_UNDEFINED { + return f64::from_bits(v.bits()); + } + } + } + return bits; } } return f64::from_bits(crate::value::TAG_UNDEFINED); diff --git a/crates/perry-runtime/src/object/map_set_subclass.rs b/crates/perry-runtime/src/object/map_set_subclass.rs new file mode 100644 index 0000000000..90f6f80ef7 --- /dev/null +++ b/crates/perry-runtime/src/object/map_set_subclass.rs @@ -0,0 +1,180 @@ +//! `class X extends Map` / `class X extends Set` — subclass backing support. +//! +//! Perry models a class instance as a plain `ObjectHeader`, not a real exotic +//! Map/Set (`MapHeader`/`SetHeader` are separate, header-less-class allocations). +//! So `super()` to a `Map`/`Set` parent used to be a best-effort no-op, leaving +//! the subclass instance with no collection storage and no `has`/`get`/`set`/… +//! methods — `m.has(k)` threw "has is not a function". NestJS's +//! `ModulesContainer extends Map` (and any user `class … extends Map`) hit this. +//! +//! Fix: `super()` calls `js_map_set_subclass_init`, which allocates a real +//! `MapHeader`/`SetHeader`, optionally seeds it from the constructor's iterable +//! argument, and stashes its NaN-boxed pointer on the instance under a hidden +//! field. Because it is a normal object field, the GC traces + relocates it. +//! +//! The collection method/iterator/`.size` surface is then served by checking +//! for this backing field at the runtime dispatch points (see +//! `subclass_backing_of` callers in `native_call_method`, `for_of`, and +//! `field_get_set`). This is more robust than installing per-instance method +//! closures: it covers method calls, `for…of`, and `.size` reads uniformly. + +use crate::map::MapHeader; +use crate::object::{js_object_get_field_by_name_f64, js_object_set_field_by_name, ObjectHeader}; +use crate::set::SetHeader; +use crate::value::{JSValue, POINTER_MASK}; + +/// Hidden field on a Map/Set subclass instance holding the NaN-boxed backing +/// `MapHeader`/`SetHeader` pointer. +pub(crate) const BACKING_KEY: &[u8] = b"__perry_collection_backing__"; + +#[derive(Clone, Copy)] +pub(crate) enum CollectionBacking { + Map(*mut MapHeader), + Set(*mut SetHeader), +} + +fn raw_ptr_from_value(value: f64) -> usize { + let bits = value.to_bits(); + let jsval = JSValue::from_bits(bits); + if jsval.is_pointer() { + return (bits & POINTER_MASK) as usize; + } + if bits != 0 && bits < 0x0001_0000_0000_0000 { + return bits as usize; + } + 0 +} + +unsafe fn instance_object_ptr(this: f64) -> Option<*mut ObjectHeader> { + let raw = raw_ptr_from_value(this); + if raw < crate::gc::GC_HEADER_SIZE + 0x1000 { + return None; + } + // `this` can be a raw, header-less collection/buffer handle (a real Map/Set, + // a Buffer, or a typed array) when this runs before raw collection dispatch. + // Those allocations carry no `GcHeader`, so reading `raw - GC_HEADER_SIZE` + // would crash or misclassify allocator metadata. Magnitude-classify the + // address (rejecting the handle band + slab allocations) before any header + // read, and reject registered non-object collections outright. + if crate::map::is_registered_map(raw) + || crate::set::is_registered_set(raw) + || crate::buffer::is_registered_buffer(raw) + || crate::typedarray::lookup_typed_array_kind(raw).is_some() + { + return None; + } + let header = crate::value::addr_class::try_read_gc_header(raw)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT { + return None; + } + Some(raw as *mut ObjectHeader) +} + +/// If `value` is a Map/Set *subclass instance* (a plain object carrying the +/// hidden backing field), return its backing collection. Returns `None` for +/// real Maps/Sets, ordinary objects, and non-objects — so callers fall through +/// to their existing handling. +pub(crate) fn subclass_backing_of(value: f64) -> Option { + unsafe { + let obj = instance_object_ptr(value)?; + let backing = js_object_get_field_by_name_f64( + obj as *const ObjectHeader, + crate::string::js_string_from_bytes(BACKING_KEY.as_ptr(), BACKING_KEY.len() as u32), + ); + let bjs = JSValue::from_bits(backing.to_bits()); + if !bjs.is_pointer() { + return None; + } + let raw = (backing.to_bits() & POINTER_MASK) as usize; + if raw < crate::gc::GC_HEADER_SIZE + 0x1000 { + return None; + } + if crate::map::is_registered_map(raw) { + return Some(CollectionBacking::Map(raw as *mut MapHeader)); + } + if crate::set::is_registered_set(raw) { + return Some(CollectionBacking::Set(raw as *mut SetHeader)); + } + None + } +} + +/// True when a Map/Set subclass INSTANCE carries a USER `[Symbol.iterator]` +/// override anywhere on its class/prototype chain — an own +/// `inst[Symbol.iterator] = …`, a symbol accessor, or a class method +/// `*[Symbol.iterator]()` (registered under the synthetic `@@iterator` name). +/// The backing-store iteration shortcuts must defer to such an override and only +/// synthesize the built-in default iterator when none exists. Returns `false` +/// for non-subclass values. +pub(crate) fn subclass_has_iterator_override(value: f64) -> bool { + unsafe { + let Some(obj) = instance_object_ptr(value) else { + return false; + }; + let iter_wk = crate::symbol::well_known_symbol("iterator"); + if iter_wk.is_null() { + return false; + } + let iter_f64 = f64::from_bits(JSValue::pointer(iter_wk as *const u8).bits()); + // Own symbol property or symbol accessor on the instance. + if crate::symbol::own_symbol_property(value, iter_f64).is_some() { + return true; + } + // Class-method override `*[Symbol.iterator]()` anywhere on the chain. + // The built-in Map/Set iterator is a runtime default, NOT a class vtable + // method, so a hit here means the user declared one. + let class_id = crate::object::js_object_get_class_id(obj); + if class_id != 0 && crate::object::method_owner_class_id(class_id, "@@iterator").is_some() { + return true; + } + false + } +} + +/// Like [`subclass_backing_of`] but returns the backing only when there is NO +/// user `[Symbol.iterator]` override — so the iteration fast paths fall through +/// to the normal iterator protocol when the user overrode `@@iterator`. +pub(crate) fn subclass_backing_for_default_iteration(value: f64) -> Option { + if subclass_has_iterator_override(value) { + return None; + } + subclass_backing_of(value) +} + +/// `super()` for a `class X extends Map | Set`. `kind`: 0 = Map, 1 = Set. +/// `iterable` is the (optional) first constructor argument; `undefined`/`null` +/// seed an empty collection. +#[no_mangle] +pub extern "C" fn js_map_set_subclass_init(this: f64, kind: i32, iterable: f64) -> f64 { + let obj = match unsafe { instance_object_ptr(this) } { + Some(o) => o, + None => return this, + }; + let iter_js = JSValue::from_bits(iterable.to_bits()); + let has_iter = !(iter_js.is_undefined() || iter_js.is_null()); + + // Allocate the backing collection and keep a RAW pointer root live across + // the key allocation below: `js_string_from_bytes` can allocate and trigger + // a GC, which would otherwise reclaim/relocate an unrooted backing store + // before we stash it on the instance. + let backing_ptr: *mut u8 = if kind == 0 { + let map = if has_iter { + crate::map::js_map_from_iterable(iterable) + } else { + crate::map::js_map_alloc(0) + }; + map as *mut u8 + } else { + let set = if has_iter { + crate::set::js_set_from_iterable(iterable) + } else { + crate::set::js_set_alloc(0) + }; + set as *mut u8 + }; + + let key = crate::string::js_string_from_bytes(BACKING_KEY.as_ptr(), BACKING_KEY.len() as u32); + let backing_bits = JSValue::pointer(backing_ptr as *const u8).bits(); + js_object_set_field_by_name(obj, key, f64::from_bits(backing_bits)); + this +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 5ae7d63aef..76b5ca2944 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -47,6 +47,7 @@ mod groupby; pub(crate) mod has_own_helpers; mod instanceof; pub(crate) mod iterator_prototypes; +pub(crate) mod map_set_subclass; mod namespace_create; mod native_call_method; mod native_module; diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 3b23c93c56..5e35cb96f5 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -426,6 +426,42 @@ pub unsafe extern "C" fn js_native_call_method_apply( js_native_call_method(object, method_name_ptr, method_name_len, args_ptr, args_len) } +/// Apply form of `obj[key](...args)` — the spread-call sibling of +/// `js_native_call_method_value`. `key` is a *runtime value* (computed member +/// access, e.g. `receiver[prop](...args)`) and `args_array_handle` is a JS +/// array holding every regular + spread arg already concatenated by codegen. +/// +/// Without this, a CallSpread whose callee is a computed member (`IndexGet`) +/// fell through to the plain closure-spread path (`js_closure_call_apply_with_spread`) +/// which dropped `this`, so the invoked method saw `this` = a field-less +/// prototype stub instead of `obj` (NestJS `receiver[prop](...args)` inside its +/// exception-zone proxy — the instance's data fields and inherited methods all +/// read as `undefined`). Materialise the array to a temp buffer and forward to +/// `js_native_call_method_value`, which resolves the method by key and binds +/// `this = obj`. +#[no_mangle] +pub unsafe extern "C" fn js_native_call_method_value_apply( + object: f64, + key: f64, + args_array_handle: i64, +) -> f64 { + let arr = args_array_handle as *const crate::array::ArrayHeader; + let len = if arr.is_null() { + 0 + } else { + crate::array::js_array_length(arr) as usize + }; + let buf: Vec = (0..len) + .map(|i| crate::array::js_array_get_f64(arr, i as u32)) + .collect(); + let (args_ptr, args_len) = if buf.is_empty() { + (std::ptr::null::(), 0_usize) + } else { + (buf.as_ptr(), buf.len()) + }; + js_native_call_method_value(object, key, args_ptr, args_len) +} + #[inline] fn root_string_arg_handle<'scope>( scope: &'scope crate::gc::RuntimeHandleScope, diff --git a/crates/perry-runtime/src/object/native_call_method/collection_methods.rs b/crates/perry-runtime/src/object/native_call_method/collection_methods.rs index bf58151ba7..4fc76c8eba 100644 --- a/crates/perry-runtime/src/object/native_call_method/collection_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/collection_methods.rs @@ -5,6 +5,50 @@ use super::proto_dispatch::*; use super::typed_array::*; use super::*; +/// Whether `method` is a backing-store collection method for a `class … extends +/// Map/Set` instance whose backing is `backing`. Map-only vs Set-only methods +/// are kept distinct (mirrors the codegen `is_collection_method_for_kind`). The +/// iterator names (`Symbol.iterator`/`@@iterator`) are included so spreading a +/// subclass instance still routes through the backing iterator. Anything else +/// (Object.prototype methods, user methods) must NOT be redirected. +fn is_backed_collection_method( + backing: super::super::map_set_subclass::CollectionBacking, + method: &str, +) -> bool { + let shared = matches!( + method, + "has" + | "delete" + | "clear" + | "forEach" + | "keys" + | "values" + | "entries" + | "size" + | "Symbol.iterator" + | "@@iterator" + ); + match backing { + super::super::map_set_subclass::CollectionBacking::Map(_) => { + shared || matches!(method, "get" | "set") + } + super::super::map_set_subclass::CollectionBacking::Set(_) => { + shared + || matches!( + method, + "add" + | "union" + | "intersection" + | "difference" + | "symmetricDifference" + | "isSubsetOf" + | "isSupersetOf" + | "isDisjointFrom" + ) + } + } +} + pub(super) unsafe fn dispatch_map_set( root_scope: &crate::gc::RuntimeHandleScope, object_handle: &crate::gc::RuntimeHandle, @@ -21,6 +65,85 @@ pub(super) unsafe fn dispatch_map_set( let refreshed_args = || crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(arg_handles); let _ = (root_scope, object_handle, &refreshed_args, raw_bits, jsval); let _ = (method_name_ptr, method_name_len); + // `class X extends Map | Set` instance — redirect the OPERATION onto the + // hidden backing collection so `has`/`get`/`set`/`delete`/`clear`/`size`/ + // `forEach`/`keys`/`values`/`entries` (and the Set composition methods) + // dispatch as if called on a real Map/Set. Receiver-sensitive methods, + // however, must keep the SUBCLASS INSTANCE as the observable receiver: + // * `set`/`add` return `this` (the instance) so chaining works + // (`m.set(a,1).set(b,2)`), + // * `forEach` callbacks receive the instance as their 3rd argument, + // while `clear` → undefined and `has`/`get`/`size`/`delete` read through. + if let Some(backing) = super::super::map_set_subclass::subclass_backing_of(object) { + // Only redirect ACTUAL collection methods to the backing. A non-collection + // method (`hasOwnProperty`, `propertyIsEnumerable`, `toString`, a + // user-defined subclass method, …) must fall through to the normal + // object/vtable/prototype dispatch — redirecting it onto the backing + // returned `undefined` for every such call (and hid finding 6's + // `propertyIsEnumerable` filter). Returning `None` here lets the outer + // dispatcher resolve it against `Object.prototype` / the class vtable. + if !is_backed_collection_method(backing, method_name) { + return None; + } + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + let args = if !args_ptr.is_null() && args_len > 0 { + std::slice::from_raw_parts(args_ptr, args_len) + } else { + &[] + }; + // forEach: run over the backing but observe the subclass instance. + if method_name == "forEach" { + // Pass the callback through even when absent so the impl's + // `js_validate_array_callback` throws `TypeError: callback is not a + // function` (matching Node) instead of silently returning undefined. + let callback = args.first().copied().unwrap_or(undefined); + let this_arg = args.get(1).copied().unwrap_or(undefined); + match backing { + super::super::map_set_subclass::CollectionBacking::Map(m) => { + crate::map::js_map_foreach_with_collection(m, callback, this_arg, object); + } + super::super::map_set_subclass::CollectionBacking::Set(s) => { + crate::set::js_set_foreach_with_collection(s, callback, this_arg, object); + } + } + return Some(undefined); + } + let backing_value = match backing { + super::super::map_set_subclass::CollectionBacking::Map(m) => { + f64::from_bits(JSValue::pointer(m as *const u8).bits()) + } + super::super::map_set_subclass::CollectionBacking::Set(s) => { + f64::from_bits(JSValue::pointer(s as *const u8).bits()) + } + }; + let result = dispatch_map_set( + root_scope, + object_handle, + arg_handles, + backing_value, + method_name, + method_name_ptr, + method_name_len, + args_ptr, + args_len, + ); + // `Map.prototype.set` / `Set.prototype.add` return the receiver — the + // SUBCLASS INSTANCE, not the hidden backing — so chains preserve identity. + let returns_receiver = matches!( + (backing, method_name), + ( + super::super::map_set_subclass::CollectionBacking::Map(_), + "set" + ) | ( + super::super::map_set_subclass::CollectionBacking::Set(_), + "add" + ) + ); + if returns_receiver { + return Some(object); + } + return result; + } // Check Map/Set registries for raw or NaN-boxed pointers. // Maps/Sets are allocated with plain alloc (no GcHeader), so they can't be // dispatched through the ObjectHeader path below. @@ -64,7 +187,11 @@ pub(super) unsafe fn dispatch_map_set( "size" => crate::map::js_map_size(map) as f64, // #2856: value-level iterator methods return real iterator // OBJECTS (not arrays), dispatched via class id. - "entries" => f64::from_bits( + // `class X extends Map` default iterator (`[Symbol.iterator]`) + // is `entries()` — matches the builtin Map. Reached when a + // bound `obj[Symbol.iterator]` (from `js_class_method_bind`) + // is invoked, e.g. by `iterare`'s `toIterator(modulesContainer)`. + "entries" | "Symbol.iterator" | "@@iterator" => f64::from_bits( JSValue::pointer( crate::collection_iter_object::js_map_entries_iter_obj(map) as *mut u8, ) @@ -122,7 +249,9 @@ pub(super) unsafe fn dispatch_map_set( // through to `undefined` (only add/has/delete/clear/size // were handled). Return real iterator objects; `entries` // yields `[v, v]` pairs. - "values" | "keys" => f64::from_bits( + // `class X extends Set` default iterator (`[Symbol.iterator]`) + // is `values()` — matches the builtin Set. + "values" | "keys" | "Symbol.iterator" | "@@iterator" => f64::from_bits( JSValue::pointer( crate::collection_iter_object::js_set_values_iter_obj(set) as *mut u8, ) diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index e7481cb229..aba2915e54 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -179,6 +179,16 @@ pub(super) unsafe fn dispatch_common( } let obj_ptr = jsval.as_pointer::(); if !obj_ptr.is_null() && is_valid_obj_ptr(obj_ptr as *const u8) { + // perry's hidden `__perry_collection_backing__` runtime-internal + // field lives in a class instance's keys_array but is never a + // reflectable own property — `hasOwnProperty` must report false. + if (*obj_ptr).class_id != 0 { + if let Some(key) = super::has_own_helpers::str_from_string_header(key_str) { + if crate::object::field_get_set::is_internal_runtime_key(key) { + return Some(f64::from_bits(JSValue::bool(false).bits())); + } + } + } return Some(f64::from_bits( JSValue::bool(own_key_present(obj_ptr as *mut ObjectHeader, key_str)) .bits(), @@ -302,6 +312,14 @@ pub(super) unsafe fn dispatch_common( )); } } + // perry's hidden `__perry_*` runtime-internal own keys (the + // `class … extends Map/Set` backing field) live in the instance + // keys_array but are never observable — report non-enumerable. + if (*obj_ptr).class_id != 0 + && crate::object::field_get_set::is_internal_runtime_key(key_name) + { + return Some(f64::from_bits(JSValue::bool(false).bits())); + } if !own_key_present(obj_ptr as *mut ObjectHeader, key_str) { return Some(f64::from_bits(JSValue::bool(false).bits())); } diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index 1077365544..05cb54df3d 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -5,6 +5,56 @@ use super::proto_dispatch::*; use super::typed_array::*; use super::*; +/// Wall 10 — resolve and invoke a method that a framework attached to a native +/// registry handle via `Object.setPrototypeOf(handle, proto)` (Express's +/// augmented `res.send` / `req.accepts` / …). The link was recorded in the +/// `OBJECT_PROTOTYPES` side-table keyed by the handle id (see +/// `js_object_set_prototype_of`). Walk it via `resolve_inherited_field`; if the +/// resolved member is a callable closure, invoke it with `this` bound to the +/// handle value (`object`) so the method's internal `this.end(...)` / +/// `this.statusCode = …` route back to the native handle dispatch. +/// +/// Returns `None` when no recorded prototype yields a callable for `method_name` +/// — the caller then falls back to JS `undefined`, preserving prior behavior for +/// genuinely-unknown handle methods. +unsafe fn dispatch_handle_proto_method( + handle_id: usize, + object: f64, + method_name: &str, + args_ptr: *const f64, + args_len: usize, +) -> Option { + // Only do the (locked) side-table walk when a prototype was actually + // recorded for this handle — the overwhelmingly common case (fastify / + // axios / ioredis handles with no user setPrototypeOf) skips it cheaply. + crate::object::prototype_chain::object_static_prototype(handle_id)?; + let key = crate::string::js_string_from_bytes(method_name.as_ptr(), method_name.len() as u32); + let resolved = crate::object::prototype_chain::resolve_inherited_field( + handle_id, + key as *const crate::StringHeader, + )?; + let resolved_bits = resolved.bits(); + if (resolved_bits >> 48) != 0x7FFD { + return None; + } + let closure_ptr = (resolved_bits & crate::value::POINTER_MASK) as usize; + if closure_ptr == 0 || !crate::closure::is_closure_ptr(closure_ptr) { + return None; + } + // An inherited prototype method may be a closure that BAKED `this` into a + // capture slot at definition time (object-literal methods are lowered with + // `captures_this`). Setting IMPLICIT_THIS alone can't override that slot, so + // rebind the closure's `this` to the handle receiver first — + // `clone_closure_rebind_this` is a no-op for closures that don't capture + // `this` and for non-closure values. Mirrors the class-prototype fallback. + let _ = closure_ptr; + let bound = crate::closure::clone_closure_rebind_this(resolved_bits, object); + let prev = crate::object::js_implicit_this_set(object); + let result = crate::closure::js_native_call_value(f64::from_bits(bound), args_ptr, args_len); + crate::object::js_implicit_this_set(prev); + Some(result) +} + pub(super) unsafe fn dispatch_handle( root_scope: &crate::gc::RuntimeHandleScope, object_handle: &crate::gc::RuntimeHandle, @@ -29,18 +79,43 @@ pub(super) unsafe fn dispatch_handle( if crate::value::addr_class::is_small_handle(raw_ptr) { // This is a handle, not a real memory pointer - dispatch to stdlib if let Some(dispatch) = handle_method_dispatch() { - return Some(dispatch( + let r = dispatch( raw_ptr as i64, method_name.as_ptr(), method_name.len(), args_ptr, args_len, - )); + ); + // Wall 10 — when the native handle dispatch doesn't recognise the + // method (returns `undefined`), the call may target a method that + // a framework attached via `Object.setPrototypeOf(handle, proto)` + // (Express's `res.send` / `req.accepts`, …). Walk the recorded + // handle prototype; if it yields a callable, invoke it with + // `this` bound to the handle so the method's internal + // `this.end(...)` / `this.statusCode = …` route back to us. + if r.to_bits() == crate::value::TAG_UNDEFINED { + if let Some(v) = dispatch_handle_proto_method( + raw_ptr, + object, + method_name, + args_ptr, + args_len, + ) { + return Some(v); + } + } + return Some(r); + } + // No dispatcher registered: still try a setPrototypeOf'd method. + if let Some(v) = + dispatch_handle_proto_method(raw_ptr, object, method_name, args_ptr, args_len) + { + return Some(v); } - // No dispatcher registered, return JS `undefined`. Must be - // TAG_UNDEFINED (0x7FFC_..._0001); the bit pattern 0x7FF8_..._0001 a - // prior copy used is a signaling NaN (a JS number), which leaks out - // as a non-object and trips `js_iterator_result_validate`. + // Return JS `undefined`. Must be TAG_UNDEFINED (0x7FFC_..._0001); the + // bit pattern 0x7FF8_..._0001 a prior copy used is a signaling NaN (a + // JS number), which leaks out as a non-object and trips + // `js_iterator_result_validate`. return Some(f64::from_bits(crate::value::TAG_UNDEFINED)); } diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index ebf26c170a..1afe5b2512 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1188,6 +1188,50 @@ pub(crate) fn class_has_own_method(class_id: u32, method_name: &str) -> bool { .unwrap_or(false) } +/// Wall 10 — `name in instance` for a class instance: true when `name` is a +/// prototype METHOD, GETTER, or SETTER anywhere in the instance's class chain. +/// Class instance methods/accessors live in `CLASS_VTABLE_REGISTRY` (the +/// instance carries no recorded `[[Prototype]]` object with a `keys_array`), so +/// the ordinary own-key + recorded-prototype walk in `js_object_has_property` +/// misses them — making `'method' in instance` wrongly `false`. NestJS's app +/// Proxy gates routing on `'listen' in receiver`; the false result misrouted +/// `app.listen`, so the server never bound. Walk the class parent chain here. +pub(crate) fn class_instance_has_member(class_id: u32, name: &str) -> bool { + if class_id == 0 { + return false; + } + let registry = match CLASS_VTABLE_REGISTRY.read() { + Ok(g) => g, + Err(_) => return false, + }; + let Some(reg) = registry.as_ref() else { + return false; + }; + let mut cid = class_id; + let mut depth = 0u32; + while cid != 0 && depth < 32 { + if let Some(vtable) = reg.get(&cid) { + // Honor `delete C.prototype.m`: a deleted key must report `false` + // from `'m' in new C()`, matching the descriptor/static lookup paths. + if !super::class_registry::class_is_key_deleted(cid, name) + && (vtable.methods.contains_key(name) + || vtable.getters.contains_key(name) + || vtable.setters.contains_key(name)) + { + return true; + } + } + match super::class_registry::get_parent_class_id(cid) { + Some(p) if p != 0 && p != cid => { + cid = p; + depth += 1; + } + _ => break, + } + } + false +} + pub fn class_prototype_method_value_for_name(class_id: u32, method_name: &str) -> f64 { if let Some(bits) = CLASS_PROTOTYPE_METHOD_VALUES.with(|cache| { let cache = cache.borrow(); diff --git a/crates/perry-runtime/src/object/object_ops/define_properties.rs b/crates/perry-runtime/src/object/object_ops/define_properties.rs index e943e178fb..1fff03b37e 100644 --- a/crates/perry-runtime/src/object/object_ops/define_properties.rs +++ b/crates/perry-runtime/src/object/object_ops/define_properties.rs @@ -211,6 +211,35 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 } }; + // Wall 10 — `Object.setPrototypeOf(handle, proto)` on a native registry + // handle (a POINTER-tagged small-handle id, e.g. a node:http + // `ServerResponse` / `IncomingMessage`). Express attaches its augmented + // `res.send` / `res.json` / `res.status` (and `req.fresh` / `req.accepts` / + // …) onto the per-request native objects via + // `Object.setPrototypeOf(res, app.response)`. The heap-object recording + // path below rejects the handle (`is_valid_obj_ptr` is false for a small + // id), so without this the prototype was silently dropped and every + // express response method no-op'd (the Wall-10 express/NestJS blocker). + // Record the link in the SAME `OBJECT_PROTOTYPES` side-table keyed by the + // handle id; the small-handle method/property dispatch fallbacks then walk + // it via `resolve_inherited_field`, binding `this` to the handle so the + // express method's internal `this.end(...)` / `this.statusCode = …` route + // back to the native handle. Gated on a non-zero handle id in the small + // band; a plain heap object (top16 0, addr above the band) still takes the + // canonical path below. + { + let top = obj_bits >> 48; + let handle_id = if top == 0x7FFD { + (obj_bits & 0x0000_FFFF_FFFF_FFFF) as usize + } else { + 0 + }; + if crate::value::addr_class::is_small_handle(handle_id) { + super::super::prototype_chain::object_set_static_prototype(handle_id, proto_bits); + return obj_value; + } + } + // #36 / #321: when the target is a closure (a plain function value) and the // proto is an object, record the (closure → proto) link in the closure // static-prototype side-table. effect's `Context.Tag(id)` returns a diff --git a/crates/perry-runtime/src/object/object_ops/has_own.rs b/crates/perry-runtime/src/object/object_ops/has_own.rs index 9b44f4ac9e..8f47363614 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -269,11 +269,14 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { return f64::from_bits(if present { TAG_TRUE } else { TAG_FALSE }); } - // Private elements (`#x`) sit in a class instance's keys_array but are - // never reflectable own properties. Plain literals keep class_id 0. + // Private elements (`#x`) — and perry's hidden `__perry_collection_backing__` + // runtime-internal field — sit in a class instance's keys_array but are + // never reflectable own properties, so `Object.hasOwn` must report false + // for them. Plain literals keep class_id 0. if (*obj).class_id != 0 { if let Some(key) = super::super::has_own_helpers::str_from_string_header(key_str) { - if key.starts_with('#') { + if key.starts_with('#') || super::super::field_get_set::is_internal_runtime_key(key) + { return f64::from_bits(TAG_FALSE); } } @@ -494,6 +497,13 @@ pub extern "C" fn js_object_property_is_enumerable(obj_value: f64, key_value: f6 ); } } + // Perry's hidden `__perry_*` runtime-internal own keys (e.g. the + // `class … extends Map/Set` backing field) physically live in a class + // instance's keys_array but must never be observable, so report them as + // non-enumerable like private (`#`) elements. + if (*obj).class_id != 0 && super::super::field_get_set::is_internal_runtime_key(key_name) { + return f64::from_bits(TAG_FALSE); + } if !own_key_present(obj, key_str) { return f64::from_bits(TAG_FALSE); } diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index c4796b37e8..f50a4f50d9 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -119,9 +119,74 @@ pub(crate) fn is_regex_pointer(ptr: *const u8) -> bool { if ptr.is_null() || (ptr as usize) < 0x1000 { return false; } + // Wall 18: check the header-resident magic FIRST so identity survives a + // duplicate-runtime thread-local split (see `RegExpHeader.magic`). A + // RegExp is a `gc_malloc(GC_TYPE_OBJECT)` allocation, so it always carries + // a preceding GcHeader; only read the magic field when the GC header says + // this is an object of sufficient size to actually contain it. + if regex_header_has_magic(ptr as *const RegExpHeader) { + return true; + } REGEX_POINTERS.with(|s| s.borrow().contains(&(ptr as usize))) } +/// Bounds-checked read of `RegExpHeader.magic`. Confirms the preceding +/// `GcHeader` exists, is a `GC_TYPE_OBJECT`, and the allocation is large enough +/// to hold a full `RegExpHeader` before dereferencing the `magic` field. +/// Returns true iff the field equals [`REGEXP_MAGIC`]. Immune to which linked +/// `perry-runtime` copy's thread-locals are live. +/// +/// SAFETY: this is called from `is_regex_pointer` / `is_registered_regex` with +/// ARBITRARY payloads — including small-handle-band ids (`< 0x100000`), null, +/// NaN-box tag remnants, and small-buffer slab addresses that carry NO +/// `GcHeader`. Dereferencing `addr - GC_HEADER_SIZE` directly SIGSEGVs on those +/// (regression caught by `object_to_string_rejects_handle_band_ids`). Route the +/// header read through [`addr_class::try_read_gc_header`], which magnitude- +/// classifies FIRST (rejecting the handle band + implausible heap addresses + +/// slab addresses) and only then touches memory. +#[inline] +pub(crate) fn regex_header_has_magic(re: *const RegExpHeader) -> bool { + let addr = re as usize; + unsafe { + let Some(gc) = crate::value::addr_class::try_read_gc_header(addr) else { + return false; + }; + if gc.obj_type != crate::gc::GC_TYPE_OBJECT { + return false; + } + // `size` in the GcHeader covers the GcHeader + payload. Require enough + // payload to reach the `magic` field. + if (gc.size as usize) < crate::gc::GC_HEADER_SIZE + std::mem::size_of::() { + return false; + } + (*re).magic == REGEXP_MAGIC + } +} + +/// The GC-VISIBLE slots of a `RegExpHeader`. Only three fields can hold a +/// heap reference the collector must mark/relocate: +/// * `pattern_ptr` — the original-source `StringHeader`, +/// * `flags_ptr` — the flags `StringHeader`, +/// * `last_index` — a writable JSValue (`re.lastIndex = …`) that may be a +/// NaN-boxed heap pointer. +/// `regex_ptr`/`fancy_ptr` point to OFF-heap leaked Rust allocations and the +/// bool/`magic` fields are never heap refs, so they must NOT be scanned. +/// +/// `pattern_ptr` and `flags_ptr` are consecutive equal-width fields, so under +/// `#[repr(C)]` they are adjacent and form a 2-slot contiguous range; the +/// returned tuple is `(range_start, range_slot_count, last_index_slot)`. Offsets +/// are taken from the actual struct via `addr_of_mut!` (no hardcoded layout). +#[inline] +pub(crate) unsafe fn regex_gc_slot_ptrs(re: *mut RegExpHeader) -> (*mut u64, usize, *mut u64) { + let pattern = std::ptr::addr_of_mut!((*re).pattern_ptr) as *mut u64; + let flags = std::ptr::addr_of_mut!((*re).flags_ptr) as *mut u64; + let last_index = std::ptr::addr_of_mut!((*re).last_index) as *mut u64; + // `pattern_ptr` then `flags_ptr` must be adjacent for the 2-slot range to be + // exact; assert so a future field reorder is caught in debug builds. + debug_assert_eq!(flags as usize - pattern as usize, 8); + (pattern, 2, last_index) +} + #[cfg(feature = "regex-engine")] thread_local! { /// Cache of compiled regex objects, keyed by (pattern, flags). @@ -249,8 +314,36 @@ pub struct RegExpHeader { /// raw NaN-boxed bits; `exec`/`test` apply `ToLength` on read to derive the /// match offset. Initialized to the number `0`. pub last_index: u64, + /// Wall 18 (nestjs / get-intrinsic): self-identifying sentinel. + /// + /// `is_valid_regex_ptr` / `is_regex_pointer` / `is_registered_regex` used to + /// rely SOLELY on the `REGEX_POINTERS` thread-local set. That breaks when a + /// statically-linked app pulls a second copy of `perry-runtime` (every + /// `perry-ext-*` archive bundles its own — the link emits duplicate-symbol + /// warnings): `js_regexp_new` inserts into copy-A's thread-local while the + /// `.source`/`.flags`/dynamic-`.replace` reader resolves to copy-B's + /// (empty) thread-local, so a perfectly valid regex reports `.source === + /// "(?:)"`, `is_regex_pointer === false`, and `str.replace(re, fn)` (via a + /// `function-bind` bound `String.prototype.replace`) treats `re` as a plain + /// string pattern → never matches → get-intrinsic's `stringToPath` returns + /// `[]` → `intrinsic %% does not exist!` → express adapter load `exit(1)`. + /// + /// Storing the marker (and the fancy-regex Arc) ON the heap header makes + /// identity + fancy-fallback resolution independent of WHICH runtime copy's + /// thread-locals are live. Set to `REGEXP_MAGIC` by `js_regexp_new`. + pub magic: u64, + /// Leaked `Arc` (as a raw pointer) for patterns the + /// `regex` crate can't compile (lookahead/lookbehind/backrefs), or null. + /// Header-resident twin of the `FANCY_CACHE` thread-local so the fancy + /// fallback survives the duplicate-runtime split described above. + pub fancy_ptr: *const (), } +/// Self-identifying sentinel stamped into every `RegExpHeader.magic` by +/// `js_regexp_new`. ASCII `"PRYREGEX"` little-endian — distinctive enough that +/// a random heap object is astronomically unlikely to collide. +pub const REGEXP_MAGIC: u64 = 0x5845_4745_5259_5250; + /// `ToLength(Get(R, "lastIndex"))` → a non-negative integer match offset. The /// stored value may be any JSValue (e.g. `re.lastIndex = { valueOf() {…} }`), so /// coerce via `ToNumber` (which invokes `valueOf`/`toString`), then `ToInteger`, @@ -291,6 +384,10 @@ pub(crate) fn is_valid_regex_ptr(p: *const RegExpHeader) -> bool { if !is_valid_ptr(p) { return false; } + // Wall 18: header magic first (duplicate-runtime thread-local resilient). + if regex_header_has_magic(p) { + return true; + } REGEX_POINTERS.with(|s| s.borrow().contains(&(p as usize))) } @@ -300,6 +397,10 @@ pub(crate) fn is_valid_regex_ptr(p: *const RegExpHeader) -> bool { /// with no enumerable string keys). Registry-gated so a generic object /// is never mis-read as a RegExpHeader. pub fn is_registered_regex(addr: usize) -> bool { + // Wall 18: header magic first (duplicate-runtime thread-local resilient). + if regex_header_has_magic(addr as *const RegExpHeader) { + return true; + } REGEX_POINTERS.with(|s| s.borrow().contains(&addr)) } @@ -500,6 +601,25 @@ pub extern "C" fn js_regexp_new( (*ptr).unicode = unicode; (*ptr).has_indices = has_indices; (*ptr).last_index = crate::value::JSValue::number(0.0).bits(); + // Wall 18: self-identifying marker so identity checks survive a + // duplicate-runtime thread-local split. + (*ptr).magic = REGEXP_MAGIC; + // Header-resident fancy-regex fallback (lookahead/lookbehind/backrefs) + // so `.replace(re, fn)` etc. don't depend on the (possibly other-copy) + // FANCY_CACHE thread-local. `get_or_compile_regex` above already + // populated FANCY_CACHE on THIS thread when the std `regex` crate + // rejected the pattern; clone that Arc onto the header (leaked so the + // raw pointer stays valid for the header's lifetime — RegExp headers + // and their compiled programs live for the process today). + (*ptr).fancy_ptr = FANCY_CACHE.with(|fc| { + match fc + .borrow() + .get(&(pattern_str.to_string(), flags_str.to_string())) + { + Some(arc) => Arc::into_raw(arc.clone()) as *const (), + None => std::ptr::null(), + } + }); // Record the pointer so that js_string_split can detect // `s.split(regex)` without a dedicated runtime decl. @@ -692,6 +812,18 @@ pub extern "C" fn js_regexp_test(re: *const RegExpHeader, s: *const StringHeader #[cfg(feature = "regex-engine")] pub(crate) fn lookup_fancy_regex(re: *const RegExpHeader) -> Option> { unsafe { + // Wall 18: header-resident fancy Arc first (duplicate-runtime + // thread-local resilient). `fancy_ptr` is a leaked `Arc` raw pointer; to + // hand back an owned `Arc` clone WITHOUT consuming the header's + // reference, reconstruct, clone, then `mem::forget` the reconstructed + // one so the header's strong count is preserved. + if regex_header_has_magic(re) && !(*re).fancy_ptr.is_null() { + let raw = (*re).fancy_ptr as *const fancy_regex::Regex; + let arc = Arc::from_raw(raw); + let cloned = arc.clone(); + std::mem::forget(arc); + return Some(cloned); + } let pat = string_as_str((*re).pattern_ptr); let flags_str = string_as_str((*re).flags_ptr); FANCY_CACHE.with(|fc| { diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index 4c2d384720..b32dd9468d 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -919,6 +919,33 @@ pub extern "C" fn js_set_from_iterable(value: f64) -> *mut SetHeader { /// omitted at the call site. #[no_mangle] pub extern "C" fn js_set_foreach(set: *const SetHeader, callback: f64, this_arg: f64) { + js_set_foreach_impl( + set, + callback, + this_arg, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); +} + +/// `Set.prototype.forEach` for a `class … extends Set` subclass instance: the +/// 3rd callback argument and the `self === collection` identity must be the +/// SUBCLASS instance (`collection`), not the hidden backing set. Iteration runs +/// over `set` (the backing). `collection` is a NaN-boxed value. +pub(crate) fn js_set_foreach_with_collection( + set: *const SetHeader, + callback: f64, + this_arg: f64, + collection: f64, +) { + js_set_foreach_impl(set, callback, this_arg, collection); +} + +fn js_set_foreach_impl( + set: *const SetHeader, + callback: f64, + this_arg: f64, + collection_override: f64, +) { // ECMA-262 Set.prototype.forEach step 4: a non-callable callback throws a // TypeError before iterating (and before any null-set early return). crate::array::js_validate_array_callback(callback); @@ -930,20 +957,29 @@ pub extern "C" fn js_set_foreach(set: *const SetHeader, callback: f64, this_arg: let set_handle = scope.root_raw_const_ptr(set); let callback_handle = scope.root_nanbox_f64(callback); let this_handle = scope.root_nanbox_f64(this_arg); + let has_override = collection_override.to_bits() != crate::value::TAG_UNDEFINED; + let collection_handle = scope.root_nanbox_f64(collection_override); unsafe { - let set = set_handle.get_raw_const_ptr::(); - let size = (*set).size as usize; - if size == 0 { - return; - } - // The Set itself is the third callback argument / `self === s`. - let set_value = crate::value::js_nanbox_pointer(set as i64); - - for i in 0..size { + // ECMA-262 24.2.3.6: Set.prototype.forEach iterates [[SetData]] in + // insertion order, re-reading the live entry count each step. Entries + // appended during the callback (`set.add` inside the callback) MUST be + // visited, so the loop bound is re-evaluated against `(*set).size` every + // iteration rather than snapshotting the initial size — mirrors + // `js_map_foreach_impl`. + let mut i = 0usize; + loop { let set = set_handle.get_raw_const_ptr::(); if i >= (*set).size as usize { break; } + // The Set itself is the third callback argument / `self === s`. + // Re-derive each step from a rooted handle so a GC during a prior + // callback never bakes in a stale (relocated) pointer. + let set_value = if has_override { + collection_handle.get_nanbox_f64() + } else { + crate::value::js_nanbox_pointer(set as i64) + }; let elements = elements_ptr(set); let value = ptr::read(elements.add(i)); let args = [value, value, set_value]; @@ -952,6 +988,7 @@ pub extern "C" fn js_set_foreach(set: *const SetHeader, callback: f64, this_arg: let prev_this = crate::object::js_implicit_this_set(this_v); let _ = crate::closure::js_native_call_value(cb, args.as_ptr(), args.len()); crate::object::js_implicit_this_set(prev_this); + i += 1; } } } diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index c9a5ec6640..f08e2d60ef 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -465,6 +465,34 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if let Some(v) = resolve_explicit_object_prototype_symbol(obj_f64, sym_f64) { return v; } + // `class X extends Map | Set` instance — its default `[Symbol.iterator]` + // is inherited from Map/Set.prototype, so it is NOT an own symbol prop. + // Reading the property (e.g. `typeof obj[Symbol.iterator] === 'function'`, + // as `iterare`'s `isIterable` / `toIterator` do for NestJS's + // `ModulesContainer extends Map`) must still resolve to a callable. + // Return a bound method that, when invoked, produces the backing + // collection's default iterator (entries for Map, values for Set — see + // the `"Symbol.iterator"` arms in `collection_methods.rs`). + // + // This runs AFTER the class/prototype symbol walk and the explicit-prototype + // lookup above, so a user override — `class M extends Map { + // *[Symbol.iterator]() {} }` (registered as `@@iterator` and resolved at the + // class/proto walk) or `Object.setPrototypeOf(m, { [Symbol.iterator]: … })` + // — wins, and we only synthesize the built-in default when the normal lookup + // would have fallen through. + if sym_key != 0 { + let iter_wk = well_known_symbol("iterator"); + if !iter_wk.is_null() { + let iter_f64 = + f64::from_bits(crate::value::JSValue::pointer(iter_wk as *const u8).bits()); + if sym_key == sym_key_from_f64(iter_f64) + && crate::object::map_set_subclass::subclass_backing_of(obj_f64).is_some() + { + let mname = b"Symbol.iterator"; + return crate::object::js_class_method_bind(obj_f64, mname.as_ptr(), mname.len()); + } + } + } if sym_key != 0 { let iter_wk = well_known_symbol("iterator"); if !iter_wk.is_null() { diff --git a/crates/perry-runtime/src/symbol/iterator.rs b/crates/perry-runtime/src/symbol/iterator.rs index 961e49ebc6..36193a9502 100644 --- a/crates/perry-runtime/src/symbol/iterator.rs +++ b/crates/perry-runtime/src/symbol/iterator.rs @@ -237,6 +237,25 @@ pub extern "C" fn js_get_iterator(val_f64: f64) -> f64 { } } } + // `class X extends Map | Set` instance — its default `[Symbol.iterator]` + // yields the hidden backing collection's entries (Map) / values (Set), + // returned as a real iterator object so the lazy `for…of` protocol can + // drive `.next()`. Matches the builtins' default iterator. Skipped when the + // subclass overrides `[Symbol.iterator]`, so we fall through to the generic + // symbol lookup below (which resolves the user's `@@iterator` method). + match crate::object::map_set_subclass::subclass_backing_for_default_iteration(val_f64) { + Some(crate::object::map_set_subclass::CollectionBacking::Map(m)) => { + return crate::value::js_nanbox_pointer( + crate::collection_iter_object::js_map_entries_iter_obj(m), + ); + } + Some(crate::object::map_set_subclass::CollectionBacking::Set(s)) => { + return crate::value::js_nanbox_pointer( + crate::collection_iter_object::js_set_values_iter_obj(s), + ); + } + None => {} + } // A primitive number / boolean / null / undefined is not iterable. Per // GetIterator this is a TypeError; bail before the `[Symbol.iterator]` // lookup, which would otherwise dereference a raw (non-NaN-boxed) double as diff --git a/crates/perry-runtime/src/weakref.rs b/crates/perry-runtime/src/weakref.rs index 1c46910a0b..8b26be461f 100644 --- a/crates/perry-runtime/src/weakref.rs +++ b/crates/perry-runtime/src/weakref.rs @@ -134,6 +134,21 @@ fn is_valid_weak_target(value: f64) -> bool { return true; } + // A class reference (e.g. an imported `class Foo {}` passed as a value, or a + // class prototype) is NaN-boxed as an INT32-tagged (`0x7FFE`) class-ref ID, + // not a heap pointer. In JS such a value is a function/object and therefore + // "CanBeHeldWeakly" (ES2023) — a valid WeakMap/WeakSet/WeakRef key. NestJS + // relies on this: `InitializeOnPreviewAllowlist.add(InternalCoreModule)` does + // `weakmap.set(InternalCoreModule, true)`, and module-token factories key a + // WeakMap by the module class. Cross-module class imports arrive as class-ref + // values (not closure pointers), so without this branch they were rejected + // with "Invalid value used as weak map key" only inside a full module graph. + if crate::object::class_ref_id(value).is_some() + || crate::object::class_prototype_ref_id(value).is_some() + { + return true; + } + let jv = JSValue::from_bits(value.to_bits()); if !jv.is_pointer() { return false;