`jobs --auto` dependency resolution bug
### `brew config` AND `brew doctor` output OR `brew gist-logs <formula>` link
```shell
brew config 19:58:41
HOMEBREW_VERSION: 6.0.2-144-g09203ec
ORIGIN: https://github.com/Homebrew/brew
HEAD: 09203ecc68212c4e9017d85ee6244a6fa5567b96
Last commit: 3 hours ago
Branch: main
Core tap: N/A
Core cask tap: N/A
HOMEBREW_PREFIX: /opt/homebrew
HOMEBREW_CASK_OPTS: ["--appdir=~/Applications"]
HOMEBREW_DOWNLOAD_CONCURRENCY: 16
HOMEBREW_EDITOR: nvim
HOMEBREW_FORBID_PACKAGES_FROM_PATHS: set
HOMEBREW_MAKE_JOBS: 8
HOMEBREW_REQUIRE_TAP_TRUST: set
Homebrew Ruby: 4.0.5 => /opt/homebrew/Library/Homebrew/vendor/portable-ruby/4.0.5_1/bin/ruby
CPU: octa-core 64-bit arm_ibiza
Clang: 21.0.0 build 2100
Git: 2.54.0 => /opt/homebrew/bin/git
Curl: 8.7.1 => /usr/bin/curl
macOS: 26.5.1-arm64
CLT: 26.5.0.0.1777544298
Xcode: N/A
Rosetta 2: false
brew doctor 12:31:41
Your system is ready to brew.
```
### Verification
- [x] My `brew doctor` output says `Your system is ready to brew.` and am still able to reproduce my issue.
- [x] I ran `brew update` and am still able to reproduce my issue.
- [x] I have resolved all warnings from `brew doctor` and that did not fix my problem.
- [x] I searched for recent similar issues at https://github.com/Homebrew/homebrew-core/issues?q=is%3Aissue and found no duplicates.
- [x] My issue is not about a failure to build a formula from source.
### What were you trying to do (and why)?
Install tmux and tpack
### What happened (include all command output)?
```shell
Error: A `brew install --cask tmuxpack/tpack/tpack --adopt` process has already locked /home/repro/.cache/Homebrew/downloads/12b6dc145eabad7f2a10d8b5664c4c407c090b4d02cb87bdb8cbb5f4a518273d--gcc--16.1.0.arm64_linux.bottle.1.tar.gz.incomplete.
```
The following analysis was created by claude and verified by gemini ...
```markdown
# `brew bundle install --jobs auto` ignores a cask's `depends_on formula:` — race against duplicated top-level formula
## Summary
`Homebrew::Bundle::ParallelInstaller#build_dependency_map` reads each cask's
`depends_on formula:` declarations via `Homebrew::Bundle::Cask.formula_dependencies`.
That method has **two independent bugs** in `Library/Homebrew/bundle/cask.rb` that
combine to return `[]` for any third-party tap cask, regardless of install state.
As a result, when a `Brewfile` declares both:
```ruby
brew "tmux"
cask "tmuxpack/tpack/tpack" # this cask depends_on formula: ["git", "tmux"]
```
the parallel installer treats the two as independent entries, runs them concurrently
under `--jobs auto`, and they race on the cellar / download locks of the shared
transitive dependencies. The end-user sees lock-contention errors mid-install:
```
Error: A `brew install --formula tmux` process has already locked
/home/linuxbrew/.linuxbrew/Cellar/linux-headers@6.8.
Error: A `brew install --cask tmuxpack/tpack/tpack --adopt` process has already
locked /home/repro/.cache/Homebrew/downloads/...--gcc--16.1.0...
```
Despite the lock errors and `Installing tmux has failed! / Installing tpack has failed!`
output, `brew bundle` then prints `complete! 3 Brewfile dependencies now installed.`
and exits 0 — a third bug, false success reporting (called out separately at the end).
## Root cause — `Cask.formula_dependencies`
`Library/Homebrew/bundle/cask.rb` (around line 260):
```ruby
sig { params(cask_list: T::Array[String]).returns(T::Array[String]) }
def formula_dependencies(cask_list)
return [] unless Bundle.cask_installed?
return [] if cask_list.blank?
casks.flat_map do |cask| # bug #1
next unless cask_list.include?(cask.to_s) # bug #2
cask.depends_on[:formula]
end.compact
end
```
with `casks` (same file, around line 48):
```ruby
sig { returns(T::Array[::Cask::Cask]) }
def casks
return [] unless Bundle.cask_installed?
require "cask/caskroom"
@casks ||= T.let(::Cask::Caskroom.casks, T.nilable(T::Array[::Cask::Cask]))
end
```
### Bug #1 — `casks` enumerates installed casks only
`::Cask::Caskroom.casks` walks the Caskroom directory (`Library/Homebrew/cask/caskroom.rb`,
`tokens` → `paths`). On a fresh `brew bundle install`, the target cask is not yet
installed, so `casks` returns `[]`, the `flat_map` returns `[]`, and the cask's
`depends_on[:formula]` is invisible to the scheduler.
### Bug #2 — full-name vs short-token mismatch
The filter `cask_list.include?(cask.to_s)` compares the Brewfile entry name
(`cask_list` element, e.g. `"tmuxpack/tpack/tpack"`) against the cask's short
token (`cask.to_s`, e.g. `"tpack"`). Third-party tap casks declared by their
full `user/repo/cask` name in the Brewfile never match. Even after a successful
install, `formula_dependencies(["tmuxpack/tpack/tpack"])` returns `[]`.
Default-tap casks (`homebrew/cask`) accidentally dodge bug #2 because their
full name equals their short token (`"claude-code"`), so the comparison works.
That's likely why this hasn't been reported before — most casks are default-tap.
## Symptom in the scheduler
`Library/Homebrew/bundle/parallel_installer.rb`, dep-map phase 2 (around 102-109):
```ruby
deps = case entry.cls.name
when "Homebrew::Bundle::Brew"
Homebrew::Bundle::Brew.formula_dep_names(entry.name)
when "Homebrew::Bundle::Cask"
Homebrew::Bundle::Cask.formula_dependencies([entry.name]) # always [] for 3rd-party tap casks
else
[]
end
```
`brewfile_deps[cask_name]` ends up empty, phase 4 doesn't make the cask wait on
the top-level formula entry, and `--jobs auto` schedules them concurrently.
The right pattern already exists in the same file — `cask_dep_names`
(parallel_installer.rb around 161-172) walks cask-on-cask deps via
`::Cask::CaskLoader.load(name)`, which reads the cask source from the tap
regardless of install state and handles full-qualified names natively:
```ruby
def cask_dep_names(name, cask_names)
return Set.new unless Bundle.cask_installed?
require "cask/cask_loader"
cask = ::Cask::CaskLoader.load(name)
direct = Array(cask.depends_on[:cask]).to_set
direct & cask_names
rescue ::Cask::CaskUnavailableError
Set.new
end
```
`Cask.formula_dependencies` should follow the same shape.
## Minimal reproduction
A self-contained Docker-based repro is attached
([github.com/<user>/brew-bundle-bug-repro](#)). Files:
`Brewfile`:
```ruby
tap "tmuxpack/tpack"
brew "tmux"
cask "tmuxpack/tpack/tpack"
```
`Dockerfile`:
```dockerfile
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
curl git build-essential procps file sudo ca-certificates && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash repro && \
echo "repro ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/repro
USER repro
WORKDIR /home/repro
RUN NONINTERACTIVE=1 /bin/bash -c \
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
ENV PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH
RUN mkdir -p /home/repro/.config/homebrew && \
printf '%s\n' '{' \
' "trustedtaps": ["tmuxpack/tpack"],' \
' "trustedcasks": ["tmuxpack/tpack/tpack"]' \
'}' > /home/repro/.config/homebrew/trust.json
ENV XDG_CONFIG_HOME=/home/repro/.config
COPY --chown=repro:repro Brewfile /tmp/Brewfile
COPY --chown=repro:repro probe.sh /tmp/probe.sh
RUN chmod +x /tmp/probe.sh
CMD ["/tmp/probe.sh"]
```
`probe.sh`:
```bash
#!/usr/bin/env bash
set -uo pipefail
brew --version
brew tap tmuxpack/tpack >/dev/null
# PROBE 1 — before install: formula_dependencies returns []
brew ruby -e '
require "bundle"
require "bundle/cask"
puts Homebrew::Bundle::Cask.formula_dependencies(["tmuxpack/tpack/tpack"]).inspect
'
# PROBE 2 — race manifests
brew bundle install --jobs auto --file=/tmp/Brewfile
# PROBE 3 — after install (cask now in Caskroom): STILL returns []
brew ruby -e '
require "bundle"
require "bundle/cask"
puts Homebrew::Bundle::Cask.formula_dependencies(["tmuxpack/tpack/tpack"]).inspect
'
```
Run:
```sh
docker build -t brew-bundle-bug-repro .
docker run --rm brew-bundle-bug-repro
```
### Observed output
```
Homebrew 6.0.2
==== PROBE 1 — before install ====
formula_dependencies(["tmuxpack/tpack/tpack"]) => [] # bug #1 (or #2): empty
==== PROBE 2 — brew bundle install --jobs auto ====
Fetching tmux, tmuxpack/tpack/tpack
Using tmuxpack/tpack
Installing tmux
Installing tpack
==> Fetching downloads for: tmux
...
Error: A `brew install --formula tmux` process has already locked
/home/linuxbrew/.linuxbrew/Cellar/linux-headers@6.8.
Please wait for it to finish or terminate it to continue.
...
Error: A `brew install --cask tmuxpack/tpack/tpack --adopt` process has already
locked /home/repro/.cache/Homebrew/downloads/...--gcc--16.1.0...
...
🍺 tpack was successfully installed!
Installing tpack has failed!
Installing tmux has failed!
`brew bundle` complete! 3 Brewfile dependencies now installed.
brew bundle exit code: 0 # false success — see "Bonus bug" below
==== PROBE 3 — after install (cask landed) ====
formula_dependencies(["tmuxpack/tpack/tpack"]) => [] # bug #2: still empty
```
### Expected
- `Cask.formula_dependencies(["tmuxpack/tpack/tpack"])` returns
`["git", "tmux"]` on the first invocation (before any install) and every
invocation thereafter.
- `ParallelInstaller` sees the dependency, schedules `brew "tmux"` before
`cask "tmuxpack/tpack/tpack"` (or at minimum serialises them so they don't
race on shared transitive build deps).
- `brew bundle install --jobs auto` succeeds without lock-contention errors.
- If any entry truly fails, `brew bundle` reports it and exits non-zero.
## Why this hasn't surfaced before
Four conditions all have to hold:
1. A cask in the `Brewfile` declares `depends_on formula:`. (Most casks don't.)
2. That formula is **also** declared as a top-level `brew` entry in the same
`Brewfile`. Without this, the formula isn't a separate scheduled job, so
nothing competes with the cask's internal install of it.
3. The cask is from a **third-party tap** (full-qualified name in the
Brewfile). For default-tap casks, bug #2 accidentally doesn't fire, so
idempotent re-runs after a first install would mask the problem.
4. `--jobs` > 1 (default `auto`). `--jobs 1` serialises and hides the race.
Note: source builds widen the race window but are **not** required to
reproduce. The lock contention in the observed output above is around bottle
pours and bottle downloads, not from-source builds.
## Suggested fix
Replace `formula_dependencies` with a `CaskLoader.load`-based implementation,
mirroring `ParallelInstaller#cask_dep_names`:
```ruby
sig { params(cask_list: T::Array[String]).returns(T::Array[String]) }
def formula_dependencies(cask_list)
return [] unless Bundle.cask_installed?
return [] if cask_list.blank?
require "cask/cask_loader"
cask_list.flat_map do |name|
Array(::Cask::CaskLoader.load(name).depends_on[:formula])
rescue ::Cask::CaskUnavailableError
[]
end.compact
end
```
This fixes both bug #1 (works pre-install) and bug #2 (works with
full-qualified names).
## Bonus bug — false success reporting
In the observed output, `tpack` and `tmux` both have `Installing X has failed!`
lines, yet `brew bundle` reports `complete! 3 Brewfile dependencies now
installed.` and exits 0. Looking at parallel_installer.rb#install_entries_parallel!
this seems to come from the futures evaluating to non-nil (the underlying
`brew install --cask ... --adopt` eventually retries past the lock and succeeds —
note `🍺 tpack was successfully installed!` immediately before the failure
message) while the failure message has already been written. Either:
- the success/failure counters should reflect the final state, not the first
recorded failure; or
- if the entry truly failed, `brew bundle` should not report `complete!` and
should exit non-zero.
Happy to file this separately if preferred — flagged here because it's
load-bearing for "users haven't noticed": the scheduling bug emits errors
but bundle exits clean.
## Environment
- `brew --version`: Homebrew 6.0.2
- Host: macOS arm64, Docker Desktop, container running `linux/arm64`
- Same bug expected on `linux/amd64` (bug is arch-independent; only the size
of the race window differs). Untested on macOS directly because Brewfile
cask deps are most commonly mixed with macOS-specific dev tooling that
isn't easily isolated in a clean container.
```
### What did you expect to happen?
`jobs --auto` should detect the dependency and order installs appropriately.
This is similar to the bug I raised previously https://github.com/Homebrew/brew/issues/22293
### Step-by-step reproduction instructions (by running `brew` commands)
```shell
The following MRE was created by claude and reviewed by gemini...
# Minimal repro for brew bundle install --jobs auto failing to schedule
# casks correctly w.r.t. their `depends_on formula:` declarations.
#
# The tpack cask declares `depends_on formula: ["git", "tmux"]`. With tmux
# also listed as a top-level brew entry, the parallel installer treats the
# two as independent and runs them concurrently — colliding on the shared
# build-dep (`linux-headers@6.8`) when no `tmux` bottle is available for
# the target platform.
tap "tmuxpack/tpack"
brew "tmux"
cask "tmuxpack/tpack/tpack"
# Minimal Debian image to reproduce a brew bundle scheduling bug.
# Runs as host arch (linux/arm64 on Apple Silicon). The bug is arch-
# independent in theory — arm64 just guarantees a tmux source-build
# (no bottle), widening the race window.
FROM debian:bookworm
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl git build-essential procps file sudo ca-certificates && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash repro && \
echo "repro ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/repro
USER repro
WORKDIR /home/repro
# Install Homebrew (non-interactive).
RUN NONINTERACTIVE=1 /bin/bash -c \
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
ENV PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH
# Pre-approve the third-party tap so cask install isn't blocked on trust.
RUN mkdir -p /home/repro/.config/homebrew && \
printf '%s\n' \
'{' \
' "trustedtaps": ["tmuxpack/tpack"],' \
' "trustedcasks": ["tmuxpack/tpack/tpack"]' \
'}' > /home/repro/.config/homebrew/trust.json
ENV XDG_CONFIG_HOME=/home/repro/.config
COPY --chown=repro:repro Brewfile /tmp/Brewfile
COPY --chown=repro:repro probe.sh /tmp/probe.sh
RUN chmod +x /tmp/probe.sh
CMD ["/tmp/probe.sh"]
#!/usr/bin/env bash
# Probe script for the brew bundle parallel-installer cask-deps bug.
set -uo pipefail
echo "===================================================================="
echo "Environment"
echo "===================================================================="
uname -a
echo
brew --version
echo
echo "===================================================================="
echo "PROBE 1 — Cask.formula_dependencies BEFORE install"
echo "Expected (correct): [\"git\", \"tmux\"]"
echo "Observed (buggy): []"
echo "===================================================================="
brew tap tmuxpack/tpack >/dev/null 2>&1 || true
brew ruby -e '
require "bundle"
require "bundle/cask"
deps = Homebrew::Bundle::Cask.formula_dependencies(["tmuxpack/tpack/tpack"])
puts " formula_dependencies([\"tmuxpack/tpack/tpack\"]) => #{deps.inspect}"
'
echo
echo "===================================================================="
echo "PROBE 2 — brew bundle install --jobs auto"
echo "Expected (correct): both entries install in dependency order"
echo "Observed (buggy): cellar-lock contention on linux-headers"
echo "===================================================================="
set +e
brew bundle install --jobs auto --file=/tmp/Brewfile 2>&1
bundle_rc=$?
set -e
echo
echo " brew bundle exit code: ${bundle_rc}"
echo
echo "===================================================================="
echo "PROBE 3 — Cask.formula_dependencies AFTER install (if cask landed)"
echo "Expected: [\"git\", \"tmux\"] in both runs; consistency proves the"
echo " bug is install-state sensitivity, not data unavailability."
echo "===================================================================="
brew ruby -e '
require "bundle"
require "bundle/cask"
deps = Homebrew::Bundle::Cask.formula_dependencies(["tmuxpack/tpack/tpack"])
puts " formula_dependencies([\"tmuxpack/tpack/tpack\"]) => #{deps.inspect}"
'
exit "${bundle_rc}"
```
关闭于 2026-06-21 3 条评论