Skip to content

bundle: fix rack lock conflicts and ordering in parallel installs - #23386

Draft
dduugg wants to merge 6 commits into
mainfrom
bundle-formula-rack-lock-conflicts
Draft

bundle: fix rack lock conflicts and ordering in parallel installs#23386
dduugg wants to merge 6 commits into
mainfrom
bundle-formula-rack-lock-conflicts

Conversation

@dduugg

@dduugg dduugg commented Jul 31, 2026

Copy link
Copy Markdown
Member

What does this change do, and why?

Fixes one of the causes of the still-reproducing report on #23328 (#23328 (comment)). Further causes are listed under Notes for reviewers and are not fixed here. #23342 and #23343 fixed DownloadLock collisions. This one is a FormulaLock, which those PRs deliberately left fail-fast, so neither applies:

Error: A `brew install --formula dust` process has already locked /home/linuxbrew/.linuxbrew/Cellar/xz.

/…/Cellar/xz is a FormulaLock (FormulaLock.new(rack_name) locks HOMEBREW_CELLAR/rack_name), and the advice line ("Please wait for it to finish…" rather than "Gave up after waiting N seconds…") pins the raise to plain LockFile#lock, not DownloadLock#lock_or_wait.

ParallelInstaller#build_dependency_map decides which Brewfile entries may install concurrently. Its model of what a child process locks was incomplete in three ways, and its ordering edges pointed two different directions. Each is fixed here.

1. An entry's own rack was missing from its lock set. FormulaInstaller#lock locks the formula it is installing and all of its recursive dependencies. The scheduler intersected only the recursive dependency sets. xz has no dependencies of its own, so its set was empty, it intersected nothing and it got zero ordering edges, while almost every other entry has xz in its recursive set and locks Cellar/xz.

2. Cask entries modelled no rack locks at all. Cask::Installer runs a FormulaInstaller for each missing depends_on formula: dependency, and those take the same rack locks a brew entry would. Casks were compared on Brewfile cask tokens instead, a different namespace, so a cask never conflicted with a formula. Casks now report the racks their formula dependencies lock, expanded through the same Brew.lock_names a formula entry uses. This matters because TopologicalHash.graph_package_dependencies prunes build and test dependencies while FormulaInstaller#lock does not, so collecting that graph's formulae alone still under-reports: cask "droid" reaches 28 racks, not the 2 its direct dependencies suggest.

3. Cask-on-cask ordering could never fire. It was expressed as an intersection of Brewfile cask tokens, which for cask "a" depending on cask "b" gives {"b"} against {} and never intersects. It is now a declared dependency edge via a new Cask.cask_dependencies.

4. Conflict edges were oriented by Brewfile order, not dependency order. Declared-dependency edges follow the dependency graph, while the pairwise conflict rule always made the later Brewfile entry wait on the earlier one. A Brewfile listing a dependent before its dependency therefore cycled whenever that dependency has recursive dependencies of its own, ready_entries was empty on the first iteration and run! silently installed everything serially, ignoring --jobs. brew "wget" before brew "openssl@3" does this on unpatched main with no involvement from the rack change; 7 of 25 randomly generated four-entry Brewfiles drawn from common core formulae hit the fallback. A leaf dependency such as xz escaped it, because the back-edge needed a non-empty recursive-dependency intersection. Conflict edges are now oriented by dependency-first topological position, so every edge points the same way and the graph is acyclic whenever the declared dependencies are.

A cycle in the declared dependencies is still tolerated rather than fatal. tsort_with_cycles keeps the rest of the order usable, and the cyclic entries are now named with opoo rather than only under --debug, since the consequence is a visible loss of parallelism. Brew.sort! already explains the usual cause, stale keg tab dependency data, so that advice is not repeated.

Two supporting points, neither a behaviour change. The attestation edge that makes entries wait on a Brewfile gh is now added after the declared-dependency graph is built and skipped for entries gh itself reaches, so it cannot close a cycle. gh's only dependency is a build dependency, so it appears in no ordering edge and the resulting order is identical to main on every Brewfile I tried; the guard matters if gh ever gains a runtime dependency that is also a Brewfile entry. Separately, build_dependency_map keeps main's shape rather than being split into helpers, and Brew::Topo stays where it is, after review feedback on indirection.

Step-by-step reproduction

All three reproduce on macOS with no container. The first is the reporter's own Brewfile.minimal from #23328:

$ printf 'brew "bat"\nbrew "xz"\n' > /tmp/Brewfile && brew bundle install --file=/tmp/Brewfile --jobs 4

bat reaches xz through rust then llvm, so its child process locks Cellar/xz, but xz has no dependencies of its own and so gets no ordering edge.

dependency map batches
main {"bat" => [], "xz" => []} [["bat", "xz"]]
this branch {"bat" => [], "xz" => ["bat"]} [["bat"], ["xz"]]

The cask case needs a cask whose formula dependencies reach a Brewfile formula:

$ printf 'brew "xz"\ncask "superslicer"\n' > /tmp/Brewfile && brew bundle install --file=/tmp/Brewfile --jobs 4

superslicer declares zstd, which reaches xz.

batches
main [["xz", "superslicer"]]
this branch [["xz"], ["superslicer"]]

The edge orientation case needs a Brewfile listing a dependent before its dependency:

$ printf 'brew "wget"\nbrew "openssl@3"\n' > /tmp/Brewfile && brew bundle install --file=/tmp/Brewfile --jobs 4
dependency map batches
main {"wget" => ["openssl@3"], "openssl@3" => ["wget"]} sequential fallback, both entries
this branch {"wget" => ["openssl@3"], "openssl@3" => []} [["openssl@3"]], [["wget"]]

Notes for reviewers

More places where the scheduler's model of what a child process locks is still incomplete. These are separate causes, so they are not touched here and I am happy to open follow-ups:

  • Duplicate Brewfile entry names produce this identical error from a two-line Brewfile. The map is keyed by name, so brew "xz" twice collapses to one key while run! keeps two distinct entries and schedules them together. Still reproduces on this branch. Needs entry deduplication in Bundle::Installer.
  • Two more paths lock racks outside the modelled forward dependency set. Upgrade.upgrade_dependents reinstalls dependents, so it locks reverse dependencies. FormulaInstaller#link calls Unlink.unlink_link_overwrite_formulae, which takes a Keg#lock on every linked link_overwrite formula, and link_overwrite is not a dependency relation. Keg#lock also locks the oldname rack of whatever it locks, which the scheduler never models either.
  • Formula[] can raise TapFormulaAmbiguityError, FormulaSpecificationError or UntrustedTapError, none of which descend from FormulaUnavailableError, so they escape the rescue and abort the whole brew bundle install before anything is installed. Pre-existing and unchanged by this PR.

The bigger limit on how parallel this can ever be, which no amount of scheduling fixes: FormulaInstaller#lock locks the formula's entire recursive dependency closure, with no check for whether a dependency is already installed. Two entries therefore contend whenever they share any transitive dependency. On a 15-entry Brewfile of common formulae on macOS, with no sandbox involvement at all, 63 of the 105 pairs share a rack, which is 10 batches at --jobs 8.

On Linux without a system bwrap the implicit bubblewrap dependency makes that total rather than partial, though that dependency is expected to go away soon.

The scheduler can only serialize correctly here, not avoid the contention, because the contention is in what the child process locks. Removing it means not locking dependencies that are already installed and not being modified: the unless ignore_deps? guard in FormulaInstaller#lock already skips the closure, and with entries' own racks alone the same Brewfile has 0 of 105 conflicting pairs. Reaching that through --ignore-dependencies is not an option, since brew install calls it "an unsupported Homebrew developer option", so it would mean narrowing lock itself, or making a dependency lock shared rather than exclusive. Either is a formula_installer change rather than a bundle one and belongs in its own PR.

There is a larger question about whether this scheduler should exist at all, raised in review and worth stating here. brew bundle shells out to one brew install per entry, and each child builds its own DownloadQueue sized at HOMEBREW_DOWNLOAD_CONCURRENCY, which defaults to twice the CPU count. So --jobs 4 on a 12-core machine is 4 processes of 24 download threads each. Meanwhile a single brew install X Y Z already downloads and pours in parallel through one shared queue, and because FormulaInstaller.locked is class-level with an early return, it takes rack locks exactly once and cannot contend with itself. Passing the whole Brewfile to one brew install would therefore delete this entire class of bug rather than model around it. It is not a drop-in replacement, since bundle varies args, --force, --overwrite and --skip-link per entry, splits formulae from casks, and chooses install against upgrade per entry, so it would need grouping by action and flags plus per-entry failure attribution. This PR fixes the bug that is live today; it is not an argument against that being the better long-term shape.


  • Have you followed our Contributing guidelines?
  • Have you checked for other open Pull Requests for the same change?
  • Have you explained what your changes do? Performance claims (e.g. "this is faster") must include Hyperfine benchmarks.
  • Have you explained why you'd like these changes included, not just what they do?
  • For bug fixes, have you given step-by-step brew commands to reproduce the bug?
  • Have you written new tests (excluding integration tests)? Here's an example.
  • Have you successfully run brew lgtm (style, typechecking and tests) locally?

  • I did not use AI/LLM to create this PR, or I disclosed the tool/model below and reviewed its output; I did not attribute commits to AI and will answer maintainer questions and review comments myself without AI/LLM.

AI was used. Claude Code (Claude Opus) was used to investigate the report on #23328, identify which lock was actually contended, implement the change and write the tests, then to re-review the result across several passes. Verification, all of which I ran and read myself:

  • brew lgtm clean (typecheck, style --changed, tests --changed), plus the spec files of callers outside the changed-file mapping, which --changed does not select.
  • Every new example checked with a red/green cycle at its exact --only=file:line target, by reverting the corresponding production change and confirming the failure was the expected one, including the cask rack expansion, the cask attestation edge, cask-on-cask ordering and the cycle warning text.
  • Every before/after figure above measured by driving build_dependency_map against the real dependency graph at main and at this branch, then replaying run!'s batching loop over the result. Not against mocks.
  • Confirmed against the real dependency graph that recursive_dependencies keeps :build dependencies at every level (which is why xz is in dust's set at all, via rust then llvm), that graph_package_dependencies does not, and that FormulaInstaller#lock keys on Formula#name rather than the requested name.
  • End to end on real x86_64 Linux in a Debian 13 container built from the reporter's Dockerfile, running the unpatched commit and the patched one back to back over the reporter's 19-entry Brewfile, including reproducing the reported dust/xz collision on unpatched main.

One correction worth stating, since it bears on how much weight to give the rest. A mid-PR commit claimed Brew.lock_names under-reported racks for aliased dependencies and resolved each dependency through to_formula. That was wrong: Dependency.expand already canonicalises aliases and renames via dup_with_formula_name, so the change was a no-op. The probe that appeared to justify it built a Dependency by hand, which never passes through expand. It was reverted after an element-by-element comparison across all 8,527 core formulae and 156,807 dependency edges found zero differing results. brew audit also rejects a bare alias dependency outright, so no core formula could exhibit the case regardless. I reviewed the full diff by hand before pushing.

@dduugg
dduugg force-pushed the bundle-formula-rack-lock-conflicts branch from fd7e696 to 7abcde1 Compare July 31, 2026 21:45
@dduugg
dduugg marked this pull request as ready for review July 31, 2026 22:34
Copilot AI review requested due to automatic review settings July 31, 2026 22:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request fixes brew bundle parallel-install scheduling so that entries which will contend on the same FormulaLock rack are not scheduled concurrently, including cases where the contended rack is the formula being installed (not just one of its dependencies). It also re-orients lock-conflict edges to match dependency-first ordering to avoid cycles that previously forced a silent fully-serial fallback.

Changes:

  • Replace recursive-dependency-based conflict modelling with lock_names (formula’s own rack + recursive dependency racks, normalised to rack names).
  • Order lock-conflict edges by a dependency-first topological order (cycle-tolerant) instead of Brewfile order.
  • Add unit tests covering: dependency-free formula conflicts, edge orientation, cycle diagnostics, and lock-name normalisation/alias handling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
Library/Homebrew/bundle/parallel_installer.rb Builds lock-conflict sets including each formula’s own rack and orients conflict edges using a dependency-first topo order (with cycle diagnostics).
Library/Homebrew/bundle/brew.rb Adds lock_names helper to return canonical rack names for a formula and its recursive dependencies (with unavailable-formula fallback).
Library/Homebrew/test/bundle/brew_spec.rb Adds focused tests for Brew.lock_names normalisation and unavailable-formula fallback behaviour.
Library/Homebrew/test/bundle/installer_spec.rb Updates scheduler specs to use lock_names and adds coverage for the newly fixed race/orientation/cycle cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@dduugg
dduugg marked this pull request as draft July 31, 2026 23:35
dduugg added 4 commits July 31, 2026 16:48
`FormulaInstaller#lock` locks the formula it is installing as well as all of
its recursive dependencies, but `brew bundle`'s parallel scheduler decided
which entries may run together by intersecting only the recursive dependency
sets. A formula with no dependencies of its own, e.g. `xz`, therefore
conflicted with nothing and was scheduled alongside entries whose installs
lock `Cellar/xz`, so the loser died with `OperationInProgressError`.

Compare the full set of racks each install locks instead. Racks are named
after `Formula#name`, so resolve aliases and strip the tap prefixes that
`Dependency#name` reports, otherwise a Brewfile `python` entry never matches
the `python@3.14` that other formulae depend on.

Including each entry's own name also lets a declared dependency edge and an
order-based conflict edge point in opposite directions, e.g. for a Brewfile
listing `python` before `xz`, which cycles and strands every entry in `run!`'s
sequential fallback. Orient conflict edges by a dependency-first topological
position rather than Brewfile position so that every edge in the graph points
the same way.
Follow-up review fixes on top of the lock conflict change:

- The cycle callback in Phase 5 discarded the detected cycle, so a Brewfile
  whose declared dependencies disagree degraded to a fully serial install with
  nothing to explain why. Name the cyclic entries with `odebug`, matching how
  `Brew.sort!` already reports the same class of cycle.
- `entry_name_map` and `position` were inferred as `T.untyped`, which left
  `build_dependency_map`'s declared `T::Hash[String, T::Set[String]]` return
  unverified. Annotate both so the signature is actually checked.
- Rename the Phase 7 block variable to `other`: the loop walks entries in
  Brewfile order and filters to earlier ones by topological position, so
  `earlier` described the filter rather than the iteration.
- Cover the two behaviours the new tests did not distinguish: tap-prefixed
  `Dependency#name` values being reduced to rack names, and the implicit
  pioneer being chosen in dependency order rather than Brewfile order (the
  latter builds a cycle under the previous `entries.find`).
Phase 7 built its result with `each_with_object({})`, whose inferred type
is a bare shape, so `build_dependency_map`'s declared
`T::Hash[String, T::Set[String]]` return was satisfied vacuously.
Building it with `entries.to_h` types the result without a fourth
`T.let`. Injecting a non-`Set` value now fails `brew typecheck`, where
against `each_with_object` it passed.

Cover the two behaviours that were left unpinned:

- `tsort_with_cycles` in place of plain `tsort`, together with the
  `odebug` that names the cyclic entries. A Brewfile listing `gh`
  alongside one of `gh`'s own dependencies closes a loop, because
  `HOMEBREW_VERIFY_ATTESTATIONS` makes `gh` a declared dependency of
  every other entry, and plain `tsort` raises `TSort::Cyclic` there.
- the tap prefix strip in the `FormulaUnavailableError` fallback of
  `lock_names`, which the existing example missed because it used a
  name with no tap to strip.

Also give the implicit pioneer example a lock set that `lock_names`
could actually return: `alpha` declares a dependency on `beta`, so
`beta` is necessarily in `alpha`'s recursive set.
Follow-up to review of the parallel install scheduler. Four correctness
gaps and the readability problem that hid them.

`Brew.lock_names` resolved aliases for the entry itself but ran recursive
dependencies through `Utils.name_from_full_name`, which strips tap prefixes
without resolving aliases. `Dependency#name` is the string passed to
`depends_on`, so `depends_on "python"` reported a `python` rack while the
rack `FormulaInstaller#lock` takes is `python@3.14`: the two lock sets never
intersected and the racing installs this PR set out to serialize still ran
in parallel. Resolve each dependency through its formula instead. The
method-level rescue also covered `recursive_dependencies`, so one
unresolvable transitive dependency discarded every already-resolved rack
name; it now falls back per-dependency and warns like
`formulae_by_full_name` does.

The attestation edge made every entry wait on the Brewfile's `gh`,
including the entries `gh` itself depends on. That is a guaranteed cycle,
and `run!` answers a cycle by installing the whole Brewfile serially, so
`HOMEBREW_VERIFY_ATTESTATIONS` plus `brew "gh"` alongside any of its
dependencies silently ignored `--jobs`. Skip the edge when `gh` already
locks everything the entry locks, which is exactly when `gh` depends on it.

Cask entries were compared on Brewfile cask tokens, never the formula racks
a cask install locks, so a cask depending on formula `xz` still raced an
entry that locks `xz`. Casks now report the racks `Cask::Installer` takes,
built from the same dependency graph it resolves them with, and cask-on-cask
ordering becomes a declared edge rather than a token-set intersection that
could not fire.

Cycle diagnostics were `odebug` while the consequence is a visible
degradation, so use `opoo` with remediation advice as `Brew.sort!` does.

`build_dependency_map` had grown to seven numbered phases in ~110 lines;
split it into one helper per phase. `Brew::Topo` moves to
`Utils::StringTopologicalHash` next to the graph it wraps, so the scheduler
depends on the shared utility rather than reaching sideways into the formula
dumper, and `sorted_names` recovers the node type that TSort's untyped
interface discarded.
@dduugg
dduugg force-pushed the bundle-formula-rack-lock-conflicts branch from 0a1a2ac to 39acadc Compare July 31, 2026 23:52
Review follow-up. Two of the previous commit's fixes were wrong and a third
was a no-op sold on a false premise.

The attestation guard asked whether `gh`'s rack closure covered an entry, but
the cycle it protects against lives in the declared-dependency graph, and
those are different graphs: lock closures come from `recursive_dependencies`
and include build dependencies, while ordering edges come from
`formula_dep_names`, which is runtime-only. `gh`'s sole dependency `go` is a
build dependency, so `formula_dep_names("gh")` is empty and no attestation
edge can close a cycle through it at all. The superset test fired anyway,
because `{gh, go}` covers `{go}`, and dropped the one edge that sequences
`gh`: `prepare_attestation_verification!` deliberately skips the upfront
bootstrap when `gh` is a Brewfile entry. For `brew "go"` plus `brew "gh"`
that inverted the two, leaving `go` to verify an attestation with no `gh`
installed. Decide on reachability in the graph the cycle would form in
instead, which also subsumes the cask special case: a cask is never in the
formula dependency graph, so it always keeps the edge.

`Cask.lock_names` collected the `Formula` nodes of
`graph_package_dependencies`, which prunes build and test dependencies, but
`FormulaInstaller#lock` locks the full `recursive_dependencies`. That dropped
exactly the dependencies this PR is about: the first commit's own example,
`xz` reaching `dust` through `rust` and `llvm`, is a build chain. `cask
"droid"` reported 2 racks against a ground truth of 28, so `brew "xz"` plus
`cask "droid"` still raced. Expand each formula through `Brew.lock_names`.

Resolving each dependency through `to_formula` in `Brew.lock_names` was a
no-op: `Dependency.expand` already canonicalises aliases and renames through
`dup_with_formula_name`, so `Formula["ipython"].recursive_dependencies`
already reports `python@3.14` for `depends_on "python"`. It only doubled
`Formulary.factory` calls, and the per-dependency rescue was unreachable
because an unresolvable transitive dependency makes the expansion raise
before it returns. Reverted to stripping the tap prefix. The split rescue
stays: it keeps the formula's own canonical rack when the tree cannot be
expanded, where the old single rescue fell back to the requested name, which
may be an alias that names no rack.

The cycle warning asked the user to report a Brewfile that declares a
dependency loop, which the DSL cannot express, and repeated remediation that
`Brew.sort!` already prints for the same stale keg tab data in the same run.

Adds the coverage those behaviours lacked: each of the cask rack expansion,
the cask attestation edge, the cask-on-cask ordering edge and the warning
text now fails when reverted, as does the `go`/`gh` ordering.

`implicit_pioneer` is inlined, `StringTopologicalHash` defines
`tsort_each_node` privately like its sibling instead of overriding
`each_key` behind a rubocop disable, and `merge_lock_conflicts` defaults its
lookups.

Two corrections to the previous commit message: the phase split is five
helpers rather than one per phase, because the name map, declared
dependencies and their resolution are one job, and it moves coupling to the
formula dumper up rather than down, since the scheduler now requires
`bundle/brew` and `bundle/cask` explicitly instead of inheriting them. Its
justification is type hygiene, not readability: eleven `T.let` before the
split, seven after, and the one it removed from `position` was an assertion
nothing checked.

@MikeMcQuaid MikeMcQuaid 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! A few general comments:

  • some of the Ruby commends seem a little excessive
  • I think the readability would be improved with less indirection e.g. fewer single-use functions and single-use classes not in the same file of their use
  • I'm thinking this might be the last time we try this before we just rip out this parallel functionality

Comment thread Library/Homebrew/utils/topological_hash.rb Outdated
Addresses review feedback on comment volume and indirection.

`build_dependency_map`'s five single-use helpers are inlined back into it, per
the repo guideline to inline methods that are not reused twice or needed for
unit tests. None were called more than once and no spec targeted them
directly; the specs drive `build_dependency_map` itself, so coverage is
unchanged.

`Utils::StringTopologicalHash` is dropped and `Brew::Topo` restored exactly as
it was, so `utils/topological_hash.rb` and its spec are untouched by this
branch again. The two classes differed only in node type, `String` against
`CaskOrFormula`, which is not enough to justify a second class sitting in a
different file from either caller.

Comments in the changed hunks are cut to the reasons that are not evident from
the code. No behaviour change: every scheduling case this PR fixes produces
the same dependency map as before, and the attestation cases still match
`main` exactly.
@dduugg dduugg changed the title bundle: include a formula's own rack in lock conflict detection bundle: fix rack lock conflicts and ordering in parallel installs Aug 1, 2026
@dduugg

dduugg commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Thanks, all three taken.

Comments and indirection. Fixed in fcb8afa. The five single-use helpers I'd split out of build_dependency_map are inlined back into it. AGENTS.md guideline 9 already asked for this ("Inline new or existing methods as methods or local variables unless they are reused 2+ times or needed for unit tests") and I should have followed it the first time rather than needing you to point it out. None were called twice and no spec targeted them, so coverage is unchanged. Utils::StringTopologicalHash is gone and Brew::Topo is back where it was, so two files this branch previously touched are untouched again. Comments in the changed hunks are cut to the reasons that aren't evident from the code.

Net effect on the diff: 613/184 across 9 files, down to 457/107 across 7. No behaviour change, every scheduling case still produces the same dependency map.

I've also rewritten the description, which had drifted as the implementation changed, and removed my own narrative comments from the thread.

On ripping out the parallel functionality. That's a fair call to make, and I'd rather give you the honest input than argue for my own patch.

The case against keeping it is in this PR's own "Notes for reviewers": I fixed four ways the scheduler mismodelled what a child process locks, and I found at least three more while doing it, including duplicate Brewfile entries still racing from a two-line Brewfile. The pattern is that the scheduler maintains a second, approximate model of Homebrew's locking, and that model is wrong in a new way every time someone looks closely.

The one thing that does catch this class is a differential against what the installer actually locks, rather than a unit test of the model. That is how three of these four were found. It is maintainable if you want to keep the feature, but it is also the admission: the scheduler needs continuous policing against the real implementation, because nothing in the model itself tells you when it has drifted.

There's also a floor on the payoff. HOMEBREW_SANDBOX_LINUX defaults to true and OS::Linux::Sandbox.landlock? is opt-in via HOMEBREW_SANDBOX_LINUX_LANDLOCK=1, so on a stock Linux box with no system bwrap every formula gains the implicit bubblewrap dependency (everything outside bubblewrap's own dependency tree, which bubblewrap_dep_if_needed excludes). Every pair of entries then genuinely conflicts and brew bundle is serial no matter how good the scheduler is. That's the platform the original report came from.

So if you'd rather remove it, I think that's defensible and I'm happy to open that PR instead. If you'd rather keep it for now, this PR does fix the reported bug and two of its siblings, and I'd suggest the duplicate-entry case as the next one to close since it reproduces from two lines. Either way it's your call, not mine, and I won't be offended if the answer is to delete it.

@dduugg
dduugg marked this pull request as ready for review August 1, 2026 18:44
@dduugg
dduugg requested a review from Copilot August 1, 2026 18:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@dduugg
dduugg marked this pull request as draft August 1, 2026 18:51
@MikeMcQuaid

Copy link
Copy Markdown
Member

so on a stock Linux box with no system bwrap every formula gains the implicit bubblewrap dependency

Note we expect to remove this 🔜ish.

Every pair of entries then genuinely conflicts and brew bundle is serial no matter how good the scheduler is.

This seems suboptimal. Instead can we not ensure we ensure all these deps if needed first so they don't conflict later?

@dduugg

dduugg commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Note we expect to remove this soonish.

Good to know, and it means I was leaning on a temporary detail. I've dropped it from the argument in the description. It turns out the problem is much less Linux-specific than I made it sound, which your second point gets at directly.

This seems suboptimal. Instead can we not ensure we ensure all these deps if needed first so they don't conflict later?

You're right that it's suboptimal, and worse than I described. It isn't really about bubblewrap: FormulaInstaller#lock locks the formula's whole recursive dependency closure, and there's no check for whether a dependency is already installed. So two entries contend whenever they share any transitive dependency, on every platform.

Measured on a 15-entry Brewfile of common formulae, macOS, no sandbox involvement:

pairs sharing at least one rack 63 of 105
batches at --jobs 8 10
pairs sharing only their own rack 0 of 105

That last row is your suggestion, and it does work, but not quite as "ensure the deps first" alone. Pre-installing them changes nothing by itself, because lock locks the closure regardless of installed state. What makes the difference is the unless ignore_deps? guard around that loop: skip the closure and each entry locks only its own rack, which is where 63 conflicts becomes 0.

The obvious way to reach it isn't available, though. brew install --ignore-dependencies prints "an unsupported Homebrew developer option!", so bundle shelling out with it would be building on something you've explicitly marked as not for this. The supported version of the same idea would be narrowing lock itself to dependencies that are actually missing, on the grounds that a dependency which is installed and not being modified doesn't need a FormulaLock. I'd want your read on whether that's safe before writing it, since the lock presumably also guards against another process removing a dependency mid-install, and that's the risk you'd be trading away.

That's a formula_installer change rather than a bundle one, so I'd do it as a separate PR rather than growing this one. It would also make this PR's scheduling fixes much less load-bearing, since most of the conflicts they carefully serialize would stop existing. Which loops back to your earlier point: if the lock narrowing lands, the scheduler has far less to model and far less to get wrong, and if it doesn't, the parallelism ceiling stays low enough that removing the feature is a reasonable call.

Happy to take any of the three: narrow the lock, remove parallel install, or leave this PR as the bug fix it is.

@MikeMcQuaid

Copy link
Copy Markdown
Member

That's a formula_installer change rather than a bundle one, so I'd do it as a separate PR rather than growing this one. It would also make this PR's scheduling fixes much less load-bearing, since most of the conflicts they carefully serialize would stop existing. Which loops back to your earlier point: if the lock narrowing lands, the scheduler has far less to model and far less to get wrong, and if it doesn't, the parallelism ceiling stays low enough that removing the feature is a reasonable call.

Yeh, I think this makes more sense to do.

Really if we have a "read-only lock" on a rack/keg: we shouldn't care if anything/anywhere else also has that lot. Feels like we want a semaphore here rather than the current mutex?

I also wonder if part of the issue here is we now have two layers of parallelism:

  • the download queue will download/pour in parallel
  • does brew bundle end up making multiple simultaneously running download queues?

If so: maybe it'd be enough for brew bundle to instead of doing brew install X and brew install Y in parallel: perhaps it should just do brew install X Y and let the (much more robust) existing internal logic handle this?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants