Skip to content

Commit 2e7d998

Browse files
committed
Warn instead of aborting brew bundle on stale-tab dependency cycles
`brew bundle` sorts installed formulae into dependency order for display and iteration, feeding runtime dependencies from installed keg tabs into a TSort graph. Those recorded tab closures can disagree with the current formulae and form a cycle the live graph does not have (e.g. a keg built when `webp` depended on `libtiff`, against today's `libtiff` -> `webp`). `sort!` responded to `TSort::Cyclic` by `odie`ing the entire run, so a single stale tab aborts `brew bundle` for everyone. This sort only orders the list; `brew install` resolves its own real dependencies, so a degraded order is harmless. Every other sorter in the codebase degrades instead (`upgrade.rb`, `cask/installer.rb`). Warn and continue with a best-effort order from `each_strongly_connected_component` instead of aborting, and surface the real cyclic component from `strongly_connected_components` rather than parsing it out of the exception message. The reinstall remedy is kept as advice but softened, since it does not reliably clear these phantom cycles. The flipped spec drives a real cycle through fixtures so it exercises the `TSort::Cyclic` fallback end to end and fails if the hard exit returns.
1 parent 3d58a45 commit 2e7d998

2 files changed

Lines changed: 61 additions & 25 deletions

File tree

Library/Homebrew/bundle/brew.rb

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -426,24 +426,32 @@ def sort!(formulae)
426426
raise "formulae_by_full_name is nil" if @formulae_by_full_name.nil?
427427
raise "formulae_by_name is nil" if @formulae_by_name.nil?
428428

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
429+
sorted_names = begin
430+
topo.tsort
431+
rescue TSort::Cyclic
432+
# The graph is built from runtime dependencies recorded in installed
433+
# keg tabs, which can disagree with the current formulae and form a
434+
# cycle the live graph does not have (see Homebrew/homebrew-bundle#1513).
435+
# Warn and keep the bundle running with a best-effort order rather than
436+
# aborting. The reinstall remedy below does not reliably clear such cycles.
437+
cyclic = (topo.strongly_connected_components.find { |c| c.size > 1 } || [])
438+
.filter_map { |n| @formulae_by_full_name[n] || @formulae_by_name[n] }
439+
.uniq { |f| f[:full_name] }
440+
.map { |f| f[:full_name] }
441+
opoo <<~EOS
442+
Formulae dependency graph sorting failed (likely due to a circular dependency):
443+
#{cyclic.join(", ")}
444+
If this persists, run the following commands and try again:
445+
brew update
446+
brew uninstall --ignore-dependencies --force #{cyclic.join(" ")}
447+
brew install #{cyclic.join(" ")}
448+
EOS
449+
topo.each_strongly_connected_component.to_a.flatten
450+
end
451+
452+
@formulae = sorted_names
453+
.map { |name| @formulae_by_full_name[name] || @formulae_by_name[name] }
454+
.uniq { |f| f[:full_name] }
447455
end
448456
end
449457

Library/Homebrew/test/bundle/brew_spec.rb

Lines changed: 35 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(/dependency graph sorting failed/).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,13 @@
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+
expect(topo.each_strongly_connected_component.to_a.flatten).to contain_exactly("a", "b")
946+
end
919947
end
920948
end

0 commit comments

Comments
 (0)