Skip to content

Fix forward reference to Range in Hover causing load-time crash#911

Merged
Morriar merged 3 commits intoShopify:mainfrom
otama-jaccy:fix-hover-forward-range-reference
May 8, 2026
Merged

Fix forward reference to Range in Hover causing load-time crash#911
Morriar merged 3 commits intoShopify:mainfrom
otama-jaccy:fix-hover-forward-range-reference

Conversation

@otama-jaccy
Copy link
Copy Markdown
Contributor

@otama-jaccy otama-jaccy commented May 7, 2026

Problem

Spoom::LSP::Hover declares const :range, T.nilable(Range) at line 20 of lib/spoom/sorbet/lsp/structures.rb, but Spoom::LSP::Range is defined later in the same file at line 73.

At class-body evaluation time, the bare Range constant resolves through Ruby's lexical scope:

  1. Spoom::LSP::Hover::Range — not defined
  2. Spoom::LSP::Rangenot yet defined (it will be, at line 73)
  3. Spoom::Range — not defined
  4. ::Range — Ruby's built-in Range class ✅ resolved here

So T.nilable(Range) is silently interpreted as T.nilable(::Range). sorbet-runtime auto-coerces a bare ::Range class into a T::Types::TypedRange whose element type is nil. When the union's hash is later computed at registration time, the chain blows up:

T::Types::TypedRange#name           (typed_range.rb:12)
  → T::Types::TypedEnumerable#type  (typed_enumerable.rb:14)
    → T::Utils.coerce(nil)
      → RuntimeError: Invalid value for type constraint.
        Must be an T::Types::Base, a class/module, or an array. Got a `NilClass`.

This makes require "spoom" raise on load, which in turn breaks bundle exec tapioca init and any CLI entry point that transitively touches Spoom::LSP.

Reproduction

The crash reproduces on both Ruby 3.3 and Ruby 4.0 with sorbet-runtime 0.6.13196 + spoom 1.7.13:

$ docker run --rm ruby:3.3-slim bash -c \
    'gem install --no-document spoom -v 1.7.13 && \
     ruby -e "require \"spoom\""'
...
/usr/local/bundle/gems/sorbet-runtime-0.6.13196/lib/types/utils.rb:32:in `coerce_and_check_module_types':
  Invalid value for type constraint. ... Got a `NilClass`. (RuntimeError)
  ...
  from /usr/local/bundle/gems/spoom-1.7.13/lib/spoom/sorbet/lsp/structures.rb:20:in `<class:Hover>'

Same stack trace on Ruby 4.0.3. I haven't bisected sorbet-runtime to find when the ::Range auto-coercion started raising, but the spoom code has been relying on lexical-scope luck regardless.

The bug appears not to be caught by CI presumably because no test path actually causes require "spoom" to load spoom/sorbet/lsp end-to-end on a fresh Ruby process — but anything that goes through bundle exec tapioca init (a common adoption path) hits it on first run.

Fix

Move the Hover class to be defined after Position and Range, so T.nilable(Range) resolves to Spoom::LSP::Range as intended.

The diff is a pure reorder: 29 insertions, 29 deletions, no behavioral or API change.

Before After
Hover (line 16) → PositionRangeLocation → … PositionRangeHoverLocation → …

DocumentSymbol (which also references Range and Location) was already defined after them and continues to work.

Relation to #841

#841 (migrating all T::Struct usages to bare Ruby classes) supersedes this patch — once it lands, the const :range, T.nilable(Range) declaration disappears entirely and this whole class of bug goes away. I verified locally that #841 also resolves the crash.

I'm submitting this as a smaller, lower-risk patch in case #841 takes longer to land, since the load-time crash is a hard blocker for adopting Sorbet through a downstream project today. Happy to close if you'd rather wait for #841.

Verification

Setup Behavior on main Behavior on this branch
Ruby 3.3.11 + sorbet-runtime 0.6.13196 + require "spoom" crashes loaded ok
Ruby 4.0.3 + same + bundle exec tapioca init + srb tc crashes on init init succeeds, srb tc reports no errors

Existing spoom tests should pass unchanged since this is a pure reorder.

Spoom::LSP::Hover at line 16 declared const :range, T.nilable(Range),
but Spoom::LSP::Range was defined later in the same file at line 73.
At class-body evaluation time, Range resolved via lexical scope to
Ruby's built-in ::Range, which sorbet-runtime auto-coerces to a
T::Types::TypedRange. Computing the union hash later raised:

  Invalid value for type constraint. ... Got a NilClass.
  (T::Types::TypedRange#name -> T::Utils.coerce on nil element type)

This made require 'spoom/sorbet/lsp' crash on load, which in turn
broke 'tapioca init' and any CLI entry that touched Spoom::LSP.

Fix: reorder so primitive structs (Position, Range, Location) are
defined before composites that reference them (Hover). No behavior
change, no type-info loss.
@otama-jaccy otama-jaccy marked this pull request as ready for review May 7, 2026 10:27
@otama-jaccy otama-jaccy requested a review from a team as a code owner May 7, 2026 10:27
Copy link
Copy Markdown
Contributor

@KaanOzkan KaanOzkan left a comment

Choose a reason for hiding this comment

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

Thanks, it just needs a bundle exec spoom srb sigs export to fix CI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@otama-jaccy
Copy link
Copy Markdown
Contributor Author

@KaanOzkan
Thanks for the review! I've run bundle exec spoom srb sigs export and pushed the updated rbi/spoom.rbi.

@Morriar Morriar merged commit f8aab12 into Shopify:main May 8, 2026
6 checks 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.

3 participants