From ab268530584cac4b995fd09c3a7960167e438450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 26 Jun 2026 23:47:26 +0200 Subject: [PATCH 1/2] fix(hir,runtime): native chained-static-class decorators + reflect-metadata require binding (NestJS bootstrap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent bugs that block a NestJS app from booting natively (both hit at module-evaluation time, before any user code runs). 1. Wall 2 — chained static-class self-alias (`reading 'value'` crash). tsc emits decorator-captured classes as `let Logger = Logger_1 = class Logger {…}`. The self-alias reference inside a member body lowered to a capture, forcing the class onto the per-evaluation `ClassExprFresh` path, whose fresh class object never resolved STATIC methods/accessors/descriptors from the class registry. So `Object.getOwnPropertyDescriptor(Logger, 'error')` returned undefined and NestJS's decorator (`Reflect.defineMetadata(..., descriptor.value)`) threw "Cannot read properties of undefined (reading 'value')". Fix: HIR resolves a class self-alias to `ClassRef` (off the fresh path); runtime resolves static methods/accessors/own-descriptors via the registry for `ClassExprFresh` heap class objects (guarded for the tagged class-object form). 2. Wall 3 — `require("reflect-metadata")` (`ReferenceError: _req_1 is not defined`). `@nestjs/common/index.js` does `require("reflect-metadata")`; the CJS wrap hoists it to `import _req_1 ...` + a `return _req_1` shim, but module_decl special-cased reflect-metadata with an early return that never bound the default-import local. Fix: bind reflect-metadata import specifiers to `{}` (the require result is always discarded; perry already provides the global `Reflect` polyfill). Bare `import "reflect-metadata"` is unaffected. Verified on the upstream NestJS typescript-starter: both module-eval crashes are gone (app advances to the express HTTP-adapter load). cargo test -p perry-hir + perry-runtime green. Claude-Session: https://claude.ai/code/session_017S2d3tRok3oMbi6AwfJyUU --- .../src/lower/lower_expr/arm_ident.rs | 24 +++++++ crates/perry-hir/src/lower/module_decl.rs | 35 ++++++++++ crates/perry-hir/src/lower/stmt.rs | 58 ++++++++++++++++ crates/perry-hir/src/lower_decl/body_stmt.rs | 4 ++ .../perry-runtime/src/object/descriptors.rs | 40 +++++++++++ .../object/field_get_set/get_field_by_name.rs | 67 +++++++++++++++++++ 6 files changed, 228 insertions(+) diff --git a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs index 430e8bd7e5..6aad4e81b8 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs @@ -38,6 +38,30 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) -> { return Ok(Expr::ClassRef(ctx.resolve_class_name(&name))); } + // Chained-assignment class self-alias referenced from inside one of the + // class's own method/getter/setter bodies. tsc's decorator-capture form + // `let Logger = Logger_1 = class Logger { get x(){ …Logger_1… } static + // error(){ …Logger_1… } }` declares `Logger_1` as a real outer-scope local + // and binds the class to it. Inside a STATIC method the outer local is not + // in scope (the method compiles to a standalone function), and inside an + // instance method resolving to a `LocalGet` capture forces the whole class + // onto the per-evaluation `ClassExprFresh` path (which drops static methods + // and SIGSEGVs `new`). The alias value IS the class everywhere after + // evaluation, so resolve it to the constructor `ClassRef` directly — exactly + // as JS spec resolves the inner class binding. `record_chained_class_self_ + // aliases` populates `class_expr_aliases[alias] = ` + // before the body is lowered; gate on currently lowering that same class so + // an unrelated outer reference to the name is untouched. (`class_expr_ + // aliases` is NOT a `register_class`, so this does not trip + // `lower_class_expr`'s collision-rename — `current_class` stays the real + // class name.) + if let Some(cur) = ctx.current_class.clone() { + if let Some(target) = ctx.class_expr_aliases.get(&name) { + if *target == cur { + return Ok(Expr::ClassRef(ctx.resolve_class_name(target))); + } + } + } if let Some(id) = ctx.lookup_local(&name) { // A with-fallback implicit global may still be the HOLE // sentinel (the with-env took the write) — reading it then diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 709f6f0c45..1c67a6a1c5 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -50,6 +50,41 @@ pub(crate) fn lower_module_decl( if source == "reflect-metadata" { emit_reflect_metadata_shim_note(); + // `import "reflect-metadata"` (no specifiers) is a pure + // side-effect import — Perry provides the `Reflect.*metadata` + // surface natively, so there is nothing to bind. But a CJS + // module that does `require("reflect-metadata")` is wrapped to + // `import _req_N from 'reflect-metadata'` with a default-import + // LOCAL, and the synthesized require shim's + // `if (specifier === 'reflect-metadata') return _req_N;` + // references that local. Without a binding the read throws + // `ReferenceError: _req_N is not defined` at module + // evaluation (NestJS's `@nestjs/common/index.js` — + // `require("reflect-metadata")` — and every other + // reflect-metadata-importing CJS barrel hit this). Bind each + // default/namespace/named local to an empty object: the module + // value is only ever the discarded result of + // `require("reflect-metadata")` (the real effect is the global + // `Reflect` polyfill, which Perry already supplies), so an + // empty object is a faithful, inert stand-in. + for spec in &import_decl.specifiers { + let local = match spec { + ast::ImportSpecifier::Default(d) => d.local.sym.to_string(), + ast::ImportSpecifier::Namespace(n) => n.local.sym.to_string(), + ast::ImportSpecifier::Named(n) => n.local.sym.to_string(), + }; + if ctx.lookup_local(&local).is_some() { + continue; + } + let id = ctx.define_local(local.clone(), Type::Any); + module.init.push(Stmt::Let { + id, + name: local, + ty: Type::Any, + mutable: true, + init: Some(Expr::Object(Vec::new())), + }); + } return Ok(()); } diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index ef44689969..a535524bc0 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -70,6 +70,58 @@ fn emit_class_expression_value_binding( }); } +/// For a `let X = T1 = … = class [Y] {…}` declaration, record the chained +/// assignment targets (`T1`, …) as class self-aliases of the class's lowering +/// name in `ctx.class_expr_aliases`, BEFORE the init is lowered. This does NOT +/// `register_class` the names (which would trip `lower_class_expr`'s +/// collision-rename), it only feeds `synthesize_class_captures`'s self-alias +/// exclusion so a method-body reference to `T1` (e.g. tsc's `Logger_1`) is not +/// counted as an outer-scope capture — keeping the class on the shared-template +/// `ClassRef` path instead of the static-method-dropping `ClassExprFresh` path. +/// The class's own bind/inner name is already covered by `name` in that pass. +pub(crate) fn record_chained_class_self_aliases(ctx: &mut LoweringContext, decl: &ast::VarDeclarator) { + let (ast::Pat::Ident(ident), Some(init)) = (&decl.name, &decl.init) else { + return; + }; + let bind_name = ident.id.sym.to_string(); + let mut chained_targets: Vec = Vec::new(); + let mut e = init.as_ref(); + let inner_class = loop { + match e { + ast::Expr::Paren(p) => e = &p.expr, + ast::Expr::TsAs(a) => e = &a.expr, + ast::Expr::TsNonNull(n) => e = &n.expr, + ast::Expr::TsTypeAssertion(a) => e = &a.expr, + ast::Expr::Assign(assign) if assign.op == ast::AssignOp::Assign => { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(target)) = + &assign.left + { + chained_targets.push(target.id.sym.to_string()); + e = &assign.right; + } else { + return; + } + } + ast::Expr::Class(c) => break c, + _ => return, + } + }; + if chained_targets.is_empty() { + return; + } + // The class lowers under its inner expression name if present, else the + // binding name (matching `lower_class_expr`'s `ident_name`/synthetic-name + // choice for the common non-colliding case). + let class_name = inner_class + .ident + .as_ref() + .map(|i| i.sym.to_string()) + .unwrap_or_else(|| bind_name.clone()); + for t in chained_targets { + ctx.class_expr_aliases.entry(t).or_insert(class_name.clone()); + } +} + /// Recursively walk a destructuring pattern collecting every leaf identifier /// (and pre-defining each as a local). Used by the for-of binding pre-pass so /// the loop body can reference variables introduced by *nested* patterns like @@ -818,6 +870,12 @@ pub(crate) fn lower_stmt( } } } + // Record chained-assignment class self-aliases (`let + // Logger = Logger_1 = class …`) so the self-reference + // isn't captured (see `synthesize_class_captures`). This + // top-level path is reached for any `let X = T = class` + // not handled by the direct `class` fast path above. + record_chained_class_self_aliases(ctx, decl); let stmts = lower_var_decl_with_destructuring(ctx, decl, mutable, is_var)?; // `var` is function-scoped: mark defined locals so // `pop_block_scope` preserves them when leaving an inner block. diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 465911e71e..f8d530176e 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -170,6 +170,10 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result> 48) == 0` guard + // ensures `is_class_object_ptr` only ever sees a real heap pointer (it + // back-reads a GcHeader), never a tagged value — which previously SIGSEGV'd. + if !key.is_null() + && ((obj as u64) >> 48) == 0 + && (obj as usize) >= 0x10000 + && crate::object::class_registry::is_class_object_ptr(obj as *const u8) + { + let own = get_field_by_name_object_tail(obj, key); + if !own.is_undefined() { + return own; + } + unsafe { + // Re-box the raw class-object pointer as a POINTER-tagged JS value + // so `js_class_method_bind` (which expects a value, like the + // class-ref path) binds the static method to the right receiver. + let class_value = + f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()); + let class_id = super::super::js_object_get_class_id(obj); + if class_id != 0 { + let name_ptr = + (key as *const u8).add(std::mem::size_of::()); + let name_len = (*key).byte_len as usize; + let name = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) + .unwrap_or(""); + if !name.is_empty() + && !super::super::class_registry::class_is_key_deleted(class_id, name) + { + if super::super::class_registry::lookup_static_method_in_chain( + class_id, name, + ) + .is_some() + { + let heap_name = { + let layout = + std::alloc::Layout::from_size_align(name_len.max(1), 1).unwrap(); + let ptr = std::alloc::alloc(layout); + std::ptr::copy_nonoverlapping(name_ptr, ptr, name_len); + ptr + }; + let result = js_class_method_bind(class_value, heap_name, name_len); + return JSValue::from_bits(result.to_bits()); + } + if let Some(v) = + super::super::class_registry::class_static_accessor_getter_value( + class_id, + name, + class_value, + ) + { + return JSValue::from_bits(v.to_bits()); + } + } + } + } + return own; + } if let Some(addr) = crate::typedarray_props::typed_array_addr_from_value(f64::from_bits(obj as u64)) { From 2911a158f0b3f0bc297ed240cdba7d910a5e2a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 27 Jun 2026 07:54:52 +0200 Subject: [PATCH 2/2] fix: address CodeRabbit + cargo fmt (nestjs walls 2-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_field_by_name: exclude the whole small-handle band (is_above_handle_band, >=0x100000) from the class-object pointer probe — a native handle in [0x10000,0x100000) could reach is_class_object_ptr and SIGSEGV (CodeRabbit Critical). - arm_ident: compare the RESOLVED class-expr alias target against current_class so a collision-renamed class expression still matches (CodeRabbit). - descriptors: skip Symbol keys in the fresh-class string-key static-method branch so they reach the symbol descriptor path (CodeRabbit). - cargo fmt. Deferred (follow-up): chained-assignment self-alias narrowing, static-accessor descriptors for fresh class objects, own-only field check before the class registry fallback — subtler changes left for a focused pass. --- .../src/lower/lower_expr/arm_ident.rs | 9 +++++++-- crates/perry-hir/src/lower/stmt.rs | 9 +++++++-- crates/perry-runtime/src/object/descriptors.rs | 15 +++++++++------ .../object/field_get_set/get_field_by_name.rs | 18 +++++++++--------- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs index 6aad4e81b8..746b85db5d 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs @@ -57,8 +57,13 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) -> // class name.) if let Some(cur) = ctx.current_class.clone() { if let Some(target) = ctx.class_expr_aliases.get(&name) { - if *target == cur { - return Ok(Expr::ClassRef(ctx.resolve_class_name(target))); + // Compare the RESOLVED target name: a collision-renamed class + // expression makes `current_class` the resolved name while the + // recorded `target` is still the source name, so a raw `*target == + // cur` would miss. Resolving is identity when no rename happened. + let resolved = ctx.resolve_class_name(target); + if resolved == cur { + return Ok(Expr::ClassRef(resolved)); } } } diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index a535524bc0..281f8bb9d4 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -79,7 +79,10 @@ fn emit_class_expression_value_binding( /// counted as an outer-scope capture — keeping the class on the shared-template /// `ClassRef` path instead of the static-method-dropping `ClassExprFresh` path. /// The class's own bind/inner name is already covered by `name` in that pass. -pub(crate) fn record_chained_class_self_aliases(ctx: &mut LoweringContext, decl: &ast::VarDeclarator) { +pub(crate) fn record_chained_class_self_aliases( + ctx: &mut LoweringContext, + decl: &ast::VarDeclarator, +) { let (ast::Pat::Ident(ident), Some(init)) = (&decl.name, &decl.init) else { return; }; @@ -118,7 +121,9 @@ pub(crate) fn record_chained_class_self_aliases(ctx: &mut LoweringContext, decl: .map(|i| i.sym.to_string()) .unwrap_or_else(|| bind_name.clone()); for t in chained_targets { - ctx.class_expr_aliases.entry(t).or_insert(class_name.clone()); + ctx.class_expr_aliases + .entry(t) + .or_insert(class_name.clone()); } } diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index feee5729d9..368e32303a 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -137,7 +137,13 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu // NaN-box tag before any deref). Own per-evaluation static FIELDS fall // through to the ordinary own-property path (checked here via // `own_key_present` so a field shadows a same-named template method). - if super::class_registry::is_class_object_value(obj_value) { + // Skip Symbol keys here: `metadata_key_to_string` / `js_string_coerce` + // would stringify a Symbol and wrongly return a string-named static + // method descriptor instead of letting it reach the symbol descriptor + // path below. + if super::class_registry::is_class_object_value(obj_value) + && crate::symbol::js_is_symbol(key_value) == 0 + { if let Some(method_name) = metadata_key_to_string(key_value) { let obj = extract_obj_ptr(obj_value); let key_str = crate::builtins::js_string_coerce(key_value); @@ -151,11 +157,8 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu ) { let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak(); - let value = super::js_class_method_bind( - obj_value, - leaked.as_ptr(), - leaked.len(), - ); + let value = + super::js_class_method_bind(obj_value, leaked.as_ptr(), leaked.len()); return build_data_descriptor(value, true, false, true); } } 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 fa443a85c1..2e2236406d 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 @@ -45,7 +45,11 @@ pub extern "C" fn js_object_get_field_by_name( // back-reads a GcHeader), never a tagged value — which previously SIGSEGV'd. if !key.is_null() && ((obj as u64) >> 48) == 0 - && (obj as usize) >= 0x10000 + // Must be ABOVE the whole small-handle band (>= 0x100000), not just + // >= 0x10000: native handle ids in [0x10000, 0x100000) (fetch/http/…) + // would otherwise reach `is_class_object_ptr`, which back-reads a + // GcHeader and SIGSEGVs on the non-heap handle id. + && crate::value::addr_class::is_above_handle_band(obj as usize) && crate::object::class_registry::is_class_object_ptr(obj as *const u8) { let own = get_field_by_name_object_tail(obj, key); @@ -56,22 +60,18 @@ pub extern "C" fn js_object_get_field_by_name( // Re-box the raw class-object pointer as a POINTER-tagged JS value // so `js_class_method_bind` (which expects a value, like the // class-ref path) binds the static method to the right receiver. - let class_value = - f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()); + let class_value = f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()); let class_id = super::super::js_object_get_class_id(obj); if class_id != 0 { - let name_ptr = - (key as *const u8).add(std::mem::size_of::()); + let name_ptr = (key as *const u8).add(std::mem::size_of::()); let name_len = (*key).byte_len as usize; let name = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) .unwrap_or(""); if !name.is_empty() && !super::super::class_registry::class_is_key_deleted(class_id, name) { - if super::super::class_registry::lookup_static_method_in_chain( - class_id, name, - ) - .is_some() + if super::super::class_registry::lookup_static_method_in_chain(class_id, name) + .is_some() { let heap_name = { let layout =