Skip to content

fix(hir): expand type aliases in the inferred type of new C<...>() so the generic specialization resolves (#7848) - #7852

Merged
proggeramlug merged 3 commits into
mainfrom
fix/7848-generic-alias-specialization
Aug 11, 2026
Merged

fix(hir): expand type aliases in the inferred type of new C<...>() so the generic specialization resolves (#7848)#7852
proggeramlug merged 3 commits into
mainfrom
fix/7848-generic-alias-specialization

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Closes #7848.

What was wrong

A generic class instantiated with a type argument that is a type alias silently lost its monomorphized specialization at every use of the binding. The emitted dispatch guard was compiled against the generic template class while the object is an instance of the specialization, so the guard could never pass and every method call on that binding took the fully dynamic js_native_call_method_by_id path — permanently, for the life of the program.

Output stayed correct; exit code stayed 0; nothing went red. The only symptom was speed.

type Stage = (r: Rec) => Rec;             // an ordinary alias of a function type

const stats  = new Registry<Stage,  number>();   // BROKEN: every call fully dynamic
const byKind = new Registry<string, number>();   // fine:   guard-free direct call

Two locals, same class declaration, adjacent lines, and only one got a fast path.

Root cause

Two independent lowerings read the same new C<…>() type-argument list, and only one of them expanded type aliases:

site call alias
lower/expr_new.rs:1216 — the New's own type_args, which monomorphization keys the specialization on extract_ts_type_with_ctx(t, Some(ctx)) expanded
lower_types.rs:604 — the INFERRED declared type of the binding extract_ts_type(t) (= …_with_ctx(t, None)) not expanded

extract_ts_type_with_ctx resolves a type alias only when ctx.is_some() (lower_types/extract.rs, the TsTypeRef arm).

Codegen re-derives the specialization from the declared type and misses (type_analysis/predicates.rs:312):

let specialized = generate_specialized_name(base, type_args);
if ctx.classes.contains_key(&specialized) { Some(specialized) }
else if ctx.classes.contains_key(base)    { Some(base.clone()) }   // <-- silent degrade

mangle_type maps Function(_) -> "fn" but Named(n) -> n, so the declared type sought Registry$Stage_num while the class that exists is Registry$fn_num.

It killed BOTH fast tiers, not just the guard

lower_call/property_get/dynamic_dispatch.rs:1132:

let ptr_shape_receiver = ctx.ptr_shape_receiver_fact(object)
    .map(|fact| fact.class_name == class_name)   // "Registry$fn_num" != "Registry"
    .unwrap_or(false);

The binding did carry a correct Phase-3b Ptr<Shape> provenance fact naming Registry$fn_num. The declared-type resolution named Registry. They disagreed, so the guard-free arm was rejected and the site fell through to emit_guarded_direct_method_call — whose guard then tested class 1 against a class-1004 receiver.

The fix

One call, in crates/perry-hir/src/lower_types.rs: the inferred declared type of new C<…>() now lowers its type arguments with extract_ts_type_with_ctx(t, Some(ctx)) — the identical call, on the identical AST nodes, that lower/expr_new.rs already uses to build the New's type_args.

Safe by construction: the inferred declared type can no longer name anything the New does not. ctx does exactly two things in that function — resolve a type-parameter reference and resolve a type alias — and both are already applied at the New.

★ Which tier this routes to

Deliberately stating this, because a declared type is a hint and not a proof (#7846), and getting it wrong here would turn a dead-fast-path bug into a wrong-answer bug.

  • The guarded tier is unchanged in kind. emit_guarded_direct_method_call still emits a real runtime js_method_direct_shape_guard(recv, class_id, keys_token). The fix only changes which class id it tests — from one that could never match to the one that does. A wrong declared type still degrades to a failed guard, never to a wrong result.
  • The guard-free tier is not widened. Its authority is the Ptr<Shape> provenance fact (collectors/ptr_shape.rs: the local holds exactly one new <class> for its whole lifetime, and containment proves no alias exists). The declared type appears there only inside an equality test against that fact, so it can never introduce a class the fact does not already assert — it can only agree or disagree. Disagreement falls back to the guarded tier, which is exactly today's behaviour.
  • The programs that newly reach the guard-free tier are precisely those whose provenance fact already named the specialization and whose declared type merely spelled it differently. That path is not new: it is what the non-aliased sibling local (byKind / byStr) takes on unmodified main today. This change makes the aliased case behave identically to the already-shipping non-aliased case.

Tests

crates/perry-hir/src/lower_types/generic_alias_specialization_tests.rs — a lib unit test module (so it runs in the per-PR cargo-test job, not only nightly).

It asserts the property codegen actually depends on, as an equation rather than as a spelling: for const r = new C<…>(), the name generate_specialized_name derives from the binding's declared type must equal the class the New was rewritten to. A future change to mangle_type or to the naming scheme therefore cannot make the tests pass while the two sides drift apart again.

Every alias arm is covered rather than a sample of spellings — the four that were broken and the three that always round-tripped:

type argument declared mangled to specialization
type S = string Reg$S_num Reg$str_num was broken
type Stage = (n) => n Reg$Stage_num Reg$fn_num was broken
type O = { a: number } Reg$O_num Reg$obj_num was broken
type U = string | number Reg$U_num Reg$union_str_num_num was broken
class C Reg$C_num Reg$C_num ok, pinned
interface I Reg$I_num Reg$I_num ok, pinned
string (builtin) Reg$str_num Reg$str_num ok, pinned (control)

Benchmark probe

gc-handoff/bench/generic_alias_dispatch.ts and generic_alias_dispatch_ctl.ts — the same program, differing in one word: the type argument spelled through the alias vs spelled inline.

The control is what makes it a measurement rather than a story, and the probe was verified to exercise its subject before it was trusted (three probes in this campaign measured nothing while their bug was fully intact). On unmodified main, in emitted IR:

generic_alias_dispatch      4 x js_method_direct_shape_guard(recv, i32 1)
                            4 x js_native_call_method_by_id
                            dead arms calling the TEMPLATE Registry__*$pshape
                            receiver allocated as class 1001

generic_alias_dispatch_ctl  0 guards, 0 js_native_call_method_by_id
                            direct calls to u_Registry_24_fn_5f_num__*$pshape

Both print 13495500 3, matching node --experimental-strip-types.

Validation

Baseline arm = $HOME/cargo-targets/final/release (perry 0.5.1467 @ 0321c6554, from a
worktree verified clean at that commit). Fix arm = $HOME/cargo-targets/pipe/release.

The change is in the binary — checked before trusting any A/B, because my first build
process died silently and a compiler without the change makes both arms behave identically
and reads as a clean "zero regressions". m0810/genalias.ts, IR of main:

baseline fix arm
js_method_direct_shape_guard(…, i32 1) 2 0
js_native_call_method_by_id 2 0
direct calls template Reg__*$pshape (dead arm) u_Reg_24_fn_5f_num__*$pshape and u_Reg_24_str_5f_num__*$pshape

Corpus (23 programs incl. the two probes and the two repro files): exit 0 and
byte-identical to node --experimental-strip-types on both arms.

cmp of the two arms' executables — 20 identical, 3 differ:

identical  asyncpipe churn churn_alloc churn_read cycles deeplist fib40 genmono
           generic_alias_dispatch_ctl interp iso_miss push_cls push_num
           retain retain1 retain_wide retain_wide1 shapes tree tree_wide
DIFFERS    genalias  generic_alias_dispatch  pipeline

The three that differ are exactly the three programs in the set that instantiate a
generic class with a type alias. Byte-identical programs cannot regress, so no host time is
needed for them. generic_alias_dispatch_ctl (same program, type spelled inline) and
genmono (hand-monomorphized) are byte-identical, which is what makes the probe a
controlled measurement.

Note for anyone repeating this: holding the output basename constant is necessary but
not sufficient. Two perry binaries built in two CARGO_TARGET_DIRs link two separately
built libperry_{runtime,stdlib}.a, and those are not byte-identical across target dirs —
which alone makes every program differ. My first sweep read 0/23 identical for that reason.
gc-handoff/m0810/build_pipe.sh now takes RUNTIME= separately from PBIN= so a
compiler-only A/B can pin one runtime for both arms.

Unit tests: cargo test --release -p perry-hir --lib generic_alias7 passed, 0 failed.

★ The first run of those tests failed all seven cases including the three controls
(class / interface / builtin) that were never broken. That is the tell that the harness,
not the compiler, was wrong: lower_module alone leaves every Expr::New::class_name at
the generic base, because rewriting it to the specialization is
monomorph::update_call_sites' job. The harness now calls monomorphize_module. Recorded
because it is the same shape as the probes that measured nothing earlier in this campaign —
the difference is only that this one had controls in it, so it announced itself.

Timing: the shared measurement host has been continuously locked by another agent, so
absolute seconds are not in this PR. The structural evidence above is independent of it —
the byte-identical/differs split is exact and needs no host — and the coordinator's
independent back-to-back run on m0810/genalias.ts vs a hand-monomorphized genmono.ts
put the gap at ~1.44x (0.0823 vs 0.0571 s), taken under load and explicitly flagged as
indicative rather than final.

Measured (quiet M1 mini, best-of-5 interleaved, exit-checked)

LOAD_BEFORE=2.10 LOAD_AFTER=2.20 PROC_AFTER=0 VERDICT: CLEAN. Every cell exit 0.

bench baseline 0321c6554 this PR ratio
pipeline 0.4870 0.2969 0.610
genalias 0.0818 0.0545 0.666
generic_alias_dispatch 0.0087 0.0069 0.793
(the 20 byte-identical programs) 0.982 – 1.003

The noise floor is measured, not assumed. The 20 programs that are byte-identical
between the arms ran 0.982–1.003 (median 0.999); a byte-identical binary cannot change, so
that spread is this run's noise. The three movers are an order of magnitude outside it.

The pairs meet

Each is a program pair identical apart from the bug, so the fix is right exactly if the pair
converges:

pair baseline this PR
genalias vs genmono (hand-monomorphized equivalent) 0.0818 vs 0.0546 0.0545 vs 0.0545
generic_alias_dispatch vs _ctl (type spelled inline) 0.0087 vs 0.0067 0.0069 vs 0.0067

genalias lands on genmono to four decimal places — the aliased generic now costs exactly
what the hand-written monomorphic class costs.

No regression

All 18 corpus ceilings met: churn 0.2889 · churn_alloc 0.2411 · push_cls 0.2361 ·
push_num 0.0695 · churn_read 0.0224 · cycles 0.1114 · deeplist 0.1225 · tree 1.1616 ·
tree_wide 1.6503 · retain 0.3503 · retain1 0.1362 · retain_wide 0.4599 · retain_wide1 0.1591 ·
fib40 0.3936 · asyncpipe 0.1338 · shapes 0.1838 · interp 1.2364 · iso_miss 1.6705.
Canary iso_miss prints checksum 437840 misses 0.

Ralph Küpper added 2 commits August 11, 2026 15:58
…so the generic specialization resolves

A generic class instantiated with a type ALIAS argument silently lost its
monomorphized specialization at every use of the binding, so the emitted
dispatch guard was compiled against the generic TEMPLATE class and could
never pass.

Two lowerings read the same `new C<...>()` type-argument list and only one
expanded aliases: `lower/expr_new.rs` builds the `New`'s `type_args` with
`extract_ts_type_with_ctx(t, Some(ctx))` -- which monomorphization keys the
specialization on -- while `lower_types.rs`'s inferred declared type used the
context-free `extract_ts_type(t)`. Codegen re-derives the specialization from
the declared type and missed, falling back to the template class.

Refs #7848
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

new expressions now extract generic arguments with lowering context. Regression tests verify matching specialization names for alias, class, interface, and builtin type arguments.

Changes

Generic alias specialization

Layer / File(s) Summary
Context-aware constructor type extraction
crates/perry-hir/src/lower_types.rs
new expressions use context-aware type extraction for generic arguments. The regression test module is registered.
Alias specialization regression coverage
crates/perry-hir/src/lower_types/generic_alias_specialization_tests.rs, changelog.d/7852-generic-alias-specialization.md
Tests locate constructed bindings, compare declared and constructed specialization names, and cover function, primitive, object-literal, union, class, interface, and builtin aliases. The changelog documents the fix and validation.

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

Sequence Diagram(s)

sequenceDiagram
  participant NewExpression
  participant TypeLowering
  participant Monomorphization
  NewExpression->>TypeLowering: Extract generic arguments with lowering context
  TypeLowering->>Monomorphization: Provide alias-expanded specialization type
  Monomorphization-->>NewExpression: Produce matching class specialization
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Title check ✅ Passed The title clearly and concisely describes the main fix: expanding aliases for inferred generic specialization types.
Description check ✅ Passed The description thoroughly explains the problem, root cause, fix, tests, benchmarks, and related issue, despite not following the template headings exactly.
✨ 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/7848-generic-alias-specialization

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.

Without it `lower_module` leaves every `Expr::New::class_name` at the generic
BASE, so all seven cases failed identically -- including the class / interface /
builtin controls that were never broken, which is the tell that the harness and
not the compiler was wrong.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant