Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/perry-codegen/src/expr/static_field_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// 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);
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/runtime_decls/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
41 changes: 37 additions & 4 deletions crates/perry-hir/src/lower/lower_expr/arm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <expr>`) 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 <classObjectValue>()` can run the instance-field
// initializers / constructor body with the right environment.
Expand All @@ -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<Expr> = 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));
}
Expand Down
9 changes: 5 additions & 4 deletions crates/perry-runtime/src/object/class_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,11 @@ pub(crate) use dispatch::{
pub(crate) use parent_static::{
call_registered_static_method, call_static_method, class_chain_has_instance_accessor,
class_has_instance_getter, class_has_own_static_method, class_has_symbol_member_in_chain,
class_instance_setter_apply, class_method_bind_length, class_own_symbol_member_keys,
class_static_accessor_getter_value, class_static_accessor_setter_apply,
class_symbol_getter_value, class_symbol_setter_apply, get_parent_class_id,
lookup_class_symbol_method_in_chain, lookup_static_method_in_chain, register_class,
class_instance_setter_apply, class_method_bind_length, class_object_own_field_bytes,
class_object_pinned_parent, class_own_symbol_member_keys, class_static_accessor_getter_value,
class_static_accessor_setter_apply, class_symbol_getter_value, class_symbol_setter_apply,
get_parent_class_id, lookup_class_symbol_method_in_chain, lookup_static_method_in_chain,
register_class,
};
pub use parent_static::{
is_class_object_ptr, is_class_object_value, is_registered_class_prototype_object,
Expand Down
113 changes: 113 additions & 0 deletions crates/perry-runtime/src/object/class_registry/parent_static.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,119 @@ pub extern "C" fn js_register_class_parent_dynamic(class_id: u32, parent_value:
}
}

/// Own-property key under which a per-evaluation class object
/// (`ClassExprFresh`) pins ITS OWN parent class value. See
/// `js_class_object_pin_parent`.
pub(crate) const CLASS_OBJECT_PARENT_KEY: &str = "__perry_parent_class";

/// #6438: pin THIS evaluation's parent onto a per-evaluation class object.
///
/// `CLASS_DYNAMIC_PARENT_VALUE` is keyed by the child's **class id**, i.e. by
/// the compile-time template — so a class expression evaluated N times with a
/// DIFFERENT parent each time (effect's
/// `class DeclareClass extends make(ast) { … }`, where `make(ast)` returns a
/// fresh class per call) collapses to last-wins: every DeclareClass would walk
/// to the LAST `make(ast)` and read that evaluation's `static ast`.
///
/// Codegen calls this immediately after `RegisterClassParentDynamic` in the
/// same lowered Sequence, so the table still holds *this* evaluation's parent.
/// Copy it onto the class object as an own property; later evaluations
/// overwrite the table but each object already carries its own edge. Same
/// write-right-before-use shape the capture snapshot already uses.
///
/// A no-parent class expression pins nothing (the getter yields undefined or a
/// static ClassRef fallback, which the field walk treats as "no own edge").
#[no_mangle]
pub extern "C" fn js_class_object_pin_parent(obj: i64, template_class_id: u32) {
const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001;
if obj == 0 || template_class_id == 0 {
return;
}
let parent = js_get_dynamic_parent_value(template_class_id);
if parent.to_bits() == TAG_UNDEFINED {
return;
}
let key_bytes = CLASS_OBJECT_PARENT_KEY.as_bytes();
let key = crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32);
crate::object::js_object_set_field_by_name(
obj as *mut crate::object::ObjectHeader,
key,
parent,
);
}

/// Read back the parent pinned by `js_class_object_pin_parent`, or `None` when
/// this class object has no own parent edge.
///
/// Scans the keys array DIRECTLY rather than going through the by-name read
/// path: that path consults the pinned parent itself (that is the whole point
/// of the edge), so reading the marker through it re-enters this function and
/// recurses until the stack guard page — an immediate SIGSEGV. A re-entrancy
/// flag is not an option either: it would abort the legitimate
/// parent→grandparent walk of a multi-level factory chain. An own-only scan has
/// neither problem, and it is cheap: the pinned key sits among a handful of own
/// statics on a class object.
pub(crate) fn class_object_pinned_parent(obj: *const crate::object::ObjectHeader) -> Option<f64> {
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<f64> {
const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001;
// `is_valid_obj_ptr` alone does not reject the fetch/zlib/proxy handle
// bands, and dereferencing a handle id as an ObjectHeader segfaults on Linux
// (macOS hides it). Gate on `is_above_handle_band` first — a real class
// object is always a heap allocation above the band.
if obj.is_null()
|| !crate::value::addr_class::is_above_handle_band(obj as usize)
|| !crate::object::is_valid_obj_ptr(obj as *const u8)
{
return None;
}
unsafe {
let keys = (*obj).keys_array;
if keys.is_null() {
return None;
}
let len = (*keys).length;
for i in 0..len {
let k = crate::array::js_array_get_f64(keys, i);
let sp = crate::value::js_get_string_pointer_unified(k) as *const crate::StringHeader;
if sp.is_null() {
continue;
}
let blen = (*sp).byte_len as usize;
if blen != want.len() {
continue;
}
let bytes = std::slice::from_raw_parts(
(sp as *const u8).add(std::mem::size_of::<crate::StringHeader>()),
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 <runtime-value>` body uses this so the
Expand Down
46 changes: 46 additions & 0 deletions crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<crate::StringHeader>());
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;
Expand Down
43 changes: 41 additions & 2 deletions crates/perry-runtime/src/object/native_call_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Comment on lines +1348 to +1378

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Stale pointers due to GC during method resolution and rebinding.

Both changed paths invoke closure_get_dynamic_prop (which can run prototype getters) and clone_closure_rebind_this (which allocates closures). These operations can trigger garbage collection. Because the conservative stack scanner does not pin NaN-boxed f64 values or the args_ptr buffer (their tags hide the raw heap addresses), the referents can be relocated, leaving the unrooted locals stale and leading to memory corruption.

  • crates/perry-runtime/src/object/native_call_method.rs#L1348-L1378: use object_handle.get_nanbox_f64() instead of the unrooted recv_bits, and refreshed_args().as_ptr() instead of args_ptr. Re-read the receiver handle both before and after the allocation.
  • crates/perry-runtime/src/object/native_call_method.rs#L1077-L1088: use object_handle.get_nanbox_f64() instead of the unrooted object, and refreshed_args().as_ptr() instead of args_ptr. Re-read the receiver handle both before and after the allocation.
🐛 Proposed fixes

For lines 1348-1378:

                 // "Cannot convert undefined or null to object" out of
                 // `Object.keys` — killing `HttpApi` construction at module init.
+                let current_recv = object_handle.get_nanbox_f64();
                 let bound = crate::closure::clone_closure_rebind_this(
                     dyn_val.to_bits(),
-                    f64::from_bits(recv_bits),
+                    current_recv,
                 );
-                let prev_this = IMPLICIT_THIS.with(|c| c.replace(recv_bits));
+                let current_recv_post = object_handle.get_nanbox_f64();
+                let prev_this = IMPLICIT_THIS.with(|c| c.replace(current_recv_post.to_bits()));
+                let args = refreshed_args();
                 let result =
-                    crate::closure::js_native_call_value(f64::from_bits(bound), args_ptr, args_len);
+                    crate::closure::js_native_call_value(f64::from_bits(bound), args.as_ptr(), args.len());

For lines 1077-1088:

                 // (an object-literal method binds the literal) would otherwise
                 // win over IMPLICIT_THIS and leave `this` as the PROTO.
+                let current_obj = object_handle.get_nanbox_f64();
                 let bound = crate::closure::clone_closure_rebind_this(
                     dyn_val.to_bits(),
-                    f64::from_bits(object.to_bits()),
+                    current_obj,
                 );
-                let prev_this = IMPLICIT_THIS.with(|c| c.replace(object.to_bits()));
+                let current_obj_post = object_handle.get_nanbox_f64();
+                let prev_this = IMPLICIT_THIS.with(|c| c.replace(current_obj_post.to_bits()));
+                let args = refreshed_args();
                 let result =
-                    crate::closure::js_native_call_value(f64::from_bits(bound), args_ptr, args_len);
+                    crate::closure::js_native_call_value(f64::from_bits(bound), args.as_ptr(), args.len());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// #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);
// `#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 current_recv = object_handle.get_nanbox_f64();
let bound = crate::closure::clone_closure_rebind_this(
dyn_val.to_bits(),
current_recv,
);
let current_recv_post = object_handle.get_nanbox_f64();
let prev_this = IMPLICIT_THIS.with(|c| c.replace(current_recv_post.to_bits()));
let args = refreshed_args();
let result =
crate::closure::js_native_call_value(f64::from_bits(bound), args.as_ptr(), args.len());
📍 Affects 1 file
  • crates/perry-runtime/src/object/native_call_method.rs#L1348-L1378 (this comment)
  • crates/perry-runtime/src/object/native_call_method.rs#L1077-L1088
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/native_call_method.rs` around lines 1348 -
1378, Fix stale GC-sensitive values in both native method dispatch paths: at
crates/perry-runtime/src/object/native_call_method.rs:1348-1378 and :1077-1088,
use object_handle.get_nanbox_f64() instead of the unrooted receiver values and
refreshed_args().as_ptr() instead of args_ptr. In each path, re-read the
receiver handle immediately before and after allocation-triggering
closure_get_dynamic_prop/clone_closure_rebind_this operations, updating the
relevant dispatch logic while preserving existing behavior.

IMPLICIT_THIS.with(|c| c.set(prev_this));
return result;
}
Expand Down
15 changes: 15 additions & 0 deletions test-files/test_gap_class_expr_perf_eval_statics.ts
Original file line number Diff line number Diff line change
@@ -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
Loading