Skip to content

DownloadLock: wait for another process's download instead of failing - #23343

Merged
MikeMcQuaid merged 1 commit into
mainfrom
download-lock-wait-instead-of-fail
Jul 30, 2026
Merged

DownloadLock: wait for another process's download instead of failing#23343
MikeMcQuaid merged 1 commit into
mainfrom
download-lock-wait-instead-of-fail

Conversation

@dduugg

@dduugg dduugg commented Jul 28, 2026

Copy link
Copy Markdown
Member

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 brew processes need to download the same file at once (most commonly two brew bundle parallel-install workers that both need to fetch an undeclared implicit dependency, see #23342 for that specific scheduling gap), the loser currently dies immediately with Error: A `brew install ...` process has already locked ..., because LockFile#lock takes a non-blocking flock. 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_wait retries with a short sleep between attempts until the holder releases the lock, printing a "Waiting for another Homebrew process..." warning once rather than repeatedly. Only CurlDownloadStrategy#fetch (the actual download path) switches to it. FormulaLock/CaskLock and DownloadLock's existing usage in cleanup.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 locked DownloadLock genuinely blocks a second instance's lock_or_wait until released, then it succeeds.


  • 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?

  • AI was used to generate or assist with generating this PR.

Used Claude Code to investigate #23328, design and implement this fix, and write the accompanying tests. Verified by running brew typecheck and brew style --changed (clean), running the full lock_file, download_strategies/curl, and cleanup spec 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 actual flock calls that confirmed a contended lock_or_wait genuinely 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.


Comment thread Library/Homebrew/lock_file/download_lock.rb

@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, approach looks good, a few tweaks perhaps.

Comment thread Library/Homebrew/download_strategy/curl_download_strategy.rb Outdated
Comment thread Library/Homebrew/lock_file/download_lock.rb Outdated
Comment thread Library/Homebrew/lock_file/download_lock.rb
@dduugg
dduugg force-pushed the download-lock-wait-instead-of-fail branch from 2adf94f to 7bcad79 Compare July 28, 2026 17:24
Comment thread Library/Homebrew/lock_file/download_lock.rb
Comment thread Library/Homebrew/lock_file/download_lock.rb
@dduugg

dduugg commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

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 LOCK_NB flock cannot have.

Two things from that review apply to this PR:

  1. The opoo will corrupt the parallel download display. HOMEBREW_DOWNLOAD_CONCURRENCY defaults to cores * 2, so concurrency above 1 is the normal case, and DownloadQueue#fetch drives a cursor-addressed redraw whose arithmetic assumes exactly one line per download. report_or_defer_failure exists specifically because an unscheduled write desyncs it, and a warning printed from a pool worker is exactly that. Separately, opoo is not gated on quiet while DownloadQueue sets quiet = concurrency > 1, so it prints even when the queue asked for silence. Routing it through report_or_defer_failure addresses both.

  2. The give-up message should say how long it waited. As written the user is told to "wait for it to finish or terminate it to continue", which is what they just spent the timeout doing.

One test nit: allow(Time).to receive(:now).and_return(now, now + MAX + 1) is worth replacing with a small stubbed MAX_WAIT_SECONDS, because a global two-element Time.now sequence breaks as soon as anything else in the call path reads the clock.

Unrelated to either PR, lock_file_spec.rb deserves hardening. Mutation testing showed it passes with ignore_interrupts deleted from LockFile#lock entirely, and with the inode recheck deleted. I added coverage for the second in #23356 and can port it separately.

@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!

@dduugg
dduugg marked this pull request as ready for review July 30, 2026 01:18
Copilot AI review requested due to automatic review settings July 30, 2026 01:18
@dduugg
dduugg force-pushed the download-lock-wait-instead-of-fail branch from f4ca04e to 848f68a Compare July 30, 2026 01:20

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 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 OperationInProgressError to optionally report “gave up after waiting N seconds”.
  • Switch CurlDownloadStrategy#fetch to 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 opoo warning to stderr (the first contended lock attempt) while also expecting an exception, which can make the spec suite noisy. Consider passing quiet: true here 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 opoo warning to stderr before raising, which can add noise to test output. Passing quiet: true keeps 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.

Comment thread Library/Homebrew/download_strategy/curl_download_strategy.rb Outdated
Comment thread Library/Homebrew/test/lock_file/download_lock_spec.rb
Comment thread Library/Homebrew/lock_file/download_lock.rb Outdated
@dduugg
dduugg force-pushed the download-lock-wait-instead-of-fail branch 2 times, most recently from 94d7407 to fc7c3ce Compare July 30, 2026 01:29
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.
@dduugg
dduugg force-pushed the download-lock-wait-instead-of-fail branch from fc7c3ce to db0402e Compare July 30, 2026 03:20
@dduugg

dduugg commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Following up on the two comments Copilot suppressed as low confidence, both of which asked for quiet: true in the give-up examples to cut stderr noise.

Both are applied, but the stated rationale does not hold for this repo. Library/Homebrew/test/spec_helper.rb reopens $stderr to File::NULL around every example unless it is :focused or HOMEBREW_VERBOSE_TESTS is set, so a spec here cannot add noise to the suite output. Confirmed by running the file and grepping both streams for the warning, which finds nothing with or without the change. quiet: true is still worth keeping in those two examples, just for a different reason: they assert on the raised exception, so narrowing them to that is clearer.

Checking it did turn up a real gap, though one I introduced rather than Copilot. My fix for the "warns only once" comment replaced output(/Waiting for another Homebrew process.../).to_stderr with receive(:opoo).once, which tightened the count but dropped the only assertion on the message content, so nothing would have caught a reworded or empty warning. That example now asserts both:

expect(download_lock_copy).to receive(:opoo).once.with(
  /Waiting for another Homebrew process to finish downloading/,
).and_call_original

Verified it bites: changing the warning text fails that example, where a moment earlier it passed.

@MikeMcQuaid

Copy link
Copy Markdown
Member

Thanks!

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