ITADN

`jobs --auto` dependency resolution bug

#289100Closedbluefing 创建于 2026-06-21
B
bluefingcommented
### `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 条评论