diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 77dee06a3d..a391d5029f 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -395,6 +395,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // class_id from this object rather than treating it as an instance. ctx.block() .call_void("js_object_mark_class", &[(I64, &obj)]); + // #6438: pin THIS evaluation's parent onto the object. The lowering + // sequences `RegisterClassParentDynamic` immediately ahead of this + // node, so `CLASS_DYNAMIC_PARENT_VALUE[template]` still holds this + // evaluation's parent; later evaluations overwrite it, but each + // object keeps its own edge. Without this, a factory invoked more + // than once (effect's `class DeclareClass extends make(ast) { … }`) + // has every instance walk to the LAST parent — reading that + // evaluation's `static ast` instead of its own. No-op when the class + // expression has no heritage. + ctx.block().call_void( + "js_class_object_pin_parent", + &[(I64, &obj), (I32, &tcid_str)], + ); for (name, init) in named_statics { let key_idx = ctx.strings.intern(name); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 32427b99e7..1f74a16d24 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -47,6 +47,8 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // (object_type = OBJECT_TYPE_CLASS) so typeof → "function" and // new/instanceof read class_id from it. module.declare_function("js_object_mark_class", VOID, &[I64]); + // #6438: pin a per-evaluation class object's own parent edge. + module.declare_function("js_class_object_pin_parent", VOID, &[I64, I32]); // Shape-cache-aware variant: pre-populates keys_array via SHAPE_INLINE_CACHE, // so subsequent field stores can use index-based set_field (skipping the // per-call linear key-search done by js_object_set_field_by_name). diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index ae00bf73ed..623a231a4a 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -187,11 +187,37 @@ pub(crate) fn lower_class_expr( // `make()`), which produce a distinct class object per call. let at_module_top = ctx.scope_depth == 0 && ctx.inside_block_scope == 0; if !at_module_top - && parent_expr.is_none() && (!named_statics.is_empty() || !static_symbol_registrations.is_empty() || !captured_args.is_empty()) { + // #6438: a class expression WITH heritage (`class extends `) used + // to be excluded here and fell back to the shared-template `ClassRef` + // path — the very thing #1772's comment above warns about: it "shares + // one template class and `.ast` is undefined/clobbered". effect's + // Schema.ts hits exactly that shape: + // + // function makeDeclareClass(typeParameters, ast) { + // return class DeclareClass extends make(ast) { + // static typeParameters = [...typeParameters] + // } + // } + // + // `makeDeclareClass` runs 5+ times during Schema.ts init, so all five + // DeclareClasses shared ONE `@perry_static_…__DeclareClass__typeParameters` + // module global whose initializer is hoisted to module init — where the + // enclosing `typeParameters` parameter is not in scope. Every instance + // then read `undefined`, and `this.typeParameters` inside the inherited + // static `annotations` fed `[...undefined]` → "TypeError: undefined is + // not iterable", taking down the whole @effect/platform HttpApi server. + // + // Heritage is orthogonal to per-evaluation static storage: sequence the + // dynamic-parent registration (which wires the runtime parent edge for + // method dispatch, exactly as the shared-template path below does) + // AHEAD of the fresh class object, so the parent edge is registered + // before the object is materialized and the Sequence still yields the + // class value as its last element. + // // #1787: snapshot the class's captured outer-scope values so a // later `new ()` can run the instance-field // initializers / constructor body with the right environment. @@ -201,15 +227,22 @@ pub(crate) fn lower_class_expr( // that same order as `LocalGet(outer_id)`, evaluated here where // the captures are still live. let fresh_expr = Expr::ClassExprFresh { - template: synthetic_name, + template: synthetic_name.clone(), named_statics, symbol_statics: static_symbol_registrations, captured_args, }; - if computed_member_registrations.is_empty() { + let mut seq: Vec = Vec::new(); + if let Some(p) = parent_expr { + seq.push(Expr::RegisterClassParentDynamic { + class_name: synthetic_name, + parent_expr: p, + }); + } + seq.extend(computed_member_registrations); + if seq.is_empty() { return Ok(fresh_expr); } - let mut seq = computed_member_registrations; seq.push(fresh_expr); return Ok(Expr::Sequence(seq)); } diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 0570f5d9a0..8ea4ced67e 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -157,10 +157,11 @@ pub(crate) use dispatch::{ pub(crate) use parent_static::{ call_registered_static_method, call_static_method, class_chain_has_instance_accessor, class_has_instance_getter, class_has_own_static_method, class_has_symbol_member_in_chain, - class_instance_setter_apply, class_method_bind_length, class_own_symbol_member_keys, - class_static_accessor_getter_value, class_static_accessor_setter_apply, - class_symbol_getter_value, class_symbol_setter_apply, get_parent_class_id, - lookup_class_symbol_method_in_chain, lookup_static_method_in_chain, register_class, + class_instance_setter_apply, class_method_bind_length, class_object_own_field_bytes, + class_object_pinned_parent, class_own_symbol_member_keys, class_static_accessor_getter_value, + class_static_accessor_setter_apply, class_symbol_getter_value, class_symbol_setter_apply, + get_parent_class_id, lookup_class_symbol_method_in_chain, lookup_static_method_in_chain, + register_class, }; pub use parent_static::{ is_class_object_ptr, is_class_object_value, is_registered_class_prototype_object, diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 17ac64654c..91a08efa78 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -199,6 +199,119 @@ pub extern "C" fn js_register_class_parent_dynamic(class_id: u32, parent_value: } } +/// Own-property key under which a per-evaluation class object +/// (`ClassExprFresh`) pins ITS OWN parent class value. See +/// `js_class_object_pin_parent`. +pub(crate) const CLASS_OBJECT_PARENT_KEY: &str = "__perry_parent_class"; + +/// #6438: pin THIS evaluation's parent onto a per-evaluation class object. +/// +/// `CLASS_DYNAMIC_PARENT_VALUE` is keyed by the child's **class id**, i.e. by +/// the compile-time template — so a class expression evaluated N times with a +/// DIFFERENT parent each time (effect's +/// `class DeclareClass extends make(ast) { … }`, where `make(ast)` returns a +/// fresh class per call) collapses to last-wins: every DeclareClass would walk +/// to the LAST `make(ast)` and read that evaluation's `static ast`. +/// +/// Codegen calls this immediately after `RegisterClassParentDynamic` in the +/// same lowered Sequence, so the table still holds *this* evaluation's parent. +/// Copy it onto the class object as an own property; later evaluations +/// overwrite the table but each object already carries its own edge. Same +/// write-right-before-use shape the capture snapshot already uses. +/// +/// A no-parent class expression pins nothing (the getter yields undefined or a +/// static ClassRef fallback, which the field walk treats as "no own edge"). +#[no_mangle] +pub extern "C" fn js_class_object_pin_parent(obj: i64, template_class_id: u32) { + const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + if obj == 0 || template_class_id == 0 { + return; + } + let parent = js_get_dynamic_parent_value(template_class_id); + if parent.to_bits() == TAG_UNDEFINED { + return; + } + let key_bytes = CLASS_OBJECT_PARENT_KEY.as_bytes(); + let key = crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32); + crate::object::js_object_set_field_by_name( + obj as *mut crate::object::ObjectHeader, + key, + parent, + ); +} + +/// Read back the parent pinned by `js_class_object_pin_parent`, or `None` when +/// this class object has no own parent edge. +/// +/// Scans the keys array DIRECTLY rather than going through the by-name read +/// path: that path consults the pinned parent itself (that is the whole point +/// of the edge), so reading the marker through it re-enters this function and +/// recurses until the stack guard page — an immediate SIGSEGV. A re-entrancy +/// flag is not an option either: it would abort the legitimate +/// parent→grandparent walk of a multi-level factory chain. An own-only scan has +/// neither problem, and it is cheap: the pinned key sits among a handful of own +/// statics on a class object. +pub(crate) fn class_object_pinned_parent(obj: *const crate::object::ObjectHeader) -> Option { + class_object_own_field_bytes(obj, CLASS_OBJECT_PARENT_KEY.as_bytes()) +} + +/// OWN-ONLY field read on a class object: scans the keys array directly and +/// consults no prototype chain, no registry, and no pinned parent. +/// +/// Needed because `get_field_by_name_object_tail` folds the own lookup together +/// with a class_id-keyed prototype-chain walk. For a per-evaluation class object +/// that chain resolves through the TEMPLATE's parent edge — which is last-wins — +/// so it answers with a sibling evaluation's inherited value instead of this +/// object's. Callers that must order "own, then MY pinned parent, then the +/// generic tail" need the own half in isolation. +pub(crate) fn class_object_own_field_bytes( + obj: *const crate::object::ObjectHeader, + want: &[u8], +) -> Option { + const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + // `is_valid_obj_ptr` alone does not reject the fetch/zlib/proxy handle + // bands, and dereferencing a handle id as an ObjectHeader segfaults on Linux + // (macOS hides it). Gate on `is_above_handle_band` first — a real class + // object is always a heap allocation above the band. + if obj.is_null() + || !crate::value::addr_class::is_above_handle_band(obj as usize) + || !crate::object::is_valid_obj_ptr(obj as *const u8) + { + return None; + } + unsafe { + let keys = (*obj).keys_array; + if keys.is_null() { + return None; + } + let len = (*keys).length; + for i in 0..len { + let k = crate::array::js_array_get_f64(keys, i); + let sp = crate::value::js_get_string_pointer_unified(k) as *const crate::StringHeader; + if sp.is_null() { + continue; + } + let blen = (*sp).byte_len as usize; + if blen != want.len() { + continue; + } + let bytes = std::slice::from_raw_parts( + (sp as *const u8).add(std::mem::size_of::()), + blen, + ); + if bytes != want { + continue; + } + let v = crate::object::js_object_get_field(obj, i); + if v.bits() == TAG_UNDEFINED { + return None; + } + return Some(f64::from_bits(v.bits())); + } + } + None +} + /// Read back the parent constructor value stashed at class-definition time by /// `js_register_class_parent_dynamic` (see `CLASS_DYNAMIC_PARENT_VALUE`). /// `super()` in a `class X extends ` body uses this so the 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 49065fe33c..e15cc3441d 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 @@ -215,6 +215,52 @@ pub extern "C" fn js_object_get_field_by_name( && crate::value::addr_class::is_above_handle_band(obj as usize) && crate::object::class_registry::is_class_object_ptr(obj as *const u8) { + // #6438: precedence for a per-evaluation class object is + // own -> THIS object's pinned parent -> generic tail. + // + // The generic tail folds the own lookup together with a class_id-keyed + // prototype-chain walk (`resolve_proto_chain_field_with_receiver`). That + // chain goes through the TEMPLATE's parent edge, which is last-wins + // across evaluations, so for a factory called twice it answers with the + // SIBLING's inherited value and never reports undefined — which would + // silently pre-empt the pinned walk below. effect: + // + // make(ast) -> class SchemaClass { static ast = ast } + // makeTypeLiteralClass(..) -> class TypeLiteralClass extends make(ast) {…} + // + // `Struct(a)` then `Struct(b)` left `structA.ast === astB`, because + // `structA.ast` resolved through TypeLiteralClass's TEMPLATE parent edge + // (last registered = b's SchemaClass) instead of structA's own parent. + // Check the object's OWN fields first, then ITS pinned parent, and only + // then fall through to the tail. + unsafe { + if !key.is_null() { + let name_ptr = (key as *const u8).add(std::mem::size_of::()); + let name_len = (*key).byte_len as usize; + let want = std::slice::from_raw_parts(name_ptr, name_len); + if let Some(v) = + crate::object::class_registry::class_object_own_field_bytes(obj, want) + { + return JSValue::from_bits(v.to_bits()); + } + if let Some(parent) = crate::object::class_registry::class_object_pinned_parent(obj) + { + let pbits = parent.to_bits(); + if (pbits >> 48) == 0x7FFD { + let praw = (pbits & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; + if praw as usize != obj as usize + && crate::value::addr_class::is_above_handle_band(praw as usize) + && crate::object::is_valid_obj_ptr(praw as *const u8) + { + let v = js_object_get_field_by_name(praw, key); + if !v.is_undefined() { + return v; + } + } + } + } + } + } let own = get_field_by_name_object_tail(obj, key); if !own.is_undefined() { return own; diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index eb24f29741..89b7d3092f 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1074,8 +1074,18 @@ pub unsafe extern "C" fn js_native_call_method( { let dyn_val = crate::closure::closure_get_dynamic_prop(raw_addr, method_name); if dyn_val.to_bits() != crate::value::TAG_UNDEFINED { + // #6438: same rebind as the GC_TYPE_CLOSURE arm below — + // `closure_get_dynamic_prop` may return a method read off the + // closure's `Object.setPrototypeOf` proto, whose bound `this` + // (an object-literal method binds the literal) would otherwise + // win over IMPLICIT_THIS and leave `this` as the PROTO. + let bound = crate::closure::clone_closure_rebind_this( + dyn_val.to_bits(), + f64::from_bits(object.to_bits()), + ); let prev_this = IMPLICIT_THIS.with(|c| c.replace(object.to_bits())); - let result = crate::closure::js_native_call_value(dyn_val, args_ptr, args_len); + let result = + crate::closure::js_native_call_value(f64::from_bits(bound), args_ptr, args_len); IMPLICIT_THIS.with(|c| c.set(prev_this)); return result; } @@ -1335,8 +1345,37 @@ pub unsafe extern "C" fn js_native_call_method( let dyn_val = crate::closure::closure_get_dynamic_prop(obj as usize, method_name); if dyn_val.to_bits() != crate::value::TAG_UNDEFINED { let recv_bits = jsval.bits(); + // #6438: `closure_get_dynamic_prop` also walks the closure's + // `Object.setPrototypeOf` chain, so `dyn_val` may be a method + // read off the PROTO object — and an object-literal method + // carries a bound `this` (the literal). A bound `this` wins over + // IMPLICIT_THIS, so setting IMPLICIT_THIS alone left `this` as + // the PROTO instead of the receiver. Rebind to the receiver, + // exactly as the ObjectHeader arm does for its inherited-field + // dispatch (`clone_closure_rebind_this` + IMPLICIT_THIS) — that + // asymmetry is why a plain-object receiver worked and a FUNCTION + // receiver did not. + // + // @effect/platform's HttpApiGroup is built this way: + // + // const Proto = { prefix() { Record.map(this.endpoints, …) }, … } + // const makeProto = (options) => { + // function HttpApiGroup() {} + // Object.setPrototypeOf(HttpApiGroup, Proto) + // return Object.assign(HttpApiGroup, options) // own props on a FUNCTION + // } + // + // so `group.prefix("/api")` ran with `this === Proto`, read + // `this.endpoints` as undefined, and threw + // "Cannot convert undefined or null to object" out of + // `Object.keys` — killing `HttpApi` construction at module init. + let bound = crate::closure::clone_closure_rebind_this( + dyn_val.to_bits(), + f64::from_bits(recv_bits), + ); let prev_this = IMPLICIT_THIS.with(|c| c.replace(recv_bits)); - let result = crate::closure::js_native_call_value(dyn_val, args_ptr, args_len); + let result = + crate::closure::js_native_call_value(f64::from_bits(bound), args_ptr, args_len); IMPLICIT_THIS.with(|c| c.set(prev_this)); return result; } diff --git a/test-files/test_gap_class_expr_perf_eval_statics.ts b/test-files/test_gap_class_expr_perf_eval_statics.ts new file mode 100644 index 0000000000..43bb645512 --- /dev/null +++ b/test-files/test_gap_class_expr_perf_eval_statics.ts @@ -0,0 +1,15 @@ +// Per-evaluation statics for class expressions: two evaluations that extend +// DIFFERENT top-level classes must each read THEIR OWN parent's static, not the +// last-registered sibling's (the shared template's parent edge is last-wins). +class P1 { static v = "p1"; static who() { return "P1"; } } +class P2 { static v = "p2"; static who() { return "P2"; } } +function makeChild(p: any) { return class extends p {}; } +const C1 = makeChild(P1); +const C2 = makeChild(P2); +console.log("C1.v:", (C1 as any).v); // p1 (its own pinned parent) +console.log("C2.v:", (C2 as any).v); // p2 +console.log("C1.who:", (C1 as any).who()); // P1 (inherited static method) +console.log("C2.who:", (C2 as any).who()); // P2 +// A third evaluation extending P1 again must still be p1. +const C3 = makeChild(P1); +console.log("C3.v:", (C3 as any).v); // p1