Skip to content

Include uninstantiated generic subclasses in virtual metaclass dispatch (#11134)#16998

Open
stakach wants to merge 2 commits into
crystal-lang:masterfrom
stakach:fix-virtual-metaclass-uninstantiated-generic
Open

Include uninstantiated generic subclasses in virtual metaclass dispatch (#11134)#16998
stakach wants to merge 2 commits into
crystal-lang:masterfrom
stakach:fix-virtual-metaclass-uninstantiated-generic

Conversation

@stakach

@stakach stakach commented May 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #11134.

The bug

class Foo
  def self.foo; 1; end
end

class Bar(T) < Foo
  def self.foo; 2; end
end

class Baz < Foo
  def self.foo; 3; end
end

Bar.as(Foo.class).foo  # segfault

The dispatch table generated for Foo+.class.foo has branches for Baz and instantiated forms of Bar, but no branch for Bar itself (the uninstantiated generic). The call's type-id falls through every arm and lands on the unreachable LLVM emits for the else, which is undefined behaviour.

Root cause

Type#collect_subtypes filters GenericType out of a virtual type's subtypes:

subtypes << type unless type.is_a?(GenericType) || type.unbound?

That's correct for instance virtual dispatch — Bar(T) itself isn't instantiable — but for metaclass dispatch the uninstantiated Bar IS a callable class value (Bar.foo is legal Crystal), so the metaclass dispatch table needs an arm for it.

Fix

Three coordinated changes, all narrowly scoped to metaclass dispatch:

  1. Type::VirtualType (types.cr) — add subtypes_including_generic / collect_subtypes_including_generic. They yield uninstantiated GenericType subclasses only when no instantiations exist. If instantiations exist, those already carry the dispatch, and including the uninstantiated form would trip up class methods that reference the type parameter (e.g. def self.x; T.foo; end — the pattern in spec Error: can't instantiate abstract generic struct EnumType(Test) #9621). This is what keeps the regression scope minimal.

  2. VirtualMetaclassType#each_concrete_type (types.cr) — use the new method when iterating concrete metaclass types. This adds the missing Bar.metaclass arm to Foo+.class's match-fun table.

  3. VirtualTypeLookup#lookup_matches (method_lookup.cr) — same swap when virtual_metaclass?, so the lookup phase agrees with codegen. Instance virtual dispatch keeps the existing subtypes behaviour, so this change has no effect on non-metaclass paths.

Tests

Note

This was originally part of #16982 (which also covered #16947 and #10831). Per review feedback, that PR is being split. #10831's symmetric common_descendent is a separate, independent change. #16947's reproducer could not be reliably demonstrated on master so no regression test is included for it — though the same code paths plausibly help.

…ch (crystal-lang#11134)

`Bar.as(Foo.class).foo` segfaulted when `class Bar(T) < Foo` had no
instantiation. The dispatch table generated for `Foo+.class.foo` had
branches for every concrete metaclass subtype but skipped `Bar` (the
uninstantiated generic), so the matching call fell through the `else`
into an LLVM `unreachable`.

`Type::VirtualType` learns `subtypes_including_generic`, which yields
uninstantiated `GenericType` subclasses only when no instantiations
exist (instantiations carry the dispatch otherwise, and including the
uninstantiated form when instantiations exist trips up class methods
that reference the type parameter — c.f. spec crystal-lang#9621).

`VirtualMetaclassType#each_concrete_type` and
`VirtualTypeLookup#lookup_matches` use the new method only for virtual
*metaclass* dispatch. Instance virtual dispatch keeps the existing
`subtypes` behaviour so this change is scoped to the actual bug.

@straight-shoota straight-shoota left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue: This patch is for a semantic bug, so it should introduce semantic specs that reproduce the bug, not just a codegen spec.

Comment thread src/compiler/crystal/types.cr Outdated
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
@straight-shoota straight-shoota added kind:bug A bug in the code. Does not apply to documentation, specs, etc. topic:compiler:semantic labels May 24, 2026
end

def collect_subtypes(type, subtypes)
subtypes << type unless type.is_a?(GenericType) || type.unbound?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thought: I'm wondering why GenericType#unbound? does not already return true. A non-instantiated generic type should be unbound.
This affects the existing implementation, but would also have an effect on the patch (and maybe result in a different fix as a consequence).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So it looks like unbound? means that the type expression contains unresolved type parameters.
GenericType is a metaclass that can be used as a type expression directly, even without an instantiation.
A call like Array.additive_identity with Array(T) being a GenericType is entirely valid. The type parameter T is technically unresolved, but that doesn't matter if it's not used. We're just using Array, but it's still considered a GenericType.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind:bug A bug in the code. Does not apply to documentation, specs, etc. topic:compiler:semantic

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dispatch over virtual metaclasses ignores uninstantiated generic subclasses

2 participants