Skip to content

Commit bc79adb

Browse files
authored
Merge pull request #23020 from bendrucker/bundle-cyclic-tolerance
Warn instead of aborting brew bundle on stale-tab dependency cycles
2 parents c474a98 + 7c4a719 commit bc79adb

5 files changed

Lines changed: 90 additions & 37 deletions

File tree

Library/Homebrew/bundle/brew.rb

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require "json"
55
require "tsort"
66
require "utils"
7+
require "utils/topological_hash"
78
require "utils/output"
89
require "bundle/package_type"
910
require "trust"
@@ -426,24 +427,27 @@ def sort!(formulae)
426427
raise "formulae_by_full_name is nil" if @formulae_by_full_name.nil?
427428
raise "formulae_by_name is nil" if @formulae_by_name.nil?
428429

429-
@formulae = topo.tsort
430-
.map { |name| @formulae_by_full_name[name] || @formulae_by_name[name] }
431-
.uniq { |f| f[:full_name] }
432-
rescue TSort::Cyclic => e
433-
e.message =~ /\["([^"]*)".*"([^"]*)"\]/
434-
cycle_first = Regexp.last_match(1)
435-
cycle_last = Regexp.last_match(2)
436-
odie e.message if !cycle_first || !cycle_last
437-
438-
odie <<~EOS
439-
Formulae dependency graph sorting failed (likely due to a circular dependency):
440-
#{cycle_first}: #{topo[cycle_first] if topo}
441-
#{cycle_last}: #{topo[cycle_last] if topo}
442-
Please run the following commands and try again:
443-
brew update
444-
brew uninstall --ignore-dependencies --force #{cycle_first} #{cycle_last}
445-
brew install #{cycle_first} #{cycle_last}
446-
EOS
430+
# Stale keg-tab dependency data can form a cycle the live graph does not
431+
# have (Homebrew/homebrew-bundle#1513), so warn and continue rather than
432+
# aborting the whole bundle.
433+
sorted = topo.tsort_with_cycles do |cycles|
434+
cyclic = cycles.flatten
435+
.filter_map { |name| @formulae_by_full_name[name] || @formulae_by_name[name] }
436+
.uniq { |f| f[:full_name] }
437+
.map { |f| f[:full_name] }
438+
opoo <<~EOS
439+
Formulae dependency graph sorting found a circular dependency:
440+
#{cyclic.join(", ")}
441+
This is usually caused by stale dependency data in installed keg tabs.
442+
If it persists, run the following commands and try again:
443+
brew update
444+
brew uninstall --ignore-dependencies --force #{cyclic.join(" ")}
445+
brew install #{cyclic.join(" ")}
446+
EOS
447+
end
448+
449+
@formulae = sorted.filter_map { |name| @formulae_by_full_name[name] || @formulae_by_name[name] }
450+
.uniq { |f| f[:full_name] }
447451
end
448452
end
449453

@@ -773,6 +777,7 @@ def upgrade_formula!(verbose:, force:)
773777
class Topo < Hash
774778
extend T::Generic
775779
include TSort
780+
include Utils::CycleTolerantTSort
776781

777782
K = type_member { { fixed: String } }
778783
V = type_member { { fixed: T::Array[String] } }

Library/Homebrew/cask/installer.rb

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -411,13 +411,10 @@ def cask_and_formula_dependencies
411411

412412
::Utils::TopologicalHash.graph_package_dependencies(pc.dependencies, graph)
413413

414-
begin
415-
@cask_and_formula_dependencies = graph.tsort - [@cask]
416-
rescue TSort::Cyclic
417-
strongly_connected_components = graph.strongly_connected_components.sort_by(&:count)
418-
cyclic_dependencies = strongly_connected_components.last - [@cask]
414+
@cask_and_formula_dependencies = graph.tsort_with_cycles do |cycles|
415+
cyclic_dependencies = cycles.sort_by(&:count).fetch(-1) - [@cask]
419416
raise CaskCyclicDependencyError.new(@cask.token, cyclic_dependencies.to_sentence)
420-
end
417+
end - [@cask]
421418
end
422419

423420
sig { returns(T::Array[T.any(Formula, ::Cask::Cask)]) }

Library/Homebrew/test/bundle/brew_spec.rb

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,26 @@
160160
}
161161
end
162162

163+
def formula_double_depending_on(name, dependency)
164+
instance_double(Formula,
165+
name:,
166+
desc: name,
167+
oldnames: [],
168+
full_name: name,
169+
any_version_installed?: true,
170+
aliases: [],
171+
runtime_dependencies: [instance_double(Dependency, name: dependency)],
172+
deps: [],
173+
conflicts: [],
174+
any_installed_prefix: nil,
175+
linked?: false,
176+
keg_only?: true,
177+
pinned?: false,
178+
outdated?: false,
179+
stable: instance_double(SoftwareSpec, bottle_defined?: false, bottled?: false),
180+
tap: instance_double(Tap, official?: false))
181+
end
182+
163183
before do
164184
described_class.reset!
165185
end
@@ -180,13 +200,13 @@
180200
expect(dumper.formulae_by_full_name("bar")).to eql({})
181201
end
182202

183-
it "exits on cyclic exceptions" do
184-
expect(Formula).to receive(:installed).and_return([foo, bar, baz])
185-
expect_any_instance_of(Homebrew::Bundle::Brew::Topo).to receive(:tsort).and_raise(
186-
TSort::Cyclic,
187-
'topological sort failed: ["foo", "bar"]',
188-
)
189-
expect { dumper.formulae_by_full_name }.to raise_error(SystemExit)
203+
it "warns and continues on cyclic dependencies instead of exiting" do
204+
cyclic_foo = formula_double_depending_on("foo", "bar")
205+
cyclic_bar = formula_double_depending_on("bar", "foo")
206+
expect(Formula).to receive(:installed).and_return([cyclic_foo, cyclic_bar])
207+
208+
expect { dumper.formulae_by_full_name }.to output(/found a circular dependency/).to_stderr
209+
expect(dumper.formulae.map { |f| f[:full_name] }).to contain_exactly("foo", "bar")
190210
end
191211

192212
it "returns a hash for a formula" do
@@ -916,5 +936,15 @@
916936

917937
expect(topo.tsort).to eq(["libice", "b", "a"])
918938
end
939+
940+
it "flattens a cyclic graph via strongly connected components without raising" do
941+
topo = described_class.new
942+
topo["a"] = ["b"]
943+
topo["b"] = ["a"]
944+
945+
cycles = []
946+
expect(topo.tsort_with_cycles { |c| cycles.concat(c) }).to contain_exactly("a", "b")
947+
expect(cycles).to eq([["a", "b"]])
948+
end
919949
end
920950
end

Library/Homebrew/upgrade.rb

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,12 @@ def formula_installers(
9393
end
9494

9595
dependency_graph = Utils::TopologicalHash.graph_package_dependencies(formulae_to_install)
96-
begin
97-
formulae_to_install = dependency_graph.tsort & formulae_to_install
98-
rescue TSort::Cyclic
99-
if Homebrew::EnvConfig.developer?
100-
raise CyclicDependencyError, dependency_graph.strongly_connected_components
101-
end
96+
sorted = dependency_graph.tsort_with_cycles do |cycles|
97+
raise CyclicDependencyError, cycles if Homebrew::EnvConfig.developer?
98+
99+
odebug "Ignoring cyclic dependencies: #{cycles.map(&:to_sentence).join(", ")}"
102100
end
101+
formulae_to_install = sorted & formulae_to_install
103102

104103
# We need to fetch the bottle tabs ahead of the `Install.fetch_formulae`
105104
# pipeline because we need to first filter out those formulae with all

Library/Homebrew/utils/topological_hash.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,32 @@
44
require "tsort"
55

66
module Utils
7+
# Cycle-tolerant ordering for graphs that include TSort.
8+
module CycleTolerantTSort
9+
extend T::Helpers
10+
11+
requires_ancestor { TSort }
12+
13+
# Orders nodes dependency-first like tsort but, unlike tsort, does not
14+
# raise on a cycle: yields cyclic components (size > 1) to the block and
15+
# returns the flattened component order.
16+
sig {
17+
params(on_cycle: T.proc.params(arg0: T::Array[T::Array[T.untyped]]).void)
18+
.returns(T::Array[T.untyped])
19+
}
20+
def tsort_with_cycles(&on_cycle)
21+
components = each_strongly_connected_component.to_a
22+
cycles = components.select { |component| component.size > 1 }
23+
yield(cycles) if cycles.any?
24+
components.flatten
25+
end
26+
end
27+
728
# Topologically sortable hash map.
829
class TopologicalHash < Hash
930
extend T::Generic
1031
include TSort
32+
include CycleTolerantTSort
1133

1234
CaskOrFormula = T.type_alias { T.any(Cask::Cask, Formula) }
1335

0 commit comments

Comments
 (0)