Skip to content

Fixes SystemStackError for has_many assocation that points to own model - #2736

Merged
paracycle merged 2 commits into
Shopify:mainfrom
DaneHarrison:fix/1883-self-referential-has-many-through-stack-overflow
Oct 6, 2026
Merged

paracycle merged 2 commits into
Shopify:mainfrom
DaneHarrison:fix/1883-self-referential-has-many-through-stack-overflow

Conversation

@DaneHarrison

@DaneHarrison DaneHarrison commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Fixes #1883 - Tapioca crashes with a SystemStackError when a has_many :through association ends up being its own source.

The association in the issue doesn't set source:, so child_group_members resolves to itself as the source and keeps looping. This also happens without Tapioca, in a standalone Active Record script and in the demo app with rails runner, as soon as child_group_members is called. Rails looked fine in the demo because the app only used the child_members method and never called the association. Adding source: :members fixed it.


The demo repo from the ticket can be found here

Script that replicates SystemStackError in rails
# frozen_string_literal: true

# Repro: a has_many :through with no `source:` whose only candidate source is
# itself raises SystemStackError in plain Active Record (no Tapioca/Sorbet).
#
#   ruby repro.rb                                       # latest Active Record
#   AR_VERSION=7.1.3.2 ruby repro.rb    # a specific version (needs Ruby < 4.0)

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"
  ar = ENV["AR_VERSION"].to_s
  gem "activerecord", *(ar.empty? ? [] : [ar])
  # Active Record < 7.2 only supports sqlite3 1.x
  gem "sqlite3", *(ar.empty? || Gem::Version.new(ar) >= Gem::Version.new("7.2") ? [] : ["~> 1.4"])
end

require "active_record"

$stdout.sync = true
puts "Ruby #{RUBY_VERSION}, Active Record #{ActiveRecord::VERSION::STRING}"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table(:groups)  { |t| t.integer :parent_id }
  create_table(:members) { |t| t.integer :group_id }
end

# Associations copied verbatim from the demo repo's app/models/member.rb and app/models/group.rb
# (Sorbet sigs and the hand-written child_members method left out).
class Member < ActiveRecord::Base
  belongs_to :group, class_name: "Group", primary_key: "id", foreign_key: "group_id"
end

class Group < ActiveRecord::Base
  has_many :sub_groups, class_name: "Group", primary_key: "id", foreign_key: "parent_id"
  belongs_to :parent_group, class_name: "Group", primary_key: "id", foreign_key: "parent_id", optional: true

  has_many :members, class_name: "Member", primary_key: "id", foreign_key: "group_id"

  has_many :child_group_members, through: :sub_groups # no `source:`
end

a = Group.create!
b = Group.create!(parent_id: a.id)
Member.create!(group_id: b.id)
puts "1. class loaded and records created fine"

reflection = Group.reflect_on_association(:child_group_members)
reflection.check_validity!
puts "2. check_validity! passes"
puts "3. source reflection is the association itself: #{reflection.source_reflection.equal?(reflection)}"

puts "4. calling a.child_group_members.to_a ..."
a.child_group_members.to_a # => SystemStackError (stack level too deep)
puts "   not reached"
Script output
Ruby 3.3.12, Active Record 7.1.3.2
1. class loaded and records created fine
2. check_validity! passes
3. source reflection is the association itself: true
4. calling a.child_group_members.to_a ...
/usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:122:in `_reflect_on_association': stack level too deep (SystemStackError)
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:967:in `through_reflection'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:951:in `source_reflection'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:1157:in `derive_class_name'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:184:in `class_name'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:1157:in `derive_class_name'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:184:in `class_name'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:1157:in `derive_class_name'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/reflection.rb:184:in `class_name'
	 ... 11900 levels...
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/associations/association.rb:218:in `ensure_klass_exists!'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/associations/collection_association.rb:33:in `reader'
	from /usr/local/bundle/gems/activerecord-7.1.3.2/lib/active_record/associations/builder/association.rb:104:in `child_group_members'
	from repro.rb:57:in `<main>'

Implementation

Since the association can't work as written, I treated it as a configuration issue that Tapioca surfaces as an error rather than falling back to T.untyped.

  • I added a check that raises SelfReferentialSourceReflectionError when the source reflection is the association itself, before .klass is called and starts recursing.

  • The new error is rescued in populate_associations and reported the same way as the missing source case, so the association is skipped and the rest of the RBI is still generated.


Further Considerations:

  • This only catches an association that is directly its own source. If they exist, longer cycles will still recurse (e.g. A -> B -> A)
    • This could be addressed using DFS and tracking the current path (provided below)

DFS cycle detection approach
def validate_reflection!(reflection)
  if reflection.through_reflection?
    raise CircularThroughReflectionError if circular_through_reflection?(reflection)
    raise SourceReflectionError unless reflection.source_reflection
  end

  ...
end

#: (ReflectionType? reflection, ?Set[ReflectionType] visiting) -> bool
def circular_through_reflection?(reflection, visiting = Set.new)
  return false unless reflection&.through_reflection?
  return true unless visiting.add?(reflection)

  circular = circular_through_reflection?(reflection.through_reflection, visiting) || circular_through_reflection?(reflection.source_reflection, visiting)
  visiting.delete(reflection)
  circular
end

Tests

  • has_many :through association whose source is itself: the association is left out of the RBI and the error is reported

@DaneHarrison DaneHarrison changed the title Add error and test for self-referential has_many :through Fixes SystemStackError for has_many assocation that points to own model Oct 6, 2026
@DaneHarrison
DaneHarrison marked this pull request as ready for review October 6, 2026 00:11
@DaneHarrison
DaneHarrison requested a review from a team as a code owner October 6, 2026 00:11

@paracycle paracycle 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.

Thanks for the detailed analysis and the fix. This implementation makes sense to me.

@paracycle
paracycle merged commit 9738d7f into Shopify:main Oct 6, 2026
18 of 19 checks passed
@DaneHarrison

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing and merging!

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SystemStackError for has_many assocation that points to own model

2 participants