Warn instead of aborting brew bundle on stale-tab dependency cycles#23020
Conversation
`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.
There was a problem hiding this comment.
Pull request overview
Updates brew bundle’s formula dependency ordering so stale keg-tab dependency cycles no longer abort the entire run, instead warning and continuing with a best-effort order derived from strongly connected components.
Changes:
- Replace
TSort::Cyclichard-exit during formula sorting with anopoowarning + fallback ordering. - Improve cycle reporting by using strongly connected components rather than parsing the exception message.
- Update/extend specs to assert the new “warn and continue” behavior on cyclic graphs.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| Library/Homebrew/bundle/brew.rb | Changes cycle handling in formula dependency sorting to warn and continue with SCC-based fallback ordering. |
| Library/Homebrew/test/bundle/brew_spec.rb | Updates expectations to ensure cyclic dependency sorting no longer exits, and adds coverage around SCC behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Addressed the Copilot review (both threads resolved). Its concern was that a self-referential dependency would still raise t = Topo.new
t["a"] = ["a"]
t.tsort # => ["a"], no raiseA real two-node cycle ( |
MikeMcQuaid
left a comment
There was a problem hiding this comment.
Thanks for PR! I agree this is a problem that should be fixed but I'm not (yet) convinced this is the best/right solution yet.
|
Fair enough let me scrutinize this harder and try to think up some alternatives. Will reply to the comments as well. |
Sorting installed formulae by dependency built the graph from runtime dependencies recorded in installed keg tabs, which can disagree with the current formulae and form a cycle the live graph does not have. On such a cycle sort! aborted the whole bundle run via odie. Order with each_strongly_connected_component instead: it matches tsort dependency-first order for an acyclic graph but does not raise on a cycle. When a cycle exists, warn naming the full cyclic component and continue, rather than aborting.
|
Reworked, definitely a better way to go about this. Couple notes:
|
MikeMcQuaid
left a comment
There was a problem hiding this comment.
Thanks. Messaging looking better! Getting closer.
brew bundle, brew upgrade and cask installs each handled TSort::Cyclic differently: bundle warned and continued, upgrade raised for developers but silently dropped topological ordering otherwise, and cask installs aborted. Extract the strongly-connected-component handling into Utils::CycleTolerantTSort#tsort_with_cycles and use it at all three call sites, keeping per-site severity: bundle still warns, cask installs still abort, and upgrade now falls back to the flattened component order with a debug message instead of losing ordering entirely.
MikeMcQuaid
left a comment
There was a problem hiding this comment.
Thanks, this is a really nice cleanup as well as a user improvement, glad we had a few rounds of back and forth on this. Great work @bendrucker!
brew bundlesorts installed formulae into dependency order for display and iteration. The edges come from each installed keg's recorded runtime dependencies (tab.json), not the live formula graph. Those recorded closures are frozen at install time, so kegs of different vintages can disagree and form a cycle the current formulae don't have (e.g. awebpkeg built whenwebpdepended onlibtiff, against today'slibtiff->webp).I hit this here:
https://github.com/bendrucker/dotfiles/actions/runs/28981041126/job/85999630681#step:10:45
sort!responded to the cycle byodieing the entire run, so one stale tab abortsbrew bundleregardless of the Brewfile. But this sort only orders a display/iteration list.brew installresolves its own dependencies, so a degraded order is harmless, andbrew bundlealready tolerates this same tab-derived dependency data being cyclic elsewhere (parallel_installerfalls back to sequential scheduling rather than failing).This orders with
each_strongly_connected_componentinstead. It yields nodes dependency-first liketsortbut does not raise on a cycle, and for an acyclic graph the result is identical totsort, so normal runs are unchanged. When a cycle exists it collapses into one component:brew bundlewarns, names that component, and continues.The warning now names the full cyclic component rather than the two endpoints the old code parsed out of the exception message. The stale edge can live on any member's tab, so reinstalling only the two named formulae often left the cycle in place (homebrew-bundle#1513). Naming every member makes the suggested
uninstall/installrewrite each tab in the cycle.The flipped spec drives a real cycle through fixtures so it exercises the fallback end to end and fails if the hard exit returns.
brewcommands to reproduce the bug?brew lgtm(style, typechecking and tests) locally?Claude Code / Opus 4.8
Verification Steps:
maintheodieaborts and the example raisesSystemExit; it passes with the fix.each_strongly_connected_componentyields the same dependencies-first order astsort, and the.map/.uniqmapping is untouched, so normal runs produce identical output.webp/libtiffformulae are one-directional, so the reported cycle comes from a stale keg tab rather than the current formulae.An end-to-end repro needs an installed keg carrying a stale tab, which isn't practical in CI, so the flipped spec is the regression guard rather than an integration test.