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) 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);