Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/dry/system/container.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "dry/configurable"
require "dry/auto_inject"
require "dry/inflector"
require "dry/system/cyclic_dependency_detector"

module Dry
module System
Expand Down Expand Up @@ -493,11 +494,22 @@ def register(key, *)
self
end

# Resolves a component by its key, loading it if necessary.
#
# In case of a cyclic dependency, it will detect the cycle
# and replace the error with CyclicDependencyError providing
# information on the dependency loading cycle.
#
# @api public
def resolve(key)
load_component(key) unless finalized?

super
rescue SystemStackError => e
cycle = CyclicDependencyDetector.detect_from_backtrace(e.backtrace)
e = CyclicDependencyError.new(cycle) if cycle.any?

raise e
end

alias_method :registered?, :key?
Expand Down
69 changes: 69 additions & 0 deletions lib/dry/system/cycle_visualization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module Dry
module System
# Generates ASCII art visualizations for dependency cycles
#
# @api private
class CycleVisualization
# Generates ASCII art for a dependency cycle
#
# @param cycle [Array<String>] Array of component names forming the cycle
# @return [String] ASCII art representation of the cycle
#
# @api private
def self.generate(cycle) = new(cycle).generate

# @api private
def initialize(cycle) = @cycle = cycle

# @api private
def generate
return "" if cycle.empty?

case cycle.length
when 2
generate_bidirectional_arrow
when 3, 4
generate_small_cycle
else
generate_large_cycle
end
end

private

attr_reader :cycle

def generate_bidirectional_arrow
"#{cycle[0]} ◄──► #{cycle[1]}"
end

def generate_small_cycle
components = cycle + [cycle[0]] # Complete the cycle
cycle_lines = components.each_cons(2).map { |a, b| "#{a} ───► #{b}" }

cycle_text = cycle_lines.join("\n")
visual_return_arrow = build_visual_return_arrow(components[-2].length)

"#{cycle_text}\n#{visual_return_arrow}"
end

def generate_large_cycle
cycle_text = cycle.join(" ───► ")
cycle_text += " ───► #{cycle[0]}"

visual_return_arrow = build_visual_return_arrow(cycle_text.length - cycle[0].length - 8)

"#{cycle_text}\n#{visual_return_arrow}"
end

def build_visual_return_arrow(width)
arrow_up = "▲#{" " * (width + 6)}│"
arrow_line = "└#{"─" * (width + 6)}┘"

"#{arrow_up}\n#{arrow_line}"
end
end
end
end
110 changes: 110 additions & 0 deletions lib/dry/system/cyclic_dependency_detector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

module Dry
module System
# Detects cyclic dependencies from SystemStackError backtraces
#
# @api private
class CyclicDependencyDetector
# Detects cyclic dependencies from SystemStackError backtrace
#
# @param backtrace [Array<String>] The backtrace from SystemStackError
# @return [Array<String>] Array of component names forming the cycle
#
# @api private
def self.detect_from_backtrace(backtrace)
new(backtrace).detect_cycle
end

# @api private
def initialize(backtrace)
@backtrace = backtrace
end

# @api private
def detect_cycle
component_files = extract_component_files
unique_components = component_files.uniq

# If we have repeated component names, we likely have a cycle
if repeated_components?(component_files, unique_components)
cycle = find_component_cycle(component_files)
return cycle if cycle.any?
end

# Fallback: if we have multiple unique components in the stack, assume
# they form a cycle
return unique_components.first(4) if unique_components.length >= 2

[]
end

private

attr_reader :backtrace

def extract_component_files
component_files = []

backtrace.each do |frame|
# Extract component information: file name and method name
_, file_name, method_name = frame.match(%r{/([^/]+)\.rb:\d+:in\s+`([^']+)'}).to_a
next unless file_name && method_name

# Skip system/framework files
next if system_file?(file_name, frame)

# Focus on initialize methods which are likely where dependency cycles occur
component_files << file_name if component_creation_method?(method_name)
end

component_files
end

def system_file?(file_name, frame)
file_name.start_with?("dry-", "loader", "component", "container") ||
frame.include?("/lib/dry/") ||
frame.include?("/gems/")
end

def component_creation_method?(method_name)
method_name == "initialize" || method_name == "new"
end

def repeated_components?(component_files, unique_components)
component_files.length > unique_components.length && unique_components.length >= 2
end

def find_component_cycle(component_files)
return [] if component_files.length < 4

# Look for patterns where the same component sequence repeats
(2..component_files.length / 2).each do |pattern_length|
pattern = component_files[-pattern_length..]
repeat_count = count_pattern_repetitions(component_files, pattern, pattern_length)

# If we found at least 2 repetitions, this is likely a cycle
return pattern.uniq if repeat_count >= 1
end

[]
end

def count_pattern_repetitions(component_files, pattern, pattern_length)
repeat_count = 0
start_pos = component_files.length - pattern_length

while start_pos >= pattern_length
if component_files[start_pos - pattern_length, pattern_length] == pattern
repeat_count += 1
start_pos -= pattern_length
else
break
end
end

repeat_count
end
end
end
end
20 changes: 20 additions & 0 deletions lib/dry/system/errors.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "dry/system/cycle_visualization"

module Dry
module System
# Error raised when import is called on an already finalized container
Expand Down Expand Up @@ -128,5 +130,23 @@ def initialize(component, error,
super(message.join("\n"))
end
end

# Error raised when components have cyclic dependencies
#
# @api public
CyclicDependencyError = Class.new(StandardError) do
# @api private
def initialize(cycle)
cycle_visualization = CycleVisualization.generate(cycle)

super(<<~ERROR_MESSAGE)
These dependencies form a cycle:

#{cycle_visualization}

You must break this cycle in order to use any of them.
ERROR_MESSAGE
end
end
end
end
10 changes: 10 additions & 0 deletions spec/fixtures/cyclic_components/lib/cycle_bar.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

require_relative "cycle_foo"

class CycleBar
def initialize
# This creates the cycle: CycleBar -> CycleFoo -> CycleBar
CycleFoo.new
end
end
10 changes: 10 additions & 0 deletions spec/fixtures/cyclic_components/lib/cycle_foo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

require_relative "cycle_bar"

class CycleFoo
def initialize
# This creates the cycle: CycleFoo -> CycleBar -> CycleFoo
CycleBar.new
end
end
7 changes: 7 additions & 0 deletions spec/fixtures/cyclic_components/lib/safe_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class SafeComponent
def initialize
# No dependencies, no cycles
end
end
32 changes: 32 additions & 0 deletions spec/integration/container/cyclic_dependencies_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

require "dry/system/container"

RSpec.describe "Cyclic dependency detection" do
let(:container) { Test::Container }

before do
class Test::Container < Dry::System::Container
configure do |config|
config.root = SPEC_ROOT.join("fixtures/cyclic_components").realpath
config.component_dirs.add "lib"
end
end
end

context "with existing cyclic fixtures" do
it "detects the cycle and raises CyclicDependencyError" do
expect { container["cycle_foo"] }.to raise_error(Dry::System::CyclicDependencyError) do |error|
expect(error.message).to include("These dependencies form a cycle:")
expect(error.message).to include("You must break this cycle")
end
end
end

context "when there are no cycles" do
it "loads components normally without error" do
expect { container["safe_component"] }.not_to raise_error
expect(container["safe_component"]).to be_a(SafeComponent)
end
end
end
Loading