DownloadLock: wait for another process's download instead of failing - #23343
Conversation
MikeMcQuaid
left a comment
There was a problem hiding this comment.
Thanks, approach looks good, a few tweaks perhaps.
2adf94f to
7bcad79
Compare
|
I prototyped a blocking-flock alternative to this in #23356 and have closed it. The comparison and the measurements are in this comment rather than here, to keep this thread on this implementation. Short version: blocking flock wakes about 321x faster, that turns out to be imperceptible against waits measured in seconds to minutes, and making the flock long enough to block also makes it long enough to be interrupted, which admits an fd leak and an orphaned-lock case that a Two things from that review apply to this PR:
One test nit: Unrelated to either PR, |
f4ca04e to
848f68a
Compare
There was a problem hiding this comment.
Pull request overview
This PR improves Homebrew’s download locking behaviour so that when two brew processes contend for the same download lock, the losing process waits for the download to finish instead of failing immediately. This targets real-world parallelism (e.g. brew bundle parallel workers) while keeping fail-fast behaviour for other lock types and existing DownloadLock usages that intentionally skip.
Changes:
- Add
DownloadLock#lock_or_wait, which retries lock acquisition with a single warning and a bounded wait. - Extend
OperationInProgressErrorto optionally report “gave up after waiting N seconds”. - Switch
CurlDownloadStrategy#fetchto use the new waiting lock behaviour and add/extend lock-related specs.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| Library/Homebrew/lock_file/download_lock.rb | Adds lock_or_wait with a max wait and one-time warning while retrying. |
| Library/Homebrew/download_strategy/curl_download_strategy.rb | Uses lock_or_wait during the actual download path. |
| Library/Homebrew/exceptions.rb | Updates OperationInProgressError messaging to optionally include waited time. |
| Library/Homebrew/lock_file.rb | Exposes locked_path for use in wait/warn messaging. |
| Library/Homebrew/test/lock_file/download_lock_spec.rb | New unit tests for lock_or_wait behaviour. |
| Library/Homebrew/test/lock_file_spec.rb | Adds tests covering LockFile#lock retry-on-disk-race and interrupt deferral. |
Comments suppressed due to low confidence (2)
Library/Homebrew/test/lock_file/download_lock_spec.rb:66
- This test will emit an
opoowarning to stderr (the first contended lock attempt) while also expecting an exception, which can make the spec suite noisy. Consider passingquiet: truehere since the warning behaviour is tested separately.
stub_const("DownloadLock::MAX_WAIT_SECONDS", 0)
download_lock.lock
allow(download_lock_copy).to receive(:sleep)
expect { download_lock_copy.lock_or_wait }.to raise_error(OperationInProgressError)
Library/Homebrew/test/lock_file/download_lock_spec.rb:74
- This test will also print the initial
opoowarning to stderr before raising, which can add noise to test output. Passingquiet: truekeeps this focused on the exception message it is asserting.
stub_const("DownloadLock::MAX_WAIT_SECONDS", 0)
download_lock.lock
allow(download_lock_copy).to receive(:sleep)
expect { download_lock_copy.lock_or_wait }.to raise_error(/Gave up after waiting \d+ seconds/)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
94d7407 to
fc7c3ce
Compare
Two `brew` processes downloading the same file raced on the download lock and one failed outright with `OperationInProgressError`, which is half of #23328 (the scheduling half was fixed in #23342). Waiting is almost always what the user wants, since the holder is about to produce exactly the file this process needs. `DownloadLock#lock_or_wait` polls the existing non-blocking `flock` every 0.1s instead of giving up on the first failure. At 0.1s the wait costs about 0.15% of one core per waiting download, dominated by syscall overhead rather than real work, so the interval buys responsiveness for no meaningful CPU. The wait is capped at 3 minutes, or at the caller's remaining `timeout:` budget when that is shorter, so waiting on the lock can't blow a deadline the caller asked for. An hour is always going to be too long, and giving up beats waiting much longer because `RetryableDownload` preserves the `.incomplete` file, so a retry resumes the holder's partial download via `--continue-at`. The warning is suppressed when the caller is already rendering progress. `HOMEBREW_DOWNLOAD_CONCURRENCY` defaults to `cores * 2`, and above 1 `DownloadQueue#fetch` drives a cursor-addressed redraw whose arithmetic assumes one line per download, so an unscheduled write from a pool worker desyncs it. `OperationInProgressError` also takes an optional `waited:` now, because telling someone to "wait for it to finish or terminate it to continue" after three minutes of waiting is not useful. The message is unchanged for every existing caller. Mutation testing found `lock_file_spec.rb` passed with the inode/unlink recheck neutered and with `ignore_interrupts` removed from `#lock`, so cover both. Deferring the interrupt itself can't be asserted in-process, since RSpec owns the `INT` handler that `ignore_interrupts` traps and `Thread#raise` bypasses `trap`, so the wrapper's presence is asserted instead.
fc7c3ce to
db0402e
Compare
|
Following up on the two comments Copilot suppressed as low confidence, both of which asked for Both are applied, but the stated rationale does not hold for this repo. Checking it did turn up a real gap, though one I introduced rather than Copilot. My fix for the "warns only once" comment replaced expect(download_lock_copy).to receive(:opoo).once.with(
/Waiting for another Homebrew process to finish downloading/,
).and_call_originalVerified it bites: changing the warning text fails that example, where a moment earlier it passed. |
|
Thanks! |
What does this change do, and why?
Addresses one of two independent causes behind #23328 (per @MikeMcQuaid's request for a separate PR per cause).
When two separate
brewprocesses need to download the same file at once (most commonly twobrew bundleparallel-install workers that both need to fetch an undeclared implicit dependency, see #23342 for that specific scheduling gap), the loser currently dies immediately withError: A `brew install ...` process has already locked ..., becauseLockFile#locktakes a non-blockingflock. The error message already tells the user to "wait for it to finish or terminate it to continue", so this makes that happen automatically instead of requiring a manual retry.DownloadLock#lock_or_waitretries with a short sleep between attempts until the holder releases the lock, printing a "Waiting for another Homebrew process..." warning once rather than repeatedly. OnlyCurlDownloadStrategy#fetch(the actual download path) switches to it.FormulaLock/CaskLockandDownloadLock's existing usage incleanup.rb(which deliberately wants to skip an in-progress download rather than wait for it) keep the current fail-fast behavior unchanged.This is deliberately independent of #23342: it fixes the underlying race for any two processes contending on the same download, regardless of what causes them to collide, rather than trying to predict every possible cause of a collision ahead of time.
Step-by-step reproduction
Not easily reproducible on demand outside of #23328's own repro (two processes racing to download the same file), but the fix is covered by a real (non-mocked) end-to-end check using two live
flocks across threads, in addition to unit tests: a lockedDownloadLockgenuinely blocks a second instance'slock_or_waituntil released, then it succeeds.brewcommands to reproduce the bug?brew lgtm(style, typechecking and tests) locally?Used Claude Code to investigate #23328, design and implement this fix, and write the accompanying tests. Verified by running
brew typecheckandbrew style --changed(clean), running the fulllock_file,download_strategies/curl, andcleanupspec suites (all passing, confirming the untouched fail-fast paths still work), and a manual real (non-mocked) end-to-end script using two threads and actualflockcalls that confirmed a contendedlock_or_waitgenuinely blocks and then acquires the lock once the holder releases it, in the expected timeframe. Reviewed the full diff by hand before opening this PR.