bundle: batch installs where possible - #23659
Merged
Merged
Conversation
MikeMcQuaid
approved these changes
Aug 26, 2026
MikeMcQuaid
left a comment
Member
There was a problem hiding this comment.
@dduugg Thanks! Extended the approach for other types that were batchable.
brew install
Contributor
There was a problem hiding this comment.
Pull request overview
This pull request refactors brew bundle install to favor batching (delegating to fewer brew install invocations and package-manager-native batch installs) instead of maintaining a parallel-install scheduler that attempted to predict Homebrew’s internal lock behavior.
Changes:
- Replace the
bundleparallel installer/scheduler with batchedbrew installexecution and per-entry finishing. - Deprecate and hide
--jobs/HOMEBREW_BUNDLE_JOBS/HOMEBREW_BUNDLE_NO_JOBS, updating docs, completions, and env config accordingly. - Add “native batch” support for several bundle extensions (npm, cargo, mas, flatpak, VS Code) and update/expand specs.
Reviewed changes
Copilot reviewed 35 out of 35 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| manpages/brew.1 | Removes --jobs and related env var documentation from the generated manpage. |
| docs/Manpage.md | Removes --jobs and related env var documentation from docs. |
| completions/zsh/_brew | Removes --jobs completion for bundle install/upgrade. |
| completions/fish/brew.fish | Removes --jobs completion for bundle install/upgrade. |
| completions/bash/brew | Removes --jobs completion for bundle install. |
| Library/Homebrew/test/support/helper/subcommand.rb | Adjusts test arg helper behavior so jobs is nil unless explicitly set. |
| Library/Homebrew/test/install_spec.rb | Adds coverage for continuing after a BuildError during formula batch installation. |
| Library/Homebrew/test/formula_installer_spec.rb | Adds coverage ensuring locks are released when FormulaInstaller#install raises. |
| Library/Homebrew/test/env_config_spec.rb | Removes tests for bundle_jobs behavior. |
| Library/Homebrew/test/cmd/bundle/check_subcommand_spec.rb | Updates bundle check subcommand spec to match removed jobs context. |
| Library/Homebrew/test/cmd/bundle_spec.rb | Removes env-driven jobs behavior spec. |
| Library/Homebrew/test/bundle/vscode_extension_spec.rb | Adds/updates spec coverage for VS Code extension batch installs. |
| Library/Homebrew/test/bundle/npm_spec.rb | Adds/updates spec coverage for npm batch installs. |
| Library/Homebrew/test/bundle/mac_app_store_spec.rb | Adds/updates spec coverage for MAS batch installs. |
| Library/Homebrew/test/bundle/installer_spec.rb | Reworks installer specs around batching and native batch delegation. |
| Library/Homebrew/test/bundle/flatpak_spec.rb | Adds/updates spec coverage for Flatpak batch installs. |
| Library/Homebrew/test/bundle/cargo_spec.rb | Adds/updates spec coverage for Cargo batch installs. |
| Library/Homebrew/install.rb | Changes install_formulae to handle BuildError per-formula and continue. |
| Library/Homebrew/formula_installer.rb | Ensures locks are released even when install exits via exceptions (incl. interrupts). |
| Library/Homebrew/env_config.rb | Marks bundle jobs env vars as deprecated/ignored and removes custom parsing helper. |
| Library/Homebrew/bundle/subcommand/install.rb | Hides --jobs and warns that it’s ignored when passed. |
| Library/Homebrew/bundle/subcommand/exec.rb | Removes jobs from default bundle subcommand context for exec path. |
| Library/Homebrew/bundle/subcommand.rb | Removes jobs/env-driven parallelism from bundle context computation. |
| Library/Homebrew/bundle/subcommand_context.rb | Removes jobs from the typed subcommand context struct. |
| Library/Homebrew/bundle/parallel_installer.rb | Deletes the parallel scheduler implementation. |
| Library/Homebrew/bundle/package_type.rb | Introduces batch_installable?/install_batch! API for package types/extensions. |
| Library/Homebrew/bundle/installer.rb | Implements Homebrew batching + native batch dispatch for extensions. |
| Library/Homebrew/bundle/extensions/vscode_extension.rb | Adds native batch install for VS Code extensions. |
| Library/Homebrew/bundle/extensions/npm.rb | Adds native batch install for npm packages. |
| Library/Homebrew/bundle/extensions/mac_app_store.rb | Adds native batch install for MAS apps. |
| Library/Homebrew/bundle/extensions/flatpak.rb | Adds native batch install for Flatpak refs (where safe). |
| Library/Homebrew/bundle/extensions/extension.rb | Removes now-unused fetch hook from extension base class. |
| Library/Homebrew/bundle/extensions/cargo.rb | Adds native batch install for Cargo crates. |
| Library/Homebrew/bundle/cask.rb | Removes now-unused fetch hook from Cask package type. |
| Library/Homebrew/bundle/brew.rb | Removes now-unused fetch/dependency helper methods tied to old scheduler/fetching. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
`brew bundle install` ran one `brew install --formula X` child per Brewfile entry, several at a time. Each child's `FormulaInstaller#lock` takes an exclusive `FormulaLock` on the target rack and on every rack in its recursive dependency closure, whether or not those dependencies are already installed, and `LockFile#lock` is non-blocking, so the loser died immediately. Two entries collided whenever their closures intersected, which is around half of all entry pairs in the Brewfile samples measured. `ParallelInstaller` avoided that by predicting the collisions, which meant carrying a second copy of what `lock` does, and every bug in this area was that copy drifting from the original. `FormulaInstaller#lock` returns early unless `self.class.locked` is empty, so one process installing many formulae never contends with itself. Batching the entries into a single `brew install` therefore removes the hazard rather than modelling it, and leaves nothing to keep in sync. It also replaces one download queue per child with the single queue `brew install` already uses. Entries that pass extra arguments, need work before or after the install, or are not formulae keep their own child process on the existing path, so nothing that reads the per-entry changed flag joins a batch. The tap phase, the tap sweep for entries that live in taps the Brewfile does not list, and the attestation `gh` pre-warm move out of the scheduler and now run on every path rather than only when more than one job was requested. A successful batch means every entry in it succeeded, because `brew install` exits non-zero if any package failed. When it fails, which entry failed cannot be read from the exit status, so the outcome falls back to asking what ended up installed. `--jobs` is still accepted but ignored, and `$HOMEBREW_BUNDLE_JOBS` and `$HOMEBREW_BUNDLE_NO_JOBS` are deprecated.
- Share one `brew install` and download queue across formulae and casks. - Qualify cask arguments so formula name collisions cannot change type. - Let compatible extensions use native multi-package installs. - Continue after source build errors and release failed installer locks. - Keep the inert `--jobs` flag compatible but hidden. - Retry entries individually when a native batch raises or fails.
MikeMcQuaid
force-pushed
the
bundle-batch-formula-installs
branch
from
August 26, 2026 18:20
0cf598f to
da2ba60
Compare
MikeMcQuaid
enabled auto-merge
August 26, 2026 18:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this change do, and why?
Replaces
brew bundle install's parallel-install scheduler with batching, as agreed in #23386. Supersedes and closes that PR.brew bundle installran onebrew install --formula Xchild per Brewfile entry, several at a time. Each child'sFormulaInstaller#locktakes an exclusiveFormulaLockon the target rack and on every rack in its recursive dependency closure, whether or not those dependencies are already installed, andLockFile#lockisLOCK_EX | LOCK_NBso the loser dies immediately rather than waiting. Two entries collided whenever their closures intersected.ParallelInstalleravoided that by predicting the collisions before dispatching, which meant carrying a second, hand maintained copy of whatlockdoes. #22293, #22837, #22899, #22948 and #23328 were all that copy drifting from the original, and nothing in the design signalled when it had drifted.FormulaInstaller#lockopens withreturn unless self.class.locked.empty?, so one process installing N formulae never contends with itself. Batching removes the hazard rather than modelling it, so there is nothing left to keep in sync. It also replaces oneDownloadQueueper child with the single queuebrew installalready uses, which is the second half of what you raised in #23386.This is the same shape as #21252, which already collapses the fetch phase into one
brew fetch a b c, one phase later. It is only viable because of #23525: before that, one failed download aborted the whole invocation before anything installed, whichbrew bundle's attempt-every-entry contract could not tolerate.What is batched, and what is not
An entry is batched only if it is a formula, carries no option other than
full_nameortrusted, does not need a tap the Brewfile has not installed yet, and loads. Everything else keeps its own child process on the existing unmodified path: casks, taps, the extension types, and any entry withargs:,link:,conflicts_with:,restart_service:,start_service:,postinstall:orversion_file:.That rule is what keeps the per-entry changed flag out of scope. Its only three consumers are
restart_service,postinstalland theversion_filebranch, and every entry that can set those is excluded, so no batched entry ever reads it.install_formula!andupgrade_formula!are untouched for the entries that do.Batched entries are split by whether they are already installed, giving at most one
brew install --formula ...and onebrew upgrade --formula .... Keepingbrew upgradefor the outdated half preserves$HOMEBREW_BUNDLE_NO_UPGRADEsemantics without touching the environment.How per-entry success is decided
A successful batch means every entry in it succeeded, because
brew installexits non-zero if any package failed. Only when the batch fails does the outcome fall back to asking what ended up installed, since the exit status cannot name the entry that failed. Each entry is then still finished individually so its link state is reconciled exactly as before.Behaviour changes worth calling out
ghpre-warm now run on every path. They previously lived insideParallelInstaller#run!and so were skipped entirely for anyone on--jobs=1. That is a bug fix, and two existing specs were quietly relying on those phases never running.Installing X/Using Xlines print first, then the child's output. Same shape as the existingFetching a, b, cphase.--jobsis accepted but ignored, and warns when passed.$HOMEBREW_BUNDLE_JOBSand$HOMEBREW_BUNDLE_NO_JOBSare markedodeprecated.Decision for you: whether
--jobsshould stay accepted-and-inert as here, or go straight toodisabled. I have kept it accepted so existing CI invocations do not hard-fail.Known, unfixed, and separately tracked
install.rb'srescue BuildError; raisestill aborts the remaining formulae in a batch, so a build-from-source failure loses the entries after it. Excluded entries are unaffected because they get their own invocation.install_formulaewhile doing this, and it is worth its own PR:FormulaInstaller#unlockruns only fromfinish'sensure, which a raisinginstallnever reaches, so after the per-entryrescueadded in Keep one failure from aborting a batch install #23525self.class.lockedis never cleared and every later formula in the batch skips taking its own rack locks. Demonstrated locally: afterxz's installer locks,jq'slockis a no-op and@hold_locksstays false. I will open that separately unless you would rather it rode along here.Step-by-step reproduction
The bug this removes is #23328's, which reproduces with that issue's Dockerfile and Brewfile. On this branch the formula entries install in one child process, so no two of them can contend for a rack at all.
brewcommands to reproduce the bug?brew lgtm(style, typechecking and tests) locally?AI was used. Claude Code (Claude Opus) was used to survey the dispatch path and the option-to-flag mapping, implement this, and rewrite the specs. Verification, all of which I ran and read myself:
brew lgtmclean (typecheck,style --changed --fix,tests --changed), plusbundle/installer,bundle/brew,cmd/bundleandenv_configrun explicitly since--changeddoes not reach every affected spec file.FormulaInstaller#lockis process global and first wins, thatlockis the first statement of#installwhilecompute_dependenciesruns later, and thatunlockis reachable only fromfinish'sensure(which is the lock leak noted above, demonstrated with a scratch script driving two realFormulaInstallers).install-on-requestanalytics, plus this machine's own 86 explicitly-installed formulae: around half of all entry pairs share at least one rack, and that share is flat as the Brewfile grows.FormulaUnavailableError.I reviewed the full diff by hand before opening this.