From 97da842251fd7b15db68f73bc120e4549dad7275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 12:04:24 +0000 Subject: [PATCH 1/4] Read super members and instanceof from the live prototype chain super.m() called the body resolved along the declared extends chain, and its runtime fallback (taken once a prototype-surgery guard byte is set) resolved through the declared vtable, so a patched, deleted or getter-backed parent method never applied (#11760). typeof super.m and super.m as a value were the declared method's wrapper, super.x and super calls ignored a relinked home, and a static super.s() resolved an instance method of the same name. The runtime now reads home.[[GetPrototypeOf]]().[[Get]](key, this) where the declared lookups stop describing the chain and the runtime models it end to end (the home links somewhere other than its declared parent, or the declared chain is compiled user classes only): - super.m() falls back to it whenever its guard byte is set. - A static super call keeps the declared static lookup, which reads the class function objects, unless that lookup misses or a constructor on the way was relinked. - super.x keeps the declared lookup, which reads the prototype objects, unless a prototype on the declared chain was relinked. The relink checks run only once a user prototype override exists (one latch load). js_super_accessor_get now takes the home class id, and the resolved super.m value reads the same guard bytes as super.m(). instanceof walked declared class ids. Once a user prototype override exists, an instance whose own prototype was replaced, or whose declared chain passes a relinked class prototype before reaching the target, is answered by OrdinaryHasInstance on the live chain (#11765). The same holds for instanceof Object. C.prototype.__proto__ = X compiled to a prototype-method install named __proto__. It now performs the [[Set]], which reaches the Object.prototype accessor and relinks like Object.setPrototypeOf. --- crates/perry-codegen/src/expr/super_method.rs | 97 +++++-- .../src/object/class_constructors.rs | 159 ++++------- .../class_registry/prototype_methods.rs | 21 ++ .../src/object/class_super_chain.rs | 269 ++++++++++++++++++ crates/perry-runtime/src/object/instanceof.rs | 59 ++++ .../src/object/instanceof/static_dispatch.rs | 19 ++ crates/perry-runtime/src/object/mod.rs | 1 + .../perry-runtime/src/object/property_key.rs | 51 +++- 8 files changed, 541 insertions(+), 135 deletions(-) create mode 100644 crates/perry-runtime/src/object/class_super_chain.rs diff --git a/crates/perry-codegen/src/expr/super_method.rs b/crates/perry-codegen/src/expr/super_method.rs index 7bc2887216..81cd6d8d32 100644 --- a/crates/perry-codegen/src/expr/super_method.rs +++ b/crates/perry-codegen/src/expr/super_method.rs @@ -24,11 +24,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } return Ok(double_literal(0.0)); }; - // Walk parent chain starting from extends_name. - let mut parent = ctx - .classes - .get(¤t_class_name) - .and_then(|c| c.extends_name.clone()); + // Walk parent chain starting from extends_name. The method tables + // hold INSTANCE methods only: in a static member `super` is the + // parent constructor, which the runtime path resolves. + let mut parent = if ctx.in_static_member { + None + } else { + ctx.classes + .get(¤t_class_name) + .and_then(|c| c.extends_name.clone()) + }; let mut resolved_fn: Option = None; // #8040: the class the body actually lives on, so the trailing // parameter shape below is read off the callee we are calling. @@ -344,11 +349,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let Some(current_class_name) = ctx.class_stack.last().cloned() else { return Ok(undef); }; - let immediate_parent = ctx - .classes - .get(¤t_class_name) - .and_then(|c| c.extends_name.clone()); - let mut parent = immediate_parent.clone(); + // As for the call form: in a static member the instance method + // tables do not describe `super`. + let mut parent = if ctx.in_static_member { + None + } else { + ctx.classes + .get(¤t_class_name) + .and_then(|c| c.extends_name.clone()) + }; let mut resolved_fn: Option = None; while let Some(p) = parent { let key = (p.clone(), property.clone()); @@ -358,18 +367,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } parent = ctx.classes.get(&p).and_then(|c| c.extends_name.clone()); } - let Some(fn_name) = resolved_fn else { - // Not a method on the parent chain — `super.prop` then reads the - // property off the parent prototype with `this` as receiver: - // an accessor (getter) is INVOKED, a data property - // (`B.prototype.x = 42`) is returned. Route to the runtime, - // which walks the parent class chain. Refs - // class/super/in-{constructor,getter,methods,setter}. - let parent_cid = immediate_parent - .as_ref() - .and_then(|p| ctx.class_ids.get(p)) - .copied() - .unwrap_or(0); + // `super.prop` reads the property off the home object's current + // `[[Prototype]]` with `this` as receiver: an accessor (getter) is + // INVOKED, a data property (`B.prototype.x = 42`) is returned. The + // runtime reads that chain from the home class id. Refs + // class/super/in-{constructor,getter,methods,setter}. + let home_cid = ctx.class_ids.get(¤t_class_name).copied().unwrap_or(0); + let runtime_get = |ctx: &mut FnCtx<'_>| -> String { let recv_v = if let Some(this_slot) = ctx.this_stack.last().cloned() { ctx.block().load(DOUBLE, &this_slot) } else { @@ -378,12 +382,41 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let key_box = ctx.block().load(DOUBLE, &key_handle_global); - let parent_cid_s = parent_cid.to_string(); - return Ok(ctx.block().call( + let home_cid_s = home_cid.to_string(); + ctx.block().call( DOUBLE, "js_super_accessor_get", - &[(I32, &parent_cid_s), (DOUBLE, &key_box), (DOUBLE, &recv_v)], - )); + &[(I32, &home_cid_s), (DOUBLE, &key_box), (DOUBLE, &recv_v)], + ) + }; + let Some(fn_name) = resolved_fn else { + return Ok(runtime_get(ctx)); + }; + // The method above was resolved along the declared `extends` + // chain, which holds while no prototype surgery touched this + // name (the guard bytes `super.m()` reads too). + let guarded = if home_cid != 0 { + let key_idx = ctx.strings.intern(property); + let slot = (ctx.strings.entry(key_idx).dispatch_hash & 0xffff).to_string(); + let direct_idx = ctx.new_block("super_get.direct"); + let dynamic_idx = ctx.new_block("super_get.dynamic"); + let merge_idx = ctx.new_block("super_get.merge"); + let direct_label = ctx.block_label(direct_idx); + let dynamic_label = ctx.block_label(dynamic_idx); + let merge_label = ctx.block_label(merge_idx); + let ok = crate::lower_call::method_override::emit_prototype_method_guard_ok( + ctx.block(), + &slot, + ); + ctx.block().cond_br(&ok, &direct_label, &dynamic_label); + ctx.current_block = dynamic_idx; + let dynamic_value = runtime_get(ctx); + let dynamic_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + ctx.current_block = direct_idx; + Some((merge_idx, merge_label, dynamic_value, dynamic_end)) + } else { + None }; // Mirror Expr::FuncRef: route through the singleton wrapper // so callers can invoke via the closure-call ABI. The @@ -413,7 +446,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let blk = ctx.block(); let wrap_info = blk.fn_info_ref(&wrap_name); let closure_handle = blk.call(I64, "js_closure_alloc_singleton", &[(PTR, &wrap_info)]); - Ok(nanbox_pointer_inline(blk, &closure_handle)) + let direct_value = nanbox_pointer_inline(blk, &closure_handle); + let Some((merge_idx, merge_label, dynamic_value, dynamic_end)) = guarded else { + return Ok(direct_value); + }; + let direct_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + ctx.current_block = merge_idx; + Ok(ctx.block().phi( + DOUBLE, + &[(&direct_value, &direct_end), (&dynamic_value, &dynamic_end)], + )) } Expr::SuperPropertySet { diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 1c1e0e2018..6a5cb67d4e 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -11,6 +11,10 @@ use std::collections::HashMap; use std::sync::RwLock; use super::class_registry::call_vtable_method; +use super::class_super_chain::{ + class_super_base, declared_chain_has_relinked_prototype, static_chain_relinked, + super_call_on_live_base, super_call_on_relinked_chain, super_home_owner, +}; use super::ObjectHeader; /// Replace the capture array carried by one heap class-expression value. @@ -881,11 +885,7 @@ pub unsafe extern "C" fn js_super_method_call_dynamic( // Repeated evaluations can share a template id, so the template parent // table cannot represent their heritage edge. Resolve the method's own // evaluation first, then read its pinned parent. - let lexical_owner = super::field_get_set::current_private_lexical_brand_value(child_class_id) - .or_else(|| { - super::field_get_set::private_evaluation_brand_value(this_value) - .and_then(|owner| pinned_class_object_for_ancestor(owner, child_class_id)) - }); + let lexical_owner = super_home_owner(child_class_id, this_value); let parent_owner = lexical_owner.and_then(|owner| { let object = crate::value::JSValue::from_bits(owner.to_bits()).as_pointer::(); super::class_registry::class_object_pinned_parent(object) @@ -905,16 +905,67 @@ pub unsafe extern "C" fn js_super_method_call_dynamic( }; let _parent_brand = super::field_get_set::PrivateHintBrandScope::new(parent_owner.map(f64::to_bits)); + // `super.name` is a property lookup on the home object's CURRENT + // `[[Prototype]]`, with `this` as the receiver: a patched, deleted or + // accessor parent member and a relinked home all apply. Where the runtime + // models that chain end to end, read it; the declared-chain lookups below + // serve the rest (native and builtin bases, per-evaluation classes). + let is_static = super::class_ref_id(this_value).is_some() + || super::class_registry::is_class_object_value(this_value); + // A static member's declared lookup reads the class function objects (an + // assigned or deleted static ends it), so it is the property lookup unless + // a constructor on the way was relinked. An instance member reaches here + // when the compiler could not resolve the name or a prototype-surgery + // guard byte is set: the declared vtable no longer describes the chain. + let static_entry = if is_static { + super::class_registry::parent_static::lookup_static_method_owner(parent_cid, name) + } else { + None + }; + let mut base = None; + // The probes below can allocate (materializing a prototype or a class + // value), so the receiver and the arguments ride across them in handles; + // the declared-chain paths below read the refreshed copies. + let refreshed_args: Vec; + let (this_value, args_ptr) = if static_entry.is_none() + || super::prototype_chain::any_user_prototype_override() + { + let live_scope = crate::gc::RuntimeHandleScope::new(); + let this_handle = live_scope.root_nanbox_f64(this_value); + let arg_handles = + live_scope.root_nanbox_f64_slice(if args_len > 0 && !args_ptr.is_null() { + std::slice::from_raw_parts(args_ptr, args_len) + } else { + &[] + }); + let live = match static_entry { + Some((owner, _)) => static_chain_relinked(child_class_id, owner), + None => true, + }; + if live { + base = class_super_base(child_class_id, parent_cid, lexical_owner, is_static); + } + refreshed_args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); + ( + this_handle.get_nanbox_f64(), + if refreshed_args.is_empty() { + args_ptr + } else { + refreshed_args.as_ptr() + }, + ) + } else { + (this_value, args_ptr) + }; + if let Some(base) = base { + return super_call_on_live_base(name, this_value, args_ptr, args_len, base); + } // Static-context super call (`super.m()` inside a `static` method): the // receiver is the class constructor (a ClassRef), so resolve the PARENT's // STATIC method (not an instance/prototype method) and invoke it with // `this` bound to the current class. Refs class/super/in-static-methods. - if super::class_ref_id(this_value).is_some() - || super::class_registry::is_class_object_value(this_value) - { - if let Some((func_ptr, param_count, has_rest)) = - super::class_registry::lookup_static_method_in_chain(parent_cid, name) - { + if is_static { + if let Some((_, (func_ptr, param_count, has_rest))) = static_entry { crate::object::static_this_arm_if_unarmed(this_value); let result = if has_rest { // Mirror `js_class_static_method_call`'s rest bundling: fixed @@ -1024,92 +1075,6 @@ pub unsafe extern "C" fn js_super_method_call_dynamic( call_displaced_native_base_method(this_value, name, args_ptr, args_len, undef) } -/// Is the prototype of `cid` or of one of its declared ancestors relinked by a -/// user operation? Asked only after the declared-member lookups missed. -fn declared_chain_has_relinked_prototype(cid: u32) -> bool { - let mut cur = cid; - for _ in 0..32 { - if cur == 0 { - return false; - } - if super::class_registry::class_decl_prototype_relinked(cur) { - return true; - } - match crate::object::get_parent_class_id(cur) { - Some(p) if p != cur => cur = p, - _ => return false, - } - } - false -} - -/// `super.name(...args)` resolved by `read` on a relinked chain: call the value -/// with `this_value` as receiver, or throw the TypeError a call of a -/// non-callable `super.name` throws. -/// -/// # Safety -/// `args_ptr` must point to `args_len` valid `f64`s (or be null when -/// `args_len == 0`). -unsafe fn super_call_on_relinked_chain( - name: &str, - this_value: f64, - args_ptr: *const f64, - args_len: usize, - read: impl FnOnce(*const crate::StringHeader, f64) -> Option, -) -> f64 { - // The key allocation and the read (a getter on the new chain) can collect; - // the receiver and the arguments ride across them in handles. - let scope = crate::gc::RuntimeHandleScope::new(); - let this_handle = scope.root_nanbox_f64(this_value); - let args: Vec = if args_len > 0 && !args_ptr.is_null() { - std::slice::from_raw_parts(args_ptr, args_len).to_vec() - } else { - Vec::new() - }; - let arg_handles = scope.root_nanbox_f64_slice(&args); - let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - let value = if key.is_null() { - None - } else { - read( - key as *const crate::StringHeader, - this_handle.get_nanbox_f64(), - ) - }; - let callable = value.filter(|v| { - let boxed = f64::from_bits(v.bits()); - v.is_pointer() - && ((crate::proxy::js_proxy_is_proxy(boxed) == 1 - && crate::proxy::proxy_wraps_callable(boxed)) - || crate::closure::is_closure_ptr( - crate::value::js_nanbox_get_pointer(boxed) as usize - )) - }); - let Some(method) = callable else { - crate::error::js_throw_type_error_not_a_function( - std::ptr::null(), - 0, - name.as_ptr(), - name.len(), - ) - }; - let method_handle = scope.root_nanbox_f64(f64::from_bits(method.bits())); - let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); - if crate::proxy::js_proxy_is_proxy(method_handle.get_nanbox_f64()) == 1 { - return crate::proxy::call_proxy_value_with_this( - method_handle.get_nanbox_f64(), - this_handle.get_nanbox_f64(), - &args, - ); - } - crate::closure::native_call_value_this( - method_handle.get_nanbox_f64(), - crate::closure::JsThis::from_f64(this_handle.get_nanbox_f64()), - args.as_ptr(), - args.len(), - ) -} - /// Invoke the native base method a subclass override displaced (#6316), with /// `this` bound to the receiver. Falls back to `undef` when the receiver carries /// no such method — an ordinary `super.m()` miss stays `undefined`. diff --git a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs index 210fb7cfd5..ab7caa88da 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs @@ -405,6 +405,27 @@ pub unsafe extern "C" fn js_register_prototype_method( if class_has_instance_getter(class_id, &name) { return; } + // `C.prototype.__proto__ = v` is not a method install: it is a `[[Set]]` + // that reaches `Object.prototype`'s `__proto__` accessor, whose setter + // relinks the prototype exactly like `Object.setPrototypeOf`. + if name == "__proto__" { + let proto = super::class_decl_prototype_value(class_id); + if crate::value::JSValue::from_bits(proto.to_bits()).is_pointer() { + let scope = crate::gc::RuntimeHandleScope::new(); + let proto = scope.root_nanbox_f64(proto); + let value = scope.root_nanbox_f64(value); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let key = f64::from_bits(crate::value::JSValue::string_ptr(key).bits()); + crate::proxy::js_put_value_set( + proto.get_nanbox_f64(), + key, + value.get_nanbox_f64(), + proto.get_nanbox_f64(), + 1, + ); + return; + } + } class_prototype_method_root_store(class_id, name, value.to_bits()); // Ensure the receiver class can be `typeof`-detected. Method-less // classes that only get extended via `Class.prototype.m = fn` diff --git a/crates/perry-runtime/src/object/class_super_chain.rs b/crates/perry-runtime/src/object/class_super_chain.rs new file mode 100644 index 0000000000..07b932b8d6 --- /dev/null +++ b/crates/perry-runtime/src/object/class_super_chain.rs @@ -0,0 +1,269 @@ +//! The live `[[Prototype]]` chain a `super` reference reads. +//! +//! `super.name` is a property lookup on the home object's CURRENT +//! `[[Prototype]]` (the class prototype for an instance member, the class +//! constructor for a static one), with `this` as the receiver. These helpers +//! find that base and call or read through it when a relink, a patch or a +//! non-modeled base means the declared class tables no longer describe it. +//! +//! Split out of `class_constructors.rs` to keep that file under the 2,000-line +//! CI gate. + +use super::class_constructors::pinned_class_object_for_ancestor; + +/// The class object of the evaluation a `super` reference in class +/// `home_cid`'s methods belongs to, when one is pinned: repeated evaluations +/// share a template id, so the template tables cannot name it. +#[inline] +pub(crate) fn super_home_owner(home_cid: u32, this_value: f64) -> Option { + super::field_get_set::current_private_lexical_brand_value(home_cid).or_else(|| { + super::field_get_set::private_evaluation_brand_value(this_value) + .and_then(|owner| pinned_class_object_for_ancestor(owner, home_cid)) + }) +} + +/// The object `super` reads from in a method whose home object belongs to class +/// `home_cid`: that home object's current `[[Prototype]]`. The home object is +/// the class prototype for an instance method and the class constructor for a +/// static one. +/// +/// `None` when the declared-chain lookups must answer instead: the home is not +/// this template's class value (a per-evaluation class object), or the home +/// still links to its declared parent and that parent's declared chain +/// reaches a builtin, native or function-valued base, whose members this +/// runtime does not model as properties of a prototype object. +/// +/// # Safety +/// Reads class registry state; `home_owner` must be a live value or `None`. +pub(crate) unsafe fn class_super_base( + home_cid: u32, + parent_cid: u32, + home_owner: Option, + is_static: bool, +) -> Option { + if home_cid == 0 || !super::is_class_id_registered(home_cid) { + return None; + } + let class_value = super::class_value::class_value(home_cid); + if home_owner.is_some_and(|owner| owner.to_bits() != class_value.to_bits()) { + return None; + } + let home = if is_static { + class_value + } else { + super::class_registry::class_decl_prototype_value(home_cid) + }; + if !crate::value::JSValue::from_bits(home.to_bits()).is_pointer() { + return None; + } + // Materializing the declared parent's prototype can allocate. + let scope = crate::gc::RuntimeHandleScope::new(); + let base = scope.root_nanbox_f64(super::js_object_get_prototype_of(home)); + if parent_cid == 0 { + return None; + } + let declared = if is_static { + super::class_value::class_value(parent_cid) + } else { + super::class_registry::class_decl_prototype_value(parent_cid) + }; + let base = base.get_nanbox_f64(); + (base.to_bits() != declared.to_bits() || declared_chain_is_user_classes(parent_cid)) + .then_some(base) +} + +/// Was the `[[Prototype]]` of a class constructor on the declared chain from +/// `home_cid` up to (not including) `owner_cid` relinked, so that the declared +/// static lookup no longer describes it? One latch load answers `false` in a +/// process that never set a user prototype. +pub(super) fn static_chain_relinked(home_cid: u32, owner_cid: u32) -> bool { + if !super::prototype_chain::any_user_prototype_override() { + return false; + } + let mut cur = home_cid; + for _ in 0..64 { + if cur == owner_cid { + return false; + } + let Some(parent) = crate::object::get_parent_class_id(cur).filter(|p| *p != 0 && *p != cur) + else { + return false; + }; + // A constructor nobody has seen as a value cannot have been relinked. + if let Some(ctor) = super::class_value::class_value_if_minted(cur) { + let proto = + super::js_object_get_prototype_of(crate::value::js_nanbox_pointer(ctor as i64)); + if proto.to_bits() != super::class_value::class_value(parent).to_bits() { + return true; + } + } + cur = parent; + } + false +} + +/// The live super base for `super.key` in a method whose home belongs to class +/// `home_cid`, when the declared lookups may answer differently: only after a +/// relink, because they read the prototype objects and class function +/// objects, so patched, deleted and accessor members already apply. Asked +/// once a user prototype override exists; may allocate. +#[cold] +#[inline(never)] +pub(crate) unsafe fn super_get_live_base( + home_cid: u32, + parent_cid: u32, + receiver: f64, +) -> Option { + let is_static = super::class_ref_id(receiver).is_some() + || super::class_registry::is_class_object_value(receiver); + let relinked = if is_static { + static_chain_relinked(home_cid, 0) + } else { + declared_chain_has_relinked_prototype(home_cid) + }; + if !relinked { + return None; + } + let owner = super_home_owner(home_cid, receiver); + class_super_base(home_cid, parent_cid, owner, is_static) +} + +/// Does the declared chain from `cid` consist of compiled user classes only, +/// ending in a class that extends nothing? Then every member on it is a +/// property of a prototype object (or class constructor) the runtime reads. +pub(super) fn declared_chain_is_user_classes(cid: u32) -> bool { + let mut cur = cid; + for _ in 0..64 { + if cur == 0 || cur >= 0xFFFF_0000 || !super::is_class_id_registered(cur) { + return false; + } + match crate::object::get_parent_class_id(cur) { + Some(p) if p != 0 && p != cur => cur = p, + _ => { + // No parent edge: a recorded heritage value means a function, + // native or builtin base. + return crate::value::JSValue::from_bits( + super::class_registry::parent_static::template_dynamic_parent_value(cur) + .to_bits(), + ) + .is_undefined(); + } + } + } + false +} + +/// `super.name(...args)` with `base` as the super base: `base.[[Get]](name, +/// this)`, called with `this_value` as receiver. A null base or a +/// non-callable value throws the call's TypeError. +/// +/// # Safety +/// `args_ptr` must point to `args_len` valid `f64`s (or be null when +/// `args_len == 0`). +pub(super) unsafe fn super_call_on_live_base( + name: &str, + this_value: f64, + args_ptr: *const f64, + args_len: usize, + base: f64, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let base = scope.root_nanbox_f64(base); + super_call_on_relinked_chain(name, this_value, args_ptr, args_len, |key, receiver| { + let base = base.get_nanbox_f64(); + let jv = crate::value::JSValue::from_bits(base.to_bits()); + if jv.is_null() || jv.is_undefined() { + return None; + } + let key = f64::from_bits(crate::value::JSValue::string_ptr(key as *mut _).bits()); + Some(crate::value::JSValue::from_bits( + crate::proxy::js_reflect_get(base, key, receiver).to_bits(), + )) + }) +} + +/// Is the prototype of `cid` or of one of its declared ancestors relinked by a +/// user operation? Asked only after the declared-member lookups missed. +pub(super) fn declared_chain_has_relinked_prototype(cid: u32) -> bool { + let mut cur = cid; + for _ in 0..32 { + if cur == 0 { + return false; + } + if super::class_registry::class_decl_prototype_relinked(cur) { + return true; + } + match crate::object::get_parent_class_id(cur) { + Some(p) if p != cur => cur = p, + _ => return false, + } + } + false +} + +/// `super.name(...args)` resolved by `read` on a relinked chain: call the value +/// with `this_value` as receiver, or throw the TypeError a call of a +/// non-callable `super.name` throws. +/// +/// # Safety +/// `args_ptr` must point to `args_len` valid `f64`s (or be null when +/// `args_len == 0`). +pub(super) unsafe fn super_call_on_relinked_chain( + name: &str, + this_value: f64, + args_ptr: *const f64, + args_len: usize, + read: impl FnOnce(*const crate::StringHeader, f64) -> Option, +) -> f64 { + // The key allocation and the read (a getter on the new chain) can collect; + // the receiver and the arguments ride across them in handles. + let scope = crate::gc::RuntimeHandleScope::new(); + let this_handle = scope.root_nanbox_f64(this_value); + let args: Vec = if args_len > 0 && !args_ptr.is_null() { + std::slice::from_raw_parts(args_ptr, args_len).to_vec() + } else { + Vec::new() + }; + let arg_handles = scope.root_nanbox_f64_slice(&args); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let value = if key.is_null() { + None + } else { + read( + key as *const crate::StringHeader, + this_handle.get_nanbox_f64(), + ) + }; + let callable = value.filter(|v| { + let boxed = f64::from_bits(v.bits()); + v.is_pointer() + && ((crate::proxy::js_proxy_is_proxy(boxed) == 1 + && crate::proxy::proxy_wraps_callable(boxed)) + || crate::closure::is_closure_ptr( + crate::value::js_nanbox_get_pointer(boxed) as usize + )) + }); + let Some(method) = callable else { + crate::error::js_throw_type_error_not_a_function( + std::ptr::null(), + 0, + name.as_ptr(), + name.len(), + ) + }; + let method_handle = scope.root_nanbox_f64(f64::from_bits(method.bits())); + let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); + if crate::proxy::js_proxy_is_proxy(method_handle.get_nanbox_f64()) == 1 { + return crate::proxy::call_proxy_value_with_this( + method_handle.get_nanbox_f64(), + this_handle.get_nanbox_f64(), + &args, + ); + } + crate::closure::native_call_value_this( + method_handle.get_nanbox_f64(), + crate::closure::JsThis::from_f64(this_handle.get_nanbox_f64()), + args.as_ptr(), + args.len(), + ) +} diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 5ca1850f7d..f3d7de9cde 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -683,6 +683,62 @@ pub(crate) fn class_chain_reaches(start: u32, want: u32) -> bool { } } +/// `value instanceof ` for an instance of class `start` when a +/// user `[[Prototype]]` change sits on its way: the instance's own prototype +/// was replaced, or a class prototype on the declared chain from `start` was +/// relinked before that chain reaches `want`. Then the declared class ids no +/// longer describe the chain, and `OrdinaryHasInstance` walks the live one. +/// `None` means the declared walk answers: no such change was ever made (one +/// latch load), or `want` is reached first. +/// `want` is a compiled class, or `Object` (its reserved id), whose +/// constructor is the global one. +#[inline(always)] +pub(crate) fn relinked_instance_chain_answer(value: f64, start: u32, want: u32) -> Option { + if !super::prototype_chain::any_user_prototype_override() { + return None; + } + relinked_instance_chain_answer_armed(value, start, want) +} + +/// [`relinked_instance_chain_answer`] once a user prototype override exists. +/// Out of line so the callers' common path stays one latch load. +#[cold] +#[inline(never)] +fn relinked_instance_chain_answer_armed(value: f64, start: u32, want: u32) -> Option { + const CLASS_ID_OBJECT: u32 = 0xFFFF0050; + if want == 0 || (want != CLASS_ID_OBJECT && !super::is_class_id_registered(want)) { + return None; + } + let live = || { + let constructor = if want == CLASS_ID_OBJECT { + js_get_global_this_builtin_value(b"Object".as_ptr(), 6) + } else { + super::class_constructor_ref_value(want) + }; + ordinary_has_instance_prototype_walk(value, constructor) + }; + if super::prototype_chain::object_has_user_prototype_override(value_addr(value)) { + return Some(live()); + } + if start == 0 { + return None; + } + let mut cur = start; + for _ in 0..64 { + if cur == want || crate::object::class_generic_origin(cur) == Some(want) { + return None; + } + if super::class_registry::class_decl_prototype_relinked(cur) { + return Some(live()); + } + match get_parent_class_id(cur) { + Some(pid) if pid != 0 && pid != cur => cur = pid, + _ => return None, + } + } + None +} + /// The parent-only half of [`class_chain_reaches`], used to continue a walk that /// has already stepped onto a generic origin. Separate so the two edges cannot /// recurse into each other without bound. @@ -788,6 +844,9 @@ fn subclass_of_builtin_reaches(value: f64, class_id: u32) -> bool { return false; } let cur = unsafe { (*obj).class_id }; + if let Some(answer) = relinked_instance_chain_answer(value, cur, class_id) { + return answer; + } // #10624: only pay for the value-aware walk once something has pinned // per-evaluation heritage. let reaches = diff --git a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs index 33794ffc8d..ae5d7b664d 100644 --- a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs @@ -578,6 +578,22 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { if unsafe { crate::symbol::js_is_symbol(value) != 0 } { return false_val; } + // A relinked class prototype (or a replaced instance prototype) + // can end the chain before `Object.prototype`. + if let Some(header) = super::super::prototype_chain::any_user_prototype_override() + .then(|| unsafe { crate::value::addr_class::try_read_gc_header(value_addr(value)) }) + .flatten() + { + if header.obj_type == crate::gc::GC_TYPE_OBJECT { + let obj_class_id = + unsafe { (*(value_addr(value) as *const ObjectHeader)).class_id }; + if let Some(answer) = + relinked_instance_chain_answer(value, obj_class_id, CLASS_ID_OBJECT) + { + return if answer { true_val } else { false_val }; + } + } + } // Covers every heap object, including a Date (now a NaN-boxed // `DateCell` pointer — #2089) and an Invalid Date. return true_val; @@ -804,6 +820,9 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // walk also follows the generic-origin edge, so a dynamic RHS holding a // generic class (`const C = Gen; x instanceof C`) matches an instance of // one of its specializations. + if let Some(answer) = relinked_instance_chain_answer(value, obj_class_id, class_id) { + return if answer { true_val } else { false_val }; + } if class_chain_reaches(obj_class_id, class_id) { return true_val; } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 5096353482..f4e91b43fb 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -89,6 +89,7 @@ mod class_gc_roots; mod class_handles; pub mod class_image; mod class_registry; +mod class_super_chain; pub(crate) mod class_value; #[cfg(test)] mod zeroed_cache_tests; diff --git a/crates/perry-runtime/src/object/property_key.rs b/crates/perry-runtime/src/object/property_key.rs index 0c03bcc90e..dbf436125e 100644 --- a/crates/perry-runtime/src/object/property_key.rs +++ b/crates/perry-runtime/src/object/property_key.rs @@ -328,18 +328,24 @@ pub unsafe extern "C" fn js_object_super_get(home: f64, key_value: f64, _receive js_object_get_property_key(proto, key_value) } -/// `super.prop` GET for class methods: walk the parent class chain from -/// `parent_class_id` for an accessor (getter) named `key` and invoke it with -/// `receiver` as `this` (lookup starts at the super prototype, but the getter -/// runs with the current `this`). If no getter is found, read a data property -/// off the parent prototype object (`B.prototype.x = 42` then `super.x`). -/// Refs class/super/in-{constructor,getter,methods,setter}. +/// `super.prop` GET for class methods whose home object belongs to class +/// `home_class_id`: `home.[[GetPrototypeOf]]().[[Get]](key, receiver)`. +/// +/// Where the runtime models that chain end to end (see +/// `class_super_base`), it reads it: a patched, deleted or accessor parent +/// member and a relinked home all apply. Otherwise it walks the declared +/// parent class chain for an accessor (getter) named `key` and invokes it +/// with `receiver` as `this` (lookup starts at the super prototype, but the +/// getter runs with the current `this`); if no getter is found, it reads a +/// data property off the parent prototype object. Refs +/// class/super/in-{constructor,getter,methods,setter}. #[no_mangle] -pub unsafe extern "C" fn js_super_accessor_get( - parent_class_id: u32, - key: f64, - receiver: f64, -) -> f64 { +pub unsafe extern "C" fn js_super_accessor_get(home_class_id: u32, key: f64, receiver: f64) -> f64 { + let parent_class_id = if home_class_id == 0 { + 0 + } else { + crate::object::get_parent_class_id(home_class_id).unwrap_or(0) + }; // #6935: `js_string_coerce` on an object key runs a user `toString` / // `valueOf` (and allocates even for primitive keys), so it can GC and // evacuate. `receiver` is dereferenced far below (`class_ref_id`, the @@ -359,6 +365,29 @@ pub unsafe extern "C" fn js_super_accessor_get( .ok() .map(|s| s.to_string()) }; + let base = if super::prototype_chain::any_user_prototype_override() { + super::class_super_chain::super_get_live_base(home_class_id, parent_class_id, receiver) + } else { + None + }; + if let Some(base) = base { + let base_bits = crate::value::JSValue::from_bits(base.to_bits()); + if base_bits.is_null() || base_bits.is_undefined() { + let name = key_name.as_deref().unwrap_or("").as_bytes(); + crate::error::js_throw_type_error_property_access( + base_bits.is_null() as u32, + name.as_ptr(), + name.len(), + ); + } + return crate::proxy::js_reflect_get( + base, + key_handle.get_nanbox_f64(), + f64::from_bits(receiver_handle.get_heap_word_u64()), + ); + } + // `class_super_base` can allocate. + let receiver = f64::from_bits(receiver_handle.get_heap_word_u64()); // Static-context super (`super.x` inside a `static` method/getter): the // receiver is the class constructor (a ClassRef), so resolve against the // PARENT's static side — a static getter, then a static data field — From d7d1dd23b72a3e0db953e7e2ee5942e10f641778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 12:04:24 +0000 Subject: [PATCH 2/4] Test super and instanceof on a patched or relinked class chain --- crates/perry/tests/class_super_relink.rs | 114 +++++++ .../one_shape_class_super_relink/check.sh | 16 + .../one_shape_class_super_relink/expected.txt | 60 ++++ .../one_shape_class_super_relink/main.ts | 309 ++++++++++++++++++ .../static_super.expected.txt | 9 + .../static_super.ts | 42 +++ 6 files changed, 550 insertions(+) create mode 100644 crates/perry/tests/class_super_relink.rs create mode 100755 tests/fixtures/one_shape_class_super_relink/check.sh create mode 100644 tests/fixtures/one_shape_class_super_relink/expected.txt create mode 100644 tests/fixtures/one_shape_class_super_relink/main.ts create mode 100644 tests/fixtures/one_shape_class_super_relink/static_super.expected.txt create mode 100644 tests/fixtures/one_shape_class_super_relink/static_super.ts diff --git a/crates/perry/tests/class_super_relink.rs b/crates/perry/tests/class_super_relink.rs new file mode 100644 index 0000000000..1b927edbf8 --- /dev/null +++ b/crates/perry/tests/class_super_relink.rs @@ -0,0 +1,114 @@ +//! `super.m` is a property lookup on the home object's CURRENT +//! `[[Prototype]]` (`C.prototype`'s in an instance method, `C`'s in a static +//! one) with `this` as the receiver, and `inst instanceof K` walks inst's live +//! chain for `K.prototype`. The program and node's output are the +//! `one_shape_class_super_relink` fixture. +//! +//! `super.m()` called the body the compiler resolved along the declared +//! `extends` chain, and its runtime fallback resolved through the declared +//! vtable, so a patched, deleted or accessor parent method never applied +//! (#11760). `typeof super.m` and `super.m` as a value were the declared +//! method's wrapper, `super.x` read the declared parent's prototype even +//! after a relink, and a static `super.s()` resolved an instance method of +//! the same name. `instanceof` walked declared class ids, and +//! `C.prototype.__proto__ = X` was compiled as a prototype-method install +//! named `__proto__` (#11765). + +use std::path::PathBuf; +use std::process::Command; + +const SOURCE: &str = include_str!("../../../tests/fixtures/one_shape_class_super_relink/main.ts"); +const EXPECTED: &str = + include_str!("../../../tests/fixtures/one_shape_class_super_relink/expected.txt"); + +const STATIC_SOURCE: &str = + include_str!("../../../tests/fixtures/one_shape_class_super_relink/static_super.ts"); +const STATIC_EXPECTED: &str = + include_str!("../../../tests/fixtures/one_shape_class_super_relink/static_super.expected.txt"); + +/// Compiles `source` into `dir` and returns the executable. +fn compile(dir: &std::path::Path, source: &str) -> PathBuf { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + let compile = Command::new(PathBuf::from(env!("CARGO_BIN_EXE_perry"))) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .env("PERRY_NO_CACHE", "1") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstderr:\n{}", + String::from_utf8_lossy(&compile.stderr) + ); + output +} + +#[test] +fn static_super_is_not_resolved_from_instance_methods() { + let dir = tempfile::tempdir().expect("tempdir"); + let output = compile(dir.path(), STATIC_SOURCE); + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + run.status.success(), + "binary failed ({:?})\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stderr) + ); + let wrong: Vec = STATIC_EXPECTED + .lines() + .zip(stdout.lines()) + .filter(|(want, got)| want != got) + .map(|(want, got)| format!("got `{got}`, node `{want}`")) + .collect(); + assert!( + wrong.is_empty() && STATIC_EXPECTED.lines().count() == stdout.lines().count(), + "{wrong:#?}\n{stdout}" + ); +} + +#[test] +fn super_and_instanceof_follow_the_live_prototype_chain() { + let dir = tempfile::tempdir().expect("tempdir"); + let output = compile(dir.path(), SOURCE); + // Runtime trip counts: the primed cases change the chain mid-loop, and a + // fixed small loop would be unrolled. The output does not depend on n. + for n in ["40", "41"] { + let run = Command::new(&output) + .arg(n) + .current_dir(dir.path()) + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_POISON_FROMSPACE", "1") + .output() + .expect("run compiled binary"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + run.status.success(), + "n={n}: binary failed ({:?})\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stderr) + ); + let mut wrong: Vec = EXPECTED + .lines() + .zip(stdout.lines()) + .filter(|(want, got)| want != got) + .map(|(want, got)| format!("got `{got}`, node `{want}`")) + .collect(); + if EXPECTED.lines().count() != stdout.lines().count() { + wrong.push(format!( + "{} lines, node {}", + stdout.lines().count(), + EXPECTED.lines().count() + )); + } + assert!(wrong.is_empty(), "n={n}: {wrong:#?}\n{stdout}"); + } +} diff --git a/tests/fixtures/one_shape_class_super_relink/check.sh b/tests/fixtures/one_shape_class_super_relink/check.sh new file mode 100755 index 0000000000..7d11a3896f --- /dev/null +++ b/tests/fixtures/one_shape_class_super_relink/check.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# `super.m` / `super.x` read the home object's current [[Prototype]], +# `instanceof` walks the live chain, and `C.prototype.__proto__ = X` relinks +# like Object.setPrototypeOf, after patches, deletes, accessors and relinks +# (expected.txt is node 26.5.1's output). Two trip counts, under forced +# evacuation. +set -euo pipefail +binary=$(realpath "${1:?pass the compiled fixture executable}") +fixture_dir=$(cd "$(dirname "$0")" && pwd) +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +for n in 40 41; do + PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 "$binary" "$n" > "$tmp_dir/out" 2> "$tmp_dir/err" + cmp "$fixture_dir/expected.txt" "$tmp_dir/out" +done +echo ok diff --git a/tests/fixtures/one_shape_class_super_relink/expected.txt b/tests/fixtures/one_shape_class_super_relink/expected.txt new file mode 100644 index 0000000000..878fe1e438 --- /dev/null +++ b/tests/fixtures/one_shape_class_super_relink/expected.txt @@ -0,0 +1,60 @@ +call-base ok:PB.m +call-after-patch ok:patched:true +own-m-unchanged PC.m +call-primed-patch ok:QB.m / ok:QB.patched +call-via-getter ok:getter:true +typeof-via-getter function +call-before-delete ok:DB.m +call-after-delete TypeError +typeof-after-delete undefined +call-primed-relink ok:RB.m / ok:RX.m +call-missing TypeError +call-private-tripped ok:VB.m7 +typeof-base function +value-is-proto-m true +typeof-primed-patch ok:function / ok:number +value-after-patch 5 +data-primed ok:undefined / ok:42 +getter-receiver XB.g:c +getter-redefined XB.g2:c +getter-after-relink XY.g:c +data-after-relink undefined +get-on-null-home TypeError +static-base ok:SB.s:SC +static-after-patch ok:SB.s-patched:SC +static-data SB.k +static-after-relink ok:SD.s +static-typeof-after-relink function +static-data-after-relink SD.k +static-inst-only-call TypeError +static-inst-only-typeof undefined +static-inst-only-value undefined +static-prefers-static ok:StB.static-s +inst-before true,false +inst-old-parent false +inst-new-chain true,true,true,true +inst-subclass true,true,false,true +inst-dynamic-rhs false +inst-fresh-instance false,true +inst-hasInstance true,false +inst-null false,true,false +inst-plain false,true +inst-relinked-back true +inst-primed ok:true,false / ok:false,true +inst-own-proto false,false,true +inst-via-plain-mid false,true,true,true +inst-to-null-proto-object false,true,false +inst-mid-relink false,true,true,true +inst-mid-relink-dynamic false,true +inst-own-proto-plain false,true,true +inst-own-proto-null false,false,false +inst-primed-object ok:true,false,true / ok:false,true,true +dunder-direct true,false,UE,UE,false,true +dunder-alias true,UE +dunder-computed true +dunder-null ,undefined,false +dunder-ignored true,UB +dunder-primed ok:UE:false / ok:UB:true +plain-super 8 +native-base got:5 +map-base 101 diff --git a/tests/fixtures/one_shape_class_super_relink/main.ts b/tests/fixtures/one_shape_class_super_relink/main.ts new file mode 100644 index 0000000000..e9393dae64 --- /dev/null +++ b/tests/fixtures/one_shape_class_super_relink/main.ts @@ -0,0 +1,309 @@ +// `super.name` is a property lookup on the home object's CURRENT +// [[Prototype]] (`C.prototype`'s for an instance method, `C`'s for a static +// one) with `this` as the receiver. A patched, deleted or accessor parent +// member, a relinked `C.prototype` and a relinked `C` all apply, for calls, +// `typeof super.m`, `super.m` as a value and `super.x` reads. +// `inst instanceof K` is OrdinaryHasInstance (or K[Symbol.hasInstance]): it +// walks inst's LIVE chain for K.prototype. And `C.prototype.__proto__ = X` +// is the Object.prototype accessor's setter: a relink exactly like +// `Object.setPrototypeOf(C.prototype, X)`. +// +// Class names are unique per block. Each line is `name value`, compared with +// node 26.5.1. "Primed" cases change the chain mid-loop at a parameter +// receiver with an argv trip count, so the site ran before the change. +import { EventEmitter } from "events"; +const N = Number(process.argv[2] ?? "40"); +const H = N >> 1; +function show(name: string, v: any): void { + console.log(name + " " + String(v)); +} +function tryIt(f: () => any): string { + try { + return "ok:" + String(f()); + } catch (e) { + return (e instanceof TypeError) ? "TypeError" : "other:" + String(e); + } +} +// Runs `f(o)` n times, applying `change` before iteration `at`; returns the +// first and last results. +function primed(o: any, n: number, at: number, change: () => void, f: (o: any) => any): string { + let before = ""; + let after = ""; + for (let i = 0; i < n; i++) { + if (i === at) change(); + const v = tryIt(() => f(o)); + if (i < at) before = v; else after = v; + } + return before + " / " + after; +} + +// ---- super.m() calls ------------------------------------------------------- +{ + class PB { m() { return "PB.m"; } } + class PC extends PB { m() { return "PC.m"; } callM() { return super.m(); } } + const c = new PC(); + show("call-base", tryIt(() => c.callM())); + (PB.prototype as any).m = function (this: any) { return "patched:" + (this === c); }; + show("call-after-patch", tryIt(() => c.callM())); + show("own-m-unchanged", c.m()); +} +{ + class QB { m() { return "QB.m"; } } + class QC extends QB { callM() { return super.m(); } } + show("call-primed-patch", primed(new QC(), N, H, + () => { (QB.prototype as any).m = () => "QB.patched"; }, (o) => o.callM())); +} +{ + class GB { m() { return "GB.m"; } } + class GC extends GB { callM() { return super.m(); } typeM() { return typeof super.m; } } + const g = new GC(); + Object.defineProperty(GB.prototype, "m", { + get(this: any) { const self = this; return () => "getter:" + (self === g); }, + configurable: true, + }); + show("call-via-getter", tryIt(() => g.callM())); + show("typeof-via-getter", g.typeM()); +} +{ + class DB { m() { return "DB.m"; } } + class DC extends DB { callM() { return super.m(); } typeM() { return typeof super.m; } } + const d = new DC(); + show("call-before-delete", tryIt(() => d.callM())); + delete (DB.prototype as any).m; + show("call-after-delete", tryIt(() => d.callM())); + show("typeof-after-delete", d.typeM()); +} +{ + class RB { m() { return "RB.m"; } } + class RX { m() { return "RX.m"; } } + class RC extends RB { callM() { return super.m(); } } + show("call-primed-relink", primed(new RC(), N, H, + () => { Object.setPrototypeOf(RC.prototype, RX.prototype); }, (o) => o.callM())); +} +{ + class MB { m() { return "MB.m"; } } + class MC extends MB { nope() { return super.zz(); } } + show("call-missing", tryIt(() => new MC().nope())); +} +{ + // A patch of the same name on an unrelated class trips the per-name guard; + // the parent's private state must still work through the runtime path. + class VB { #v = 7; m() { return "VB.m" + this.#v; } } + class VC extends VB { m() { return "VC"; } callM() { return super.m(); } } + class Unrelated { m() { return 0; } } + (Unrelated.prototype as any).m = () => 1; + show("call-private-tripped", tryIt(() => new VC().callM())); +} + +// ---- super property gets --------------------------------------------------- +{ + class TB { m() { return 1; } } + class TC extends TB { typeM() { return typeof super.m; } valueM() { return super.m; } } + const t = new TC(); + show("typeof-base", t.typeM()); + show("value-is-proto-m", t.valueM() === TB.prototype.m); + show("typeof-primed-patch", primed(t, N, H, + () => { (TB.prototype as any).m = 5; }, (o) => o.typeM())); + show("value-after-patch", String(t.valueM())); +} +{ + class XB { get g() { return "XB.g:" + (this as any).tag; } } + class XC extends XB { tag = "c"; readX() { return super.x; } readG() { return super.g; } } + const x = new XC(); + show("data-primed", primed(x, N, H, () => { (XB.prototype as any).x = 42; }, (o) => o.readX())); + show("getter-receiver", x.readG()); + Object.defineProperty(XB.prototype, "g", { get(this: any) { return "XB.g2:" + this.tag; }, configurable: true }); + show("getter-redefined", x.readG()); + class XY { get g() { return "XY.g:" + (this as any).tag; } } + Object.setPrototypeOf(XC.prototype, XY.prototype); + show("getter-after-relink", x.readG()); + show("data-after-relink", String(x.readX())); + Object.setPrototypeOf(XC.prototype, null); + show("get-on-null-home", tryIt(() => x.readG())); +} + +// ---- static super ---------------------------------------------------------- +{ + class SB { static s() { return "SB.s:" + (this as any).name; } s() { return "SB.inst-s"; } } + class SC extends SB { + static callS() { return super.s(); } + static typeS() { return typeof super.s; } + static readK() { return super.k; } + } + show("static-base", tryIt(() => SC.callS())); + (SB as any).s = function (this: any) { return "SB.s-patched:" + this.name; }; + show("static-after-patch", tryIt(() => SC.callS())); + (SB as any).k = "SB.k"; + show("static-data", SC.readK()); + class SD { static s() { return "SD.s"; } static k = "SD.k"; } + Object.setPrototypeOf(SC, SD); + show("static-after-relink", tryIt(() => SC.callS())); + show("static-typeof-after-relink", SC.typeS()); + show("static-data-after-relink", SC.readK()); +} + +{ + // The parent has an INSTANCE method of the name and no static one: in a + // static member, super.m reads the parent constructor, so it is absent. + class StB { onlyInst() { return "StB.inst-m"; } static both() { return "StB.static-s"; } both() { return "StB.inst-s"; } } + class StC extends StB { + static callOnly() { return super.onlyInst(); } + static callBoth() { return super.both(); } + static typeOnly() { return typeof super.onlyInst; } + static valueOnly() { return super.onlyInst; } + } + show("static-inst-only-call", tryIt(() => StC.callOnly())); + show("static-inst-only-typeof", StC.typeOnly()); + show("static-inst-only-value", StC.valueOnly()); + show("static-prefers-static", tryIt(() => StC.callBoth())); +} + +// ---- instanceof ------------------------------------------------------------ +{ + class IB { m() { return "IB"; } } + class ID { m() { return "ID"; } } + class IE extends ID {} + class IC extends IB {} + class ISub extends IC {} + const c = new IC(); + const s = new ISub(); + show("inst-before", [c instanceof IB, c instanceof ID].join(",")); + Object.setPrototypeOf(IC.prototype, IE.prototype); + show("inst-old-parent", c instanceof IB); + show("inst-new-chain", [c instanceof IE, c instanceof ID, c instanceof IC, c instanceof Object].join(",")); + show("inst-subclass", [s instanceof ISub, s instanceof IC, s instanceof IB, s instanceof ID].join(",")); + const K: any = Math.random() < 2 ? IB : ID; + show("inst-dynamic-rhs", c instanceof K); + show("inst-fresh-instance", [new IC() instanceof IB, new IC() instanceof IE].join(",")); +} +{ + class HB {} + class HC extends HB {} + const h = new HC(); + class HK { static [Symbol.hasInstance](v: any) { return v === h; } } + Object.setPrototypeOf(HC.prototype, HK.prototype); + show("inst-hasInstance", [h instanceof HK, new HC() instanceof HK].join(",")); +} +{ + class NB {} + class NC extends NB {} + const n = new NC(); + Object.setPrototypeOf(NC.prototype, null); + show("inst-null", [n instanceof NB, n instanceof NC, n instanceof Object].join(",")); + const plain = { tag: 1 }; + class OC extends NB {} + const o = new OC(); + Object.setPrototypeOf(OC.prototype, plain); + show("inst-plain", [o instanceof NB, o instanceof Object].join(",")); + Object.setPrototypeOf(OC.prototype, NB.prototype); + show("inst-relinked-back", o instanceof NB); +} +{ + class LB {} + class LD {} + class LC extends LB {} + const l = new LC(); + show("inst-primed", primed(l, N, H, () => { Object.setPrototypeOf(LC.prototype, LD.prototype); }, + (o) => [o instanceof LB, o instanceof LD].join(","))); +} +{ + class WB {} + class WD {} + class WC extends WB {} + const w = new WC(); + Object.setPrototypeOf(w, WD.prototype); + show("inst-own-proto", [w instanceof WC, w instanceof WB, w instanceof WD].join(",")); +} + +{ + class MA {} + class MB extends MA {} + class MD {} + const mid = Object.create(MD.prototype); + const m = new MB(); + Object.setPrototypeOf(MB.prototype, mid); + show("inst-via-plain-mid", [m instanceof MA, m instanceof MD, m instanceof MB, m instanceof Object].join(",")); +} +{ + class QA {} + class QB extends QA {} + const q = new QB(); + Object.setPrototypeOf(QB.prototype, Object.create(null)); + show("inst-to-null-proto-object", [q instanceof QA, q instanceof QB, q instanceof Object].join(",")); +} +{ + class RA {} + class RB extends RA {} + class RC extends RB {} + class RX {} + const r = new RC(); + Object.setPrototypeOf(RB.prototype, RX.prototype); + show("inst-mid-relink", [r instanceof RA, r instanceof RB, r instanceof RC, r instanceof RX].join(",")); + const dyn: any = Math.random() < 2 ? RA : RX; + const dyn2: any = Math.random() < 2 ? RX : RA; + show("inst-mid-relink-dynamic", [r instanceof dyn, r instanceof dyn2].join(",")); +} +{ + class YA {} + class YB extends YA {} + const y = new YB(); + Object.setPrototypeOf(y, Object.create(YA.prototype)); + show("inst-own-proto-plain", [y instanceof YB, y instanceof YA, y instanceof Object].join(",")); + const y2 = new YB(); + Object.setPrototypeOf(y2, null); + show("inst-own-proto-null", [y2 instanceof YB, y2 instanceof YA, y2 instanceof Object].join(",")); +} +{ + class ZA {} + class ZB extends ZA {} + class ZX {} + const z = new ZB(); + show("inst-primed-object", primed(z, N, H, () => { Object.setPrototypeOf(ZB.prototype, ZX.prototype); }, + (o) => [o instanceof ZA, o instanceof ZX, o instanceof Object].join(","))); +} + +// ---- the __proto__ setter on a class prototype ------------------------------ +{ + class UB { m() { return "UB"; } } + class UE { m() { return "UE"; } } + class U1 extends UB { callM() { return super.m(); } } + class U2 extends UB {} + class U3 extends UB {} + class U4 extends UB {} + class U5 extends UB {} + const u1 = new U1(); + (U1.prototype as any).__proto__ = UE.prototype; + show("dunder-direct", [Object.getPrototypeOf(U1.prototype) === UE.prototype, + Object.prototype.hasOwnProperty.call(U1.prototype, "__proto__"), u1.m(), u1.callM(), + u1 instanceof UB, u1 instanceof UE].join(",")); + const p2: any = U2.prototype; + p2.__proto__ = UE.prototype; + show("dunder-alias", [Object.getPrototypeOf(U2.prototype) === UE.prototype, (new U2() as any).m()].join(",")); + (U3.prototype as any)["__proto__"] = UE.prototype; + show("dunder-computed", Object.getPrototypeOf(U3.prototype) === UE.prototype); + const u4 = new U4(); + (U4.prototype as any).__proto__ = null; + show("dunder-null", [Object.getPrototypeOf(U4.prototype), typeof (u4 as any).m, u4 instanceof UB].join(",")); + (U5.prototype as any).__proto__ = 5; + show("dunder-ignored", [Object.getPrototypeOf(U5.prototype) === UB.prototype, (new U5() as any).m()].join(",")); + show("dunder-primed", primed(new U2(), N, H, () => { (U2.prototype as any).__proto__ = UB.prototype; }, + (o) => o.m() + ":" + (o instanceof UB))); +} + +// ---- untouched and native bases still resolve ------------------------------- +{ + class AB { m(x: number) { return x + 1; } } + class AC extends AB { m(x: number) { return super.m(x) * 2; } } + show("plain-super", new AC().m(3)); + class Bus extends EventEmitter { emit(ev: string, ...a: any[]) { return super.emit(ev, ...a); } } + class Logged extends Bus { emit(ev: string, x: any) { return super.emit(ev, x); } } + const l = new Logged(); + let got = ""; + l.on("x", (v: any) => { got = "got:" + v; }); + l.emit("x", 5); + show("native-base", got); + class MM extends Map { get(k: string) { return (super.get(k) ?? 0) + 100; } } + const mm = new MM(); + mm.set("a", 1); + show("map-base", mm.get("a")); +} diff --git a/tests/fixtures/one_shape_class_super_relink/static_super.expected.txt b/tests/fixtures/one_shape_class_super_relink/static_super.expected.txt new file mode 100644 index 0000000000..95ed2672cc --- /dev/null +++ b/tests/fixtures/one_shape_class_super_relink/static_super.expected.txt @@ -0,0 +1,9 @@ +call-inst-only TypeError +call-both ok:StB.static-both:StC +call-only-static ok:StB.static-only:StC +typeof-inst-only undefined +typeof-both function +value-inst-only undefined +value-both true +instance-side StB.inst-both +instance-typeof-static-only undefined diff --git a/tests/fixtures/one_shape_class_super_relink/static_super.ts b/tests/fixtures/one_shape_class_super_relink/static_super.ts new file mode 100644 index 0000000000..86f586f87b --- /dev/null +++ b/tests/fixtures/one_shape_class_super_relink/static_super.ts @@ -0,0 +1,42 @@ +// A program with no prototype surgery: `super.name` in a static member names +// the parent CONSTRUCTOR's member, which the compiler must not resolve from +// the parent's INSTANCE method tables, for calls, `typeof super.m` and +// `super.m` as a value. (A program that relinks or patches a prototype sends +// these through the runtime lookup, so one_shape_class_super_relink cannot +// see the compile-time route.) Expected output is node 26.5.1's. +function show(name: string, v: any): void { + console.log(name + " " + String(v)); +} +function tryIt(f: () => any): string { + try { + return "ok:" + String(f()); + } catch (e) { + return (e instanceof TypeError) ? "TypeError" : "other:" + String(e); + } +} +class StB { + onlyInst() { return "StB.inst-only"; } + static both() { return "StB.static-both:" + (this as any).name; } + both() { return "StB.inst-both"; } + static onlyStatic() { return "StB.static-only:" + (this as any).name; } +} +class StC extends StB { + static callOnlyInst() { return super.onlyInst(); } + static callBoth() { return super.both(); } + static callOnlyStatic() { return super.onlyStatic(); } + static typeOnlyInst() { return typeof super.onlyInst; } + static typeBoth() { return typeof super.both; } + static valueOnlyInst() { return super.onlyInst; } + static valueBoth() { return super.both === StB.both; } + callBothInst() { return super.both(); } + typeOnlyStatic() { return typeof super.onlyStatic; } +} +show("call-inst-only", tryIt(() => StC.callOnlyInst())); +show("call-both", tryIt(() => StC.callBoth())); +show("call-only-static", tryIt(() => StC.callOnlyStatic())); +show("typeof-inst-only", StC.typeOnlyInst()); +show("typeof-both", StC.typeBoth()); +show("value-inst-only", StC.valueOnlyInst()); +show("value-both", StC.valueBoth()); +show("instance-side", new StC().callBothInst()); +show("instance-typeof-static-only", new StC().typeOnlyStatic()); From a1afe41c853df377a9b6aee29d409f1124bf0849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 12:04:24 +0000 Subject: [PATCH 3/4] changelog: super and instanceof follow the live prototype chain (PR number placeholder) --- changelog.d/00000-class-super-instanceof-relink.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/00000-class-super-instanceof-relink.md diff --git a/changelog.d/00000-class-super-instanceof-relink.md b/changelog.d/00000-class-super-instanceof-relink.md new file mode 100644 index 0000000000..1480e6d326 --- /dev/null +++ b/changelog.d/00000-class-super-instanceof-relink.md @@ -0,0 +1,9 @@ +`super.m()`, `typeof super.m`, `super.m` as a value and `super.x` now read the +parent prototype as it is when they run, so a patched, deleted or getter-backed +parent method applies, and so does `Object.setPrototypeOf` on the class +prototype or, in a static method, on the class itself. A static `super.s()` no +longer calls an instance method of the same name. After a class prototype is +relinked, `instanceof` follows the new chain, including `instanceof Object` and +an instance whose own prototype was replaced. `C.prototype.__proto__ = X` now +relinks the prototype like `Object.setPrototypeOf` instead of creating an own +property named `__proto__`. From b4705c71a2372848593f2ba8a97367da75821074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 04:44:03 +0000 Subject: [PATCH 4/4] changelog: name the super and instanceof entry for PR 11777 --- ...nstanceof-relink.md => 11777-class-super-instanceof-relink.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{00000-class-super-instanceof-relink.md => 11777-class-super-instanceof-relink.md} (100%) diff --git a/changelog.d/00000-class-super-instanceof-relink.md b/changelog.d/11777-class-super-instanceof-relink.md similarity index 100% rename from changelog.d/00000-class-super-instanceof-relink.md rename to changelog.d/11777-class-super-instanceof-relink.md