Skip to content

fix(runtime): generic specializations share the generic's prototype object (#7757, prototype half) - #7762

Merged
proggeramlug merged 2 commits into
mainfrom
fix/7757-generic-class-identity
Aug 10, 2026
Merged

fix(runtime): generic specializations share the generic's prototype object (#7757, prototype half)#7762
proggeramlug merged 2 commits into
mainfrom
fix/7757-generic-class-identity

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Partial fix for #7757 — the prototype identity. The issue stays open for the .constructor edge; reasoning and what I ruled out at the bottom.

What was wrong

Each monomorphized specialization materialized its own prototype object, so:

class Gen<T> { v: T | undefined; }
const a = new Gen<number>(), b = new Gen<string>(), c = new Gen();
Object.getPrototypeOf(a) === Gen.prototype              // node true   perry true
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)   // node true   perry FALSE
Object.getPrototypeOf(a) === Object.getPrototypeOf(c)   // node true   perry FALSE

Those first two cannot both hold, and that contradiction is the actual bug: the answer depended on which class id you asked through. TypeScript erases type arguments, so at runtime there is exactly one Gen and one Gen.prototype; the specializations are an implementation detail of monomorph::mangle::generate_specialized_name.

Fix

Both prototype registries resolve a specialization through class_generic_origin — the same edge instanceof uses (#7575) and the display name uses (#7632), now applied to the prototype surface.

It had to be both: CLASS_DECL_PROTOTYPE_OBJECTS and CLASS_PROTOTYPE_OBJECTS are the two paths CLAUDE.md's "known-weak areas" flags as having disagreed about the same chain before, and redirecting only the decl one left getPrototypeOf and the lookup chain answering differently.

Method dispatch is untouched — it runs off the per-class-id vtable, so each specialization keeps its own monomorphized bodies.

Validation

Why .constructor is not in here

a.constructor === Gen is still false. It does not resolve through either prototype registry, so this change cannot reach it.

I applied the same redirect to instance_constructor_value — the obvious site, and the one get_field_by_name_tail calls for exactly this key — and it changed nothing. Two findings worth having on the record so the next attempt doesn't repeat them:

  1. object_static_prototype() appears to materialize on first call. The tail guards the instance_constructor_value call on object_static_prototype(obj).is_none(), and adding a probe that reads it flips the branch under test. I burned two instrumented builds on artifacts from this before spotting it; anything measuring this path needs to avoid touching that accessor.
  2. class Empty {} gives e.constructor === undefined (node: Empty). That is not a generics bug at all, which suggests .constructor resolution has a broader gap that monomorphization merely exposes — and that the remaining half of Generic-class specializations are distinct constructor objects: a.constructor !== Gen, and two specializations compare unequal #7757 may be better framed as that gap rather than as a specialization leak.

I stopped rather than keep probing blind, and reverted the redirect that provably never runs — leaving a dead guard that looks like a fix is the pattern I flagged on #7542.

No version bump.

Summary by CodeRabbit

  • Bug Fixes

    • Generic class specializations now share prototype identity with their originating generic class.
    • Prototype comparisons, inheritance checks, and instanceof behavior are now consistent across specialized and unspecialized instances.
    • Method dispatch remains specific to each specialization.
  • Tests

    • Added regression coverage for prototype identity, inheritance chains, instanceof, and constructor behavior.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fc3e605b-9991-4066-a4fa-7b711e69761e

📥 Commits

Reviewing files that changed from the base of the PR and between 78e8846 and 885a63c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml

📝 Walkthrough

Walkthrough

The runtime maps monomorphized class IDs to generic origins for prototype lookup and materialization. A regression test checks specialized instances, nested inheritance, instanceof, and constructor names. Version metadata and the changelog are updated.

Changes

Generic prototype identity

Layer / File(s) Summary
Prototype identity normalization
crates/perry-runtime/src/object/class_registry/prototype_objects.rs, crates/perry-runtime/src/object/class_registry/state.rs, changelog.d/..., CLAUDE.md, Cargo.toml
Prototype lookup and materialization normalize monomorphized class IDs to their generic origins. Specialization-specific method dispatch remains unchanged. The changelog records the unresolved constructor identity difference. Package versions are updated.
Prototype identity regression coverage
test-files/test_gap_generic_specialization_prototype_identity_7757.ts
The test creates generic and specialized Gen and Wrap instances, then checks prototype equality, inheritance-chain identity, instanceof, and constructor names.

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

Possibly related PRs

  • PerryTS/perry#7631 — Introduces generic-origin class metadata reused by this prototype normalization.
  • PerryTS/perry#7756 — Addresses generic specialization identity in a separate runtime path.

Suggested reviewers: jdalton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the runtime fix: generic specializations now share the generic prototype object, and it notes the partial scope.
Description check ✅ Passed The description is detailed and on-topic, references #7757, explains the fix and limitation, and lists validation results, although it omits several template headings and checklist items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7757-generic-class-identity

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.

🧹 Nitpick comments (1)
test-files/test_gap_generic_specialization_prototype_identity_7757.ts (1)

28-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for another nested specialization.

The test creates only Wrap<number>. It does not verify that another nested specialization or an unspecialized Wrap uses the same Wrap.prototype. Add these cases to cover the generic-origin redirect across multiple nested class IDs.

Proposed test additions
 const w = new Wrap<number>();
+const w2 = new Wrap<string>();
+const w3 = new Wrap();

 console.log("nested proto === Wrap.prototype:", Object.getPrototypeOf(w) === Wrap.prototype);
 console.log("nested chain:", Object.getPrototypeOf(Object.getPrototypeOf(w)) === Gen.prototype);
+console.log("nested specializations share:", Object.getPrototypeOf(w) === Object.getPrototypeOf(w2));
+console.log("nested unspecialized shares:", Object.getPrototypeOf(w) === Object.getPrototypeOf(w3));
🤖 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 `@test-files/test_gap_generic_specialization_prototype_identity_7757.ts` around
lines 28 - 30, Extend the test around the existing Wrap<number> instance to also
instantiate another nested specialization and an unspecialized Wrap, then assert
each instance’s prototype is Wrap.prototype and its prototype chain reaches
Gen.prototype. Keep the existing checks and use distinct nested class IDs to
cover the generic-origin redirect across multiple cases.
🤖 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.

Nitpick comments:
In `@test-files/test_gap_generic_specialization_prototype_identity_7757.ts`:
- Around line 28-30: Extend the test around the existing Wrap<number> instance
to also instantiate another nested specialization and an unspecialized Wrap,
then assert each instance’s prototype is Wrap.prototype and its prototype chain
reaches Gen.prototype. Keep the existing checks and use distinct nested class
IDs to cover the generic-origin redirect across multiple cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3fdf2f0e-87eb-4d11-a394-8c2fdb6538cf

📥 Commits

Reviewing files that changed from the base of the PR and between 538602a and 78e8846.

📒 Files selected for processing (4)
  • changelog.d/7762-generic-specialization-prototype-identity.md
  • crates/perry-runtime/src/object/class_registry/prototype_objects.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • test-files/test_gap_generic_specialization_prototype_identity_7757.ts

@proggeramlug
proggeramlug force-pushed the fix/7757-generic-class-identity branch from 78e8846 to 885a63c Compare August 10, 2026 12:13
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1448

A/B'd on my host — new matches node on every row:

                  node    old      new
a===Gen.proto     true    true     true
a===b             true    FALSE    true
a===c             true    FALSE    true
instanceof        true    true     true
dispatch          gen wrap  gen wrap  gen wrap

The contradiction framing is the right diagnosis. getPrototypeOf(a) === Gen.prototype being true while getPrototypeOf(a) === getPrototypeOf(b) was false cannot both hold — the answer depended on which class id you asked through. That is a sharper statement of the bug than "specializations have separate prototypes", and it is what makes the fix obviously correct rather than a preference.

Both registries was necessary, not belt-and-braces. CLASS_DECL_PROTOTYPE_OBJECTS and CLASS_PROTOTYPE_OBJECTS are exactly the pair CLAUDE.md's known-weak-areas section flags as having disagreed about the same chain before, and redirecting only the decl one would have left getPrototypeOf and the lookup chain answering differently — i.e. a new instance of the same class of bug.

The non-regression I most wanted to see is in the table: dispatch: gen wrap in both arms. Method dispatch runs off the per-class-id vtable, so each specialization keeps its own monomorphized bodies. Sharing the prototype object without sharing the vtable is the whole trick.

The two findings on .constructor are worth more than the fix

  1. object_static_prototype() appears to materialize on first call, so a probe that merely reads it flips the branch under test. Two instrumented builds lost to artifacts before spotting it. That is a measurement hazard anyone touching get_field_by_name_tail will hit, and it is now written down.
  2. class Empty {} gives e.constructor === undefined (node: Empty). That is not a generics bug at all — which reframes the remaining half of Generic-class specializations are distinct constructor objects: a.constructor !== Gen, and two specializations compare unequal #7757 from "a specialization leak" to ".constructor resolution has a broader gap that monomorphization merely exposes". Filing the residue under the right framing is worth more than a partial fix under the wrong one.

Applying the redirect to instance_constructor_value — the obvious site — and reporting that it changed nothing is the useful negative result: it rules out the whole prototype-registry route for that half.

Correctly leaves #7757 open. Gates 21/21, cargo test -p perry-runtime --lib 2023 passed.

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.

1 participant