Skip to content

fix(runtime): a generic specialization answers with its generic's constructor and prototype entries - #7826

Merged
proggeramlug merged 2 commits into
mainfrom
fix/7757-generic-specialization-constructor
Aug 11, 2026
Merged

fix(runtime): a generic specialization answers with its generic's constructor and prototype entries#7826
proggeramlug merged 2 commits into
mainfrom
fix/7757-generic-specialization-constructor

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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 its
own 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 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. #7632 made this worse before better: both values report the
name 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_method walked the parent chain from the specialization's
id, so a patch on Gen.prototype was invisible on a specialized instance:

class Gen<T> { v: T | undefined; }
(Gen.prototype as any).tag = "G";
const a = new Gen<number>();
console.log((a as any).tag);                                   // undefined
console.log(Object.getPrototypeOf(a) === Gen.prototype);       // true

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.

before after node
a.constructor === Gen false true true
a.constructor === b.constructor false true true
Gen.prototype.tag seen from a undefined "G" "G"
getPrototypeOf(a) === Gen.prototype true true true

Fix

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 Gen and never comes back. Writing it
that way 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).

Validation

  • test-files/test_gap_generic_specialization_constructor_identity_7757.ts is
    byte-identical to node --experimental-strip-types. It pins the edge in
    the negative direction too: two different generics stay distinct
    (o.constructor !== Gen), a specialized subclass reports the subclass
    rather than the base, instanceof still discriminates, and declared methods
    still dispatch per specialization.
  • The issue's own repro now matches node line for line.
  • cargo test -p perry-runtime -- --test-threads=1: 2051 passed, 0 failed.
  • cargo fmt --all -- --check and scripts/check_file_size.sh clean
    (construct.rs stays at exactly 2000 lines).

Summary by CodeRabbit

  • Bug Fixes
    • Generic class instances now report the expected shared constructor identity.
    • Prototype methods added to generic classes are consistently available on specialized instances.
    • Specialized classes retain their own declared method implementations.
    • Constructor, subclass, identity, and instanceof behavior is now more consistent.

Ralph Küpper added 2 commits August 11, 2026 04:00
…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
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The runtime now resolves specialized generic instances to their generic constructor and prototype while preserving specialized method dispatch. A regression test covers identity, inheritance, instanceof, prototype lookup, unrelated classes, and construction through .constructor.

Changes

Generic specialization identity

Layer / File(s) Summary
Runtime identity resolution
crates/perry-runtime/src/object/field_get_set/class_object_props.rs, crates/perry-runtime/src/object/class_registry/construct.rs, changelog.d/7826-generic-specialization-constructor.md
Constructor resolution uses the registered generic origin. Prototype-method lookup checks the generic origin before the registered parent. Specialized method dispatch remains tied to the specialized class ID.
Identity regression coverage
test-files/test_gap_generic_specialization_constructor_identity_7757.ts
The test verifies constructor and prototype identity, method dispatch, subclass behavior, instanceof, class separation, and construction through the reported constructor.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies both runtime fixes: generic constructor identity and prototype lookup for specialized instances.
Description check ✅ Passed The description explains the problem, changes, linked issue, compatibility constraints, and validation results, despite using different section headings.
Linked Issues check ✅ Passed The changes satisfy issue #7757 by aliasing constructor identity and prototype lookup while preserving subclass identity and specialized method dispatch.
Out of Scope Changes check ✅ Passed All changes are directly related to generic specialization identity, prototype lookup, regression coverage, and changelog documentation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7757-generic-specialization-constructor

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 win

Do 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 on Parent31 is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 1804991 and 53f4869.

📒 Files selected for processing (4)
  • changelog.d/7826-generic-specialization-constructor.md
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/object/field_get_set/class_object_props.rs
  • test-files/test_gap_generic_specialization_constructor_identity_7757.ts

@proggeramlug
proggeramlug merged commit c740f88 into main Aug 11, 2026
1 of 18 checks passed
@proggeramlug
proggeramlug deleted the fix/7757-generic-specialization-constructor branch August 11, 2026 05:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generic-class specializations are distinct constructor objects: a.constructor !== Gen, and two specializations compare unequal

1 participant