From 84584b41f6125282a16a7d43296cf2d1617c3781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 04:00:12 +0200 Subject: [PATCH 1/2] fix(runtime): a generic specialization answers with its generic's constructor and prototype entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new Gen()` is monomorphized into a separate class (`Gen$num`, `monomorph::mangle::generate_specialized_name`) with its own class id, and the instance is stamped with THAT id. TypeScript erases type arguments, so at runtime there is exactly one `Gen` — the specializations are an implementation detail that was leaking through the id-keyed surfaces. #7575 fixed `instanceof`, #7632 fixed `constructor.name`, #7762 fixed the two prototype-object registries. Two holes remained, both keyed on the raw id: 1. THE CONSTRUCTOR VALUE. `class_object_props`'s instance arm synthesized the class ref straight from `(*obj).class_id`, so `a.constructor !== Gen` and `a.constructor !== b.constructor`. #7632 made this WORSE before better: both report the name `Gen`, so two values printed identically and compared unequal. 2. THE PROPERTY LOOKUP CHAIN — the one #7762's prototype-object aliasing did not reach, and the more damaging of the pair. `lookup_prototype_method` walked the PARENT chain from the specialization's id, so a patch on `Gen.prototype` was invisible on a specialized instance: `Gen.prototype.tag = "G"` then `a.tag` gave `undefined` while `Object.getPrototypeOf(a) === Gen.prototype` reported `true`. The two edges disagreed about the same object. Both take the origin edge the other three surfaces already take. In the chain walk the generic is tried BEFORE the parent, because it is an alias rather than an ancestor — a specialization's parent chain is its generic's parent chain, so hopping to the parent first would walk past `Gen` and never come back. That also keeps the walk line-count-neutral, which `construct.rs` requires: it sits exactly at the 2000-line cap. METHOD DISPATCH IS DELIBERATELY NOT ALIASED. It runs off the per-class-id vtable, so each specialization keeps its own monomorphized bodies — the same boundary #7762 drew, and the reason this is not a `CLASS_REGISTRY` parent edge (that chain also resolves `super()` construction and would re-run the wrong constructor). `test-files/test_gap_generic_specialization_constructor_identity_7757.ts` is byte-identical to node. It pins the edge in the negative direction too: two different generics stay distinct, a specialized SUBCLASS reports the subclass rather than the base, and declared methods still dispatch per specialization. Fixes #7757 --- .../src/object/class_registry/construct.rs | 2 +- .../field_get_set/class_object_props.rs | 17 +++- ...pecialization_constructor_identity_7757.ts | 82 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_generic_specialization_constructor_identity_7757.ts diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 57356c1294..5bc9a6914d 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -1988,7 +1988,7 @@ pub(crate) fn lookup_prototype_method(class_id: u32, name: &str) -> Option return Some(f64::from_bits(bits)); } } - match get_parent_class_id(cid) { + match crate::object::class_generic_origin(cid).or_else(|| get_parent_class_id(cid)) { Some(p) if p != 0 && p != cid => { cid = p; depth += 1; diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index 1507efec92..bb44ee389f 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -54,7 +54,22 @@ pub(super) unsafe fn instance_constructor_value( if let Some(v) = own_data_field_by_name(obj, key) { return Some(v); } - let class_id = (*obj).class_id; + // #7757: a monomorphized specialization must present the GENERIC's + // reflective surface. `new Gen()` is stamped with `Gen$num`'s id + // (`monomorph::mangle::generate_specialized_name`), so every arm below + // answered with the specialization -- making `a.constructor !== Gen` and, + // worse after #7632 gave both the same display name, two constructors that + // PRINT identically and compare unequal. TypeScript erases type arguments: + // at runtime there is exactly one `Gen`. + // + // This is the third and last identity surface to take the same origin edge + // `instanceof` uses (#7575), the display name uses (#7632) and the two + // prototype registries use (#7762). METHOD DISPATCH is deliberately not + // aliased -- it runs off the per-class-id vtable, so each specialization + // keeps its own monomorphized bodies. + let class_id = crate::object::class_generic_origin((*obj).class_id) + .filter(|generic| is_class_id_registered(*generic)) + .unwrap_or((*obj).class_id); // #6530: a capture-carrying class has no ClassRef value — the // class VALUE is the per-evaluation class OBJECT registered at // `js_object_mark_class` time. Return that same object so diff --git a/test-files/test_gap_generic_specialization_constructor_identity_7757.ts b/test-files/test_gap_generic_specialization_constructor_identity_7757.ts new file mode 100644 index 0000000000..426271d5ec --- /dev/null +++ b/test-files/test_gap_generic_specialization_constructor_identity_7757.ts @@ -0,0 +1,82 @@ +// Perry monomorphizes a generic class: `new Gen()` is stamped with a +// SEPARATE class id (`monomorph::mangle::generate_specialized_name` → +// `Gen$num`). TypeScript erases type arguments, so at runtime there is exactly +// one `Gen`, one `Gen.prototype` and one `Gen.prototype.constructor` — the +// specializations are an implementation detail that must not reach any +// id-keyed identity or lookup surface. +// +// Three faces of the same leak were fixed one at a time: `instanceof` (#7575), +// `constructor.name` (#7632), and the two prototype-object registries (#7762). +// This file pins the two that remained — the CONSTRUCTOR VALUE, and the +// PROPERTY LOOKUP chain, which is the one that made a prototype patch +// invisible on a specialized instance. +// +// #7632 made the constructor case worse before it got better: two constructors +// that print identically and compare unequal is a harder failure to debug than +// an obviously-wrong name. + +class Gen { + v: T | undefined; + tell(): string { + return "gen"; + } +} + +const a = new Gen(); +const b = new Gen(); +const c = new Gen(); // never specialized + +// --- constructor identity --------------------------------------------------- +console.log("a===Gen:", a.constructor === Gen); +console.log("b===Gen:", b.constructor === Gen); +console.log("c===Gen:", c.constructor === Gen); +console.log("a===b:", a.constructor === b.constructor); +console.log("name:", (a.constructor as any).name); + +// The prototype edge and the constructor edge must AGREE. They disagreed for a +// release: `getPrototypeOf(a) === Gen.prototype` was already true while +// `a.constructor === Gen` was false. +console.log("proto===Gen.prototype:", Object.getPrototypeOf(a) === Gen.prototype); +console.log("protoA===protoB:", Object.getPrototypeOf(a) === Object.getPrototypeOf(b)); +console.log("a.ctor===proto.ctor:", a.constructor === (Object.getPrototypeOf(a) as any).constructor); + +// --- prototype LOOKUP, not just prototype identity -------------------------- +// A patch on `Gen.prototype` must be visible from a specialized instance. It +// was not: the instance's chain walk keyed on the specialization's id and never +// reached the generic's entries, so this read was `undefined` while +// `getPrototypeOf(a) === Gen.prototype` reported true. +(Gen.prototype as any).patched = "P"; +(Gen.prototype as any).patchedMethod = function (this: any) { + return "pm"; +}; +console.log("a.patched:", (a as any).patched, (b as any).patched, (c as any).patched); +console.log("a.patchedMethod():", (a as any).patchedMethod(), (b as any).patchedMethod()); + +// Declared methods still work — they dispatch off the per-class-id vtable, +// which is deliberately NOT aliased so each specialization keeps its own +// monomorphized bodies. +console.log("tell:", a.tell(), b.tell(), c.tell()); + +// --- the edge must not collapse everything into everything ------------------ +class Other { + w: T | undefined; +} +const o = new Other(); +console.log("o===Other:", o.constructor === Other); +console.log("o!==Gen:", o.constructor !== (Gen as any)); +console.log("a instanceof Gen:", a instanceof Gen, "| o instanceof Gen:", o instanceof Gen); + +// A specialization of a SUBCLASS reports the subclass, not the base: the origin +// edge is an alias for one class, not a walk up the parent chain. +class Base { + tag = "base"; +} +class Sub extends Base { + u: T | undefined; +} +const s = new Sub(); +console.log("s===Sub:", s.constructor === Sub, "| s!==Base:", s.constructor !== (Base as any)); +console.log("s instanceof Sub:", s instanceof Sub, "| s instanceof Base:", s instanceof Base); + +// Constructing through the reported constructor round-trips. +console.log("new a.ctor instanceof Gen:", new (a.constructor as any)() instanceof Gen); From 53f486989cea4292e2f385c40f2d8570db195a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 04:00:56 +0200 Subject: [PATCH 2/2] docs(changelog): add fragment for the #7757 specialization identity fix --- ...7826-generic-specialization-constructor.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 changelog.d/7826-generic-specialization-constructor.md diff --git a/changelog.d/7826-generic-specialization-constructor.md b/changelog.d/7826-generic-specialization-constructor.md new file mode 100644 index 0000000000..62167f5178 --- /dev/null +++ b/changelog.d/7826-generic-specialization-constructor.md @@ -0,0 +1,21 @@ +**A generic class specialization now answers with its generic's constructor, and sees its generic's prototype patches.** +`new Gen()` is monomorphized into a separate class (`Gen$num`) with its +own class id; TypeScript erases type arguments, so at runtime there is exactly +one `Gen`. After `instanceof` (#7575), `constructor.name` (#7632) and the two +prototype-object registries (#7762), two id-keyed holes remained. The instance +`.constructor` arm synthesized the class ref straight from the instance's class +id, so `a.constructor !== Gen` and `a.constructor !== b.constructor` — and since +#7632 gave both the display name `Gen`, they printed identically while comparing +unequal. Separately, and untouched by #7762's aliasing of the prototype +*objects*, the prototype-method chain walk started at the specialization's id +and followed only parent edges, so `Gen.prototype.tag = "G"` was invisible from a +specialized instance even though `Object.getPrototypeOf(a) === Gen.prototype` +reported true — the two edges disagreed about the same object. Both now take the +same generic-origin edge, with the generic tried *before* the parent because it +is an alias rather than an ancestor. Method dispatch is deliberately not aliased: +it runs off the per-class-id vtable, so each specialization keeps its own +monomorphized bodies. Covered by +`test-files/test_gap_generic_specialization_constructor_identity_7757.ts`, which +also pins the negative direction — distinct generics stay distinct, a specialized +subclass reports the subclass rather than its base, and `instanceof` still +discriminates. (#7757)