fix(runtime): a generic specialization answers with its generic's constructor and prototype entries - #7826
Conversation
…structor and prototype entries `new Gen<number>()` 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
📝 WalkthroughWalkthroughThe runtime now resolves specialized generic instances to their generic constructor and prototype while preserving specialized method dispatch. A regression test covers identity, inheritance, ChangesGeneric specialization identity
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/perry-runtime/src/object/class_registry/construct.rs (1)
1991-1995: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winDo not consume the parent-depth budget for the generic-origin alias.
Line 1991 adds one alias hop before parent traversal. Line 1994 counts that hop against the 32-class limit. For
Specialization -> Generic -> Parent1 ... Parent31, a property onParent31is no longer reachable.Track generic-origin hops separately, or reserve one additional bounded hop for this alias. Keep cycle protection. Add a regression case with a specialized class and a 31-level inherited prototype chain.
🤖 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/class_registry/construct.rs` around lines 1991 - 1995, Update the traversal around class_generic_origin and get_parent_class_id so selecting a generic-origin alias does not increment the parent-depth budget; track alias hops separately or allow one additional bounded alias hop while retaining cycle protection. Add a regression case covering a specialized class with a 31-level inherited prototype chain and verifying the deepest parent property remains reachable.
🤖 Prompt for all review comments with 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.
Outside diff comments:
In `@crates/perry-runtime/src/object/class_registry/construct.rs`:
- Around line 1991-1995: Update the traversal around class_generic_origin and
get_parent_class_id so selecting a generic-origin alias does not increment the
parent-depth budget; track alias hops separately or allow one additional bounded
alias hop while retaining cycle protection. Add a regression case covering a
specialized class with a 31-level inherited prototype chain and verifying the
deepest parent property remains reachable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 6f5b3082-dc0c-4a76-a649-c465c6d79664
📒 Files selected for processing (4)
changelog.d/7826-generic-specialization-constructor.mdcrates/perry-runtime/src/object/class_registry/construct.rscrates/perry-runtime/src/object/field_get_set/class_object_props.rstest-files/test_gap_generic_specialization_constructor_identity_7757.ts
Covers the 32 PRs admin-merged in one pass (audited in principle at the maintainer's direction): PerryTS#7768 PerryTS#7772 PerryTS#7779 PerryTS#7784 PerryTS#7785 PerryTS#7786 PerryTS#7788 PerryTS#7789 PerryTS#7797 PerryTS#7798 PerryTS#7801 PerryTS#7802 PerryTS#7804 PerryTS#7805 PerryTS#7806 PerryTS#7807 PerryTS#7808 PerryTS#7810 PerryTS#7811 PerryTS#7815 PerryTS#7816 PerryTS#7818 PerryTS#7819 PerryTS#7820 PerryTS#7821 PerryTS#7822 PerryTS#7823 PerryTS#7824 PerryTS#7825 PerryTS#7826 PerryTS#7827 PerryTS#7828. (PerryTS#7787 closed as already-landed via the PerryTS#7786 stack.) Per-change history lives in each PR's changelog.d fragment as usual. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
Fixes #7757 — the constructor half, plus a lookup hole the issue's repro did not
reach.
new Gen<number>()is monomorphized into a separate class (Gen$num) with itsown class id, and the instance is stamped with that id. TypeScript erases type
arguments, so at runtime there is exactly one
Gen.#7575 fixed
instanceof, #7632 fixedconstructor.name, #7762 fixed the twoprototype-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. #7632 made this worse before better: both values report thename
Gen, so two constructors printed identically and compared unequal.2. The property lookup chain — not reached by #7762, and the worse of the two
lookup_prototype_methodwalked the parent chain from the specialization'sid, so a patch on
Gen.prototypewas invisible on a specialized instance:The prototype edge and the lookup chain disagreed about the same object — the
exact inconsistency #7757 flagged, one layer below where it was looked for.
a.constructor === Gena.constructor === b.constructorGen.prototype.tagseen fromaundefined"G""G"getPrototypeOf(a) === Gen.prototypeFix
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, not
an ancestor — a specialization's parent chain is its generic's parent chain,
so hopping to the parent first walks past
Genand never comes back. Writing itthat way also keeps the walk line-count-neutral, which
construct.rsrequires: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_REGISTRYparent edge(that chain also resolves
super()construction and would re-run the wrongconstructor).
Validation
test-files/test_gap_generic_specialization_constructor_identity_7757.tsisbyte-identical to
node --experimental-strip-types. It pins the edge inthe negative direction too: two different generics stay distinct
(
o.constructor !== Gen), a specialized subclass reports the subclassrather than the base,
instanceofstill discriminates, and declared methodsstill dispatch per specialization.
cargo test -p perry-runtime -- --test-threads=1: 2051 passed, 0 failed.cargo fmt --all -- --checkandscripts/check_file_size.shclean(
construct.rsstays at exactly 2000 lines).Summary by CodeRabbit
instanceofbehavior is now more consistent.