From 2f76fb6e2676537cd78eb3e596314ff272f25471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 02:32:53 +0200 Subject: [PATCH 1/4] fix(hir): per-evaluation statics for class expressions with heritage (#6438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class expression that carries per-evaluation static fields lowers to `ClassExprFresh` — a fresh heap class object per evaluation, each holding its own statics as own properties. That path excluded any class expression WITH an `extends` clause (`parent_expr.is_none()`), so those fell back to the shared-template `ClassRef` path and hit exactly the failure #1772's own comment warns about: "a class expression returned from a factory (effect's `make`) shares one template class and `.ast` is undefined/clobbered". effect's Schema.ts is precisely that shape: function makeDeclareClass(typeParameters, ast) { return class DeclareClass extends make(ast) { static override annotations(annotations) { return makeDeclareClass(this.typeParameters, ...) } 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 read `undefined`, and `this.typeParameters` inside the inherited static `annotations` then fed `[...undefined]` → "TypeError: undefined is not iterable", taking down the @effect/platform HttpApi server at startup. Heritage is orthogonal to per-evaluation static storage. Allow the fresh path for class expressions with a parent, sequencing the dynamic-parent registration ahead of the fresh object so the parent edge is wired before the object is materialized (the Sequence still yields the class value as its last element). Heritage alone is not sufficient, though: `RegisterClassParentDynamic` keys the parent edge by class_id — i.e. by the compile-time template — so it is last-wins across evaluations, and effect hands each DeclareClass a DIFFERENT parent (`make(ast)` returns a fresh class per call). Give the class object its own edge: `js_class_object_pin_parent` copies THIS evaluation's parent onto the object right after the registration (write-right-before-use, the same shape the capture snapshot already uses), and the by-name read walks that pinned edge for inherited static fields. Two traps worth recording: * the pinned value may be an INT32 ClassRef, not a pointer — `js_get_dynamic_parent_value` falls back to one when no dynamic value was stashed. Casting it to `*mut ObjectHeader` and dereferencing is an instant SIGSEGV, so only POINTER-tagged heap class objects are walked; class-ref parents already resolve through the registry's class-id chain. * reading the pin marker through the ordinary by-name path re-enters the pinned-parent walk and recurses to the stack guard page. The reader scans the keys array directly. A re-entrancy flag does not work either — it would abort the legitimate parent→grandparent walk of a multi-level chain. Verified against `node --experimental-strip-types`: own static on the 5th factory class undefined -> [5] DateFromSelf.typeParameters undefined -> [6] DateFromSelf.annotations() undefined -> [6] ChildOfD3.typeParameters undefined -> [3] and effect's "TypeError: undefined is not iterable" is gone. Gap suite: green — 0 new untriaged failures (regression gate exit 0). This does not yet get the HttpApi server serving: `Schema.TaggedError`'s function-scoped class DECLARATIONS (`class Base extends data_.Error {}` with a per-call `Base.prototype.name = tag`) still share one compile-time template, so BadArgument and SystemError collapse onto the same `_tag`. That needs per-evaluation class IDENTITY, tracked in #6438. --- .../src/expr/static_field_meta.rs | 13 +++ .../src/runtime_decls/objects.rs | 2 + .../src/lower/lower_expr/arm_class.rs | 41 ++++++++- .../src/object/class_registry.rs | 9 +- .../object/class_registry/parent_static.rs | 91 +++++++++++++++++++ .../object/field_get_set/get_field_by_name.rs | 40 ++++++++ 6 files changed, 188 insertions(+), 8 deletions(-) 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..a0057dc8d5 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_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..e775e9fd69 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,97 @@ 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 { + const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + if obj.is_null() || !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 want = CLASS_OBJECT_PARENT_KEY.as_bytes(); + 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 630cc8c066..810aa6f017 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 @@ -258,6 +258,46 @@ pub extern "C" fn js_object_get_field_by_name( } } } + // #6438: static FIELD inheritance for a per-evaluation class object. + // Own fields and the registry (static methods / accessors, keyed by the + // TEMPLATE class id) are handled above; a static field owned by the + // PARENT was not, so `child.parentStaticField` read undefined. + // + // The parent edge must come from this object's OWN pinned parent + // (`js_class_object_pin_parent`), never `CLASS_DYNAMIC_PARENT_VALUE` — + // that table is template-keyed and last-wins, so with a factory invoked + // repeatedly (effect's `class DeclareClass extends make(ast) { … }`) + // every evaluation would walk to the LAST parent and read the wrong + // `static ast`. Recursing through the ordinary by-name read gives the + // parent's own fields, its registry statics, and — transitively — its + // own pinned parent, so a multi-level factory chain resolves correctly. + if own.is_undefined() { + if let Some(parent) = + super::super::class_registry::class_object_pinned_parent(obj as *const ObjectHeader) + { + let pbits = parent.to_bits(); + // ONLY a POINTER-tagged heap class object is walked here. The + // pinned value can also be an INT32-tagged ClassRef (0x7FFE) — + // `js_get_dynamic_parent_value` falls back to one when no + // dynamic value was stashed — and a ClassRef's payload is a + // class id, NOT an address: casting it to `*mut ObjectHeader` + // and reading it segfaults. Class-ref parents already resolve + // through the registry's class-id chain above, so skipping them + // here loses nothing. + 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; + } + } + } + } + } return own; } if let Some(addr) = From 97735ced5065fa08913105411e75bda5a766bf07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 05:00:18 +0200 Subject: [PATCH 2/4] fix(runtime): inherited statics on a per-evaluation class object must use ITS parent (#6438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gives a class expression with heritage per-evaluation OWN statics and pins this evaluation's parent onto the class object. Reading an INHERITED static still went the wrong way. `get_field_by_name`'s class-object arm called `get_field_by_name_object_tail` first, which folds the own lookup together with a class_id-keyed prototype-chain walk (`resolve_proto_chain_field_with_receiver`). For a per-evaluation class object that chain resolves through the TEMPLATE's parent edge — last-wins across evaluations — so it answered with a SIBLING evaluation's inherited value. And because that answer is never `undefined`, it silently pre-empted the pinned-parent walk that was supposed to handle exactly this case. effect's Schema.ts: export function make(ast) { return class SchemaClass { static ast = ast; … } // class expr, no heritage } function makeTypeLiteralClass(fields, records, ast = …) { return class TypeLiteralClass extends make(ast) { // class expr WITH heritage static fields = { ...fields } } } export function Struct(fields, ...records) { return makeTypeLiteralClass(fields, records) } `ast` is INHERITED (it lives on the parent `SchemaClass`), so `Struct(a)` followed by `Struct(b)` left `structA.ast === astB`. The asymmetry is what made it look like mutation: OWN statics were already correct (`structA.fields` fine), only inherited ones collapsed. `TaggedError` then fed a clobbered schema to `extend(schema, Struct({_tag}))`, which threw Unsupported schema or overlapping types at path: ["_tag"] details: cannot extend "BadArgument" with "BadArgument" on the FIRST call — killing the @effect/platform HttpApi server. Order the read as own -> THIS object's pinned parent -> generic tail. The own half needs an own-ONLY lookup (`class_object_own_field_bytes`) because the tail cannot provide one without its class_id chain; it reuses the keys-array scan the pinned reader already uses (which must not go through the by-name path — that re-enters the pinned walk and recurses to the stack guard page). Verified against `node --experimental-strip-types`: A.ast after creating B "astB" -> "astA" (inherited, isolated) A.fields / B.fields unchanged, correct (own, already fine) and effect's `cannot extend "BadArgument" with "BadArgument"` is gone; web.ts now progresses past schema construction. Gap suite: green — 0 new untriaged failures (regression gate exit 0). No regressions in the per-evaluation statics, `new`/`super()`/`instanceof`, or the multi-level factory-chain cases from the previous commit. --- .../src/object/class_registry.rs | 4 +- .../object/class_registry/parent_static.rs | 17 +++- .../object/field_get_set/get_field_by_name.rs | 86 ++++++++++--------- 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index a0057dc8d5..8ea4ced67e 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -157,8 +157,8 @@ 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_object_pinned_parent, - class_own_symbol_member_keys, class_static_accessor_getter_value, + 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, 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 e775e9fd69..ccf3cfa07a 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -252,6 +252,22 @@ pub extern "C" fn js_class_object_pin_parent(obj: i64, template_class_id: u32) { /// 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; if obj.is_null() || !crate::object::is_valid_obj_ptr(obj as *const u8) { return None; @@ -261,7 +277,6 @@ pub(crate) fn class_object_pinned_parent(obj: *const crate::object::ObjectHeader if keys.is_null() { return None; } - let want = CLASS_OBJECT_PARENT_KEY.as_bytes(); let len = (*keys).length; for i in 0..len { let k = crate::array::js_array_get_f64(keys, i); 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 810aa6f017..382830c091 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; @@ -258,46 +304,6 @@ pub extern "C" fn js_object_get_field_by_name( } } } - // #6438: static FIELD inheritance for a per-evaluation class object. - // Own fields and the registry (static methods / accessors, keyed by the - // TEMPLATE class id) are handled above; a static field owned by the - // PARENT was not, so `child.parentStaticField` read undefined. - // - // The parent edge must come from this object's OWN pinned parent - // (`js_class_object_pin_parent`), never `CLASS_DYNAMIC_PARENT_VALUE` — - // that table is template-keyed and last-wins, so with a factory invoked - // repeatedly (effect's `class DeclareClass extends make(ast) { … }`) - // every evaluation would walk to the LAST parent and read the wrong - // `static ast`. Recursing through the ordinary by-name read gives the - // parent's own fields, its registry statics, and — transitively — its - // own pinned parent, so a multi-level factory chain resolves correctly. - if own.is_undefined() { - if let Some(parent) = - super::super::class_registry::class_object_pinned_parent(obj as *const ObjectHeader) - { - let pbits = parent.to_bits(); - // ONLY a POINTER-tagged heap class object is walked here. The - // pinned value can also be an INT32-tagged ClassRef (0x7FFE) — - // `js_get_dynamic_parent_value` falls back to one when no - // dynamic value was stashed — and a ClassRef's payload is a - // class id, NOT an address: casting it to `*mut ObjectHeader` - // and reading it segfaults. Class-ref parents already resolve - // through the registry's class-id chain above, so skipping them - // here loses nothing. - 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; - } - } - } - } - } return own; } if let Some(addr) = From b521df4d8bea88085a57a16d6e02c300884217fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 05:46:36 +0200 Subject: [PATCH 3/4] fix(runtime): reject the handle band in class_object_own_field_bytes; add per-eval statics test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The address-classification ratchet flagged a lone `is_valid_obj_ptr` guard in `class_object_own_field_bytes` — it doesn't reject the fetch/zlib/proxy handle bands, and dereferencing a handle id as an ObjectHeader segfaults on Linux (masked on macOS). Gate on `is_above_handle_band` first, like every other receiver-deref site. A real class object is always a heap allocation above the band, so this only rejects values that shouldn't reach here. Adds test_gap_class_expr_perf_eval_statics covering the PR's actual behavior: two class expressions extending DIFFERENT top-level classes each read their own pinned parent's static (v/who), not the last-registered sibling's — byte- identical to node. --- .../src/object/class_registry/parent_static.rs | 9 ++++++++- .../test_gap_class_expr_perf_eval_statics.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 test-files/test_gap_class_expr_perf_eval_statics.ts 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 ccf3cfa07a..91a08efa78 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -269,7 +269,14 @@ pub(crate) fn class_object_own_field_bytes( want: &[u8], ) -> Option { const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - if obj.is_null() || !crate::object::is_valid_obj_ptr(obj as *const u8) { + // `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 { 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 From 3187ef3e557fc9bf4bcc07625e1e1504b6cf3f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 06:52:58 +0200 Subject: [PATCH 4/4] fix(runtime): bind `this` to the receiver for a method inherited by a FUNCTION object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `closure_get_dynamic_prop` walks the closure's `Object.setPrototypeOf` chain, so a method call on a function object can resolve to a method read off the PROTO. Both closure arms in `js_native_call_method` then set IMPLICIT_THIS and invoked the value directly — but an object-literal method carries a BOUND `this` (the literal), and a bound `this` wins over IMPLICIT_THIS. `this` therefore stayed the PROTO instead of the receiver. The ObjectHeader arm already gets this right: it pairs `clone_closure_rebind_this(field_val, receiver)` with the IMPLICIT_THIS set for its inherited-field dispatch. That asymmetry is exactly why a plain-object receiver worked while a FUNCTION receiver silently read `undefined` off `this`. Repro (no framework): const Proto = { tag: "proto", probe() { return this.endpoints } } function makeProto(options) { function G() {} Object.setPrototypeOf(G, Proto) return Object.assign(G, options) } const a = makeProto({ endpoints: { x: 1 } }) a.endpoints // { x: 1 } (own prop, fine) a.probe() // node: { x: 1 } perry: undefined Inside `probe`, perry reported `typeof this === "object"` and `this.tag === "proto"` — i.e. `this` was Proto — while node reports `typeof this === "function"`. A plain-object receiver (`Object.create(Proto)`) was already correct, which is what hid this. @effect/platform's HttpApiGroup / HttpLayerRouter are built on the idiom: const Proto = { prefix() { return Record.map(this.endpoints, …) }, … } const makeProto = (options) => { function HttpApiGroup() {} Object.setPrototypeOf(HttpApiGroup, Proto) return Object.assign(HttpApiGroup, options) } 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 during module init. The idiom (callable object + shared method table) is common well beyond effect, and the failure is silent: `this.` reads undefined rather than throwing. Rebind the resolved method to the receiver in both closure arms, mirroring the ObjectHeader arm. Verified against `node --experimental-strip-types`: the repro above and the `this`-identity probe now match node exactly; effect's api.ts module init completes and the fiber runtime starts (its logger emits). Gap suite: green — 0 new untriaged failures (regression gate exit 0). --- .../src/object/native_call_method.rs | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) 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; }