mirror of
https://github.com/Homebrew/brew.git
synced 2026-08-12 22:29:27 +04:00
- After #23381, homebrew-core CI fails installing dependencies on version-bump PRs: the bumped formula's stale bottle block derives its manifest URL from the new version, whose bottle has not been published yet, and `DownloadQueue#fetch` treats every bottle manifest failure as fatal. - Before that change the default ask-mode plan fetched manifests synchronously via `Formula#fetch_bottle_tab`, which rescues download errors so dependency resolution falls back to a full install, and the installer's memoisation then kept the queue from retrying the download. - Add `DownloadQueue#fetch(allow_failures:)`: failed downloads are still reported but neither raise nor mark the fetch or run as failed. Use it for the metadata-only drains (`fetch_formulae`'s bottle manifest waits, `brew install`'s ask-mode drain and `brew upgrade`'s tab prefetch), restoring the synchronous path's tolerance. Manifest failures in fetches that pour bottles remain fatal.
333 lines
13 KiB
Ruby
333 lines
13 KiB
Ruby
# typed: true
|
|
# frozen_string_literal: true
|
|
|
|
require "download_queue"
|
|
|
|
RSpec.describe Homebrew::DownloadQueue do
|
|
subject(:download_queue) { described_class.new }
|
|
|
|
let(:cached_download) { HOMEBREW_CACHE/"downloads/testball--0.1.tar.gz" }
|
|
let(:downloadable) do
|
|
instance_double(
|
|
Downloadable,
|
|
cached_download:,
|
|
checksum: nil,
|
|
downloaded_and_valid?: false,
|
|
downloader: nil,
|
|
download_queue_message: "Bottle testball",
|
|
download_queue_name: "testball",
|
|
download_queue_type: "Bottle",
|
|
)
|
|
end
|
|
let(:download_error) { DownloadError.new(downloadable, RuntimeError.new("network blew up")) }
|
|
let(:multi_line_download_error) { DownloadError.new(downloadable, RuntimeError.new("line one\nline two")) }
|
|
let(:retryable_download) { instance_double(Homebrew::RetryableDownload) }
|
|
|
|
before do
|
|
allow(Homebrew::EnvConfig).to receive(:download_concurrency).and_return(2)
|
|
allow(retryable_download).to receive(:fetch).and_raise(download_error)
|
|
allow(Homebrew::RetryableDownload).to receive(:new).and_return(retryable_download)
|
|
end
|
|
|
|
after do
|
|
download_queue.shutdown
|
|
end
|
|
|
|
it "reports rejected download errors in parallel mode and marks the fetch as failed" do
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect { download_queue.fetch }.to output(/network blew up/).to_stderr
|
|
|
|
expect(download_queue.fetch_failed).to be(true)
|
|
expect(Homebrew).to have_failed
|
|
end
|
|
|
|
it "defers multi-line failure details on a TTY until the in-place redraw has finished" do
|
|
allow($stdout).to receive(:tty?).and_return(true)
|
|
ENV["TERM"] = "xterm-256color"
|
|
allow(downloadable).to receive(:fetched_size).and_return(nil)
|
|
allow(retryable_download).to receive(:fetch).and_raise(multi_line_download_error)
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
# Stub the write methods (rather than reassigning $stdout/$stderr) so both
|
|
# streams append to one buffer in call order, the same way they'd interleave
|
|
# on a real terminal.
|
|
combined_output = +""
|
|
allow($stdout).to receive(:print) { |message| combined_output << message }
|
|
allow($stdout).to receive(:flush)
|
|
allow($stderr).to receive(:puts) { |message| combined_output << "#{message}\n" }
|
|
|
|
download_queue.fetch
|
|
|
|
show_cursor_index = combined_output.index(Tty.show_cursor)
|
|
failure_index = combined_output.index("line one\nline two")
|
|
|
|
expect(show_cursor_index).not_to be_nil
|
|
expect(failure_index).not_to be_nil
|
|
# The redraw loop assumes every line it prints in-place occupies exactly one
|
|
# terminal row, so a multi-line failure must be held back until the cursor is
|
|
# restored to normal scrolling, not printed while the redraw is still live.
|
|
expect(failure_index).to be > show_cursor_index
|
|
end
|
|
|
|
it "fetches only downloads of the given class and keeps others queued unreported" do
|
|
manifest = instance_double(
|
|
Resource::BottleManifest,
|
|
cached_download: HOMEBREW_CACHE/"downloads/testball_manifest.json",
|
|
checksum: nil,
|
|
downloaded_and_valid?: true,
|
|
downloader: nil,
|
|
download_queue_message: "Bottle Manifest testball",
|
|
download_queue_name: "testball",
|
|
download_queue_type: "Bottle Manifest",
|
|
)
|
|
allow(manifest).to receive(:is_a?) { |klass| klass == Resource::BottleManifest }
|
|
|
|
download_queue.enqueue(manifest)
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect do
|
|
download_queue.fetch(only: Resource::BottleManifest)
|
|
end.to output(/Bottle Manifest testball/).to_stderr
|
|
expect(download_queue.downloads.keys).to eq [downloadable]
|
|
end
|
|
|
|
it "raises and clears queue state on a bottle manifest failure in parallel mode" do
|
|
allow(retryable_download).to receive(:fetch).and_raise(Resource::BottleManifest::Error.new("manifest missing"))
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect { download_queue.fetch }.to raise_error(Resource::BottleManifest::Error, /manifest missing/)
|
|
expect(download_queue.downloads).to be_empty
|
|
end
|
|
|
|
it "reports but tolerates failed downloads when failures are allowed" do
|
|
allow(retryable_download).to receive(:fetch).and_raise(Resource::BottleManifest::Error.new("manifest missing"))
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect { download_queue.fetch(allow_failures: true) }.to output(/✘/).to_stderr
|
|
expect(download_queue.fetch_failed).to be false
|
|
expect(Homebrew).not_to have_failed
|
|
end
|
|
|
|
it "tolerates failed downloads in serial mode when failures are allowed" do
|
|
allow(Homebrew::EnvConfig).to receive(:download_concurrency).and_return(1)
|
|
allow(retryable_download).to receive(:fetch).and_raise(Resource::BottleManifest::Error.new("manifest missing"))
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect { download_queue.fetch(allow_failures: true) }.to output(/✘/).to_stderr
|
|
expect(Homebrew).not_to have_failed
|
|
end
|
|
|
|
it "removes known-bad cached files for tolerated checksum mismatches" do
|
|
cached_download.dirname.mkpath
|
|
cached_download.write("corrupt")
|
|
allow(retryable_download).to receive(:fetch)
|
|
.and_raise(ChecksumMismatchError.new(cached_download, Checksum.new("aa" * 32), Checksum.new("bb" * 32)))
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect { download_queue.fetch(allow_failures: true) }.to output(/✘/).to_stderr
|
|
expect(cached_download).not_to exist
|
|
expect(Homebrew).not_to have_failed
|
|
end
|
|
|
|
it "removes known-bad cached files for tolerated checksum mismatches in serial mode" do
|
|
allow(Homebrew::EnvConfig).to receive(:download_concurrency).and_return(1)
|
|
cached_download.dirname.mkpath
|
|
cached_download.write("corrupt")
|
|
allow(retryable_download).to receive(:fetch)
|
|
.and_raise(ChecksumMismatchError.new(cached_download, Checksum.new("aa" * 32), Checksum.new("bb" * 32)))
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect { download_queue.fetch(allow_failures: true) }.to output(/✘/).to_stderr
|
|
expect(cached_download).not_to exist
|
|
expect(Homebrew).not_to have_failed
|
|
end
|
|
|
|
it "cancels remaining downloads and raises on a bottle manifest failure in serial mode" do
|
|
allow(Homebrew::EnvConfig).to receive(:download_concurrency).and_return(1)
|
|
allow(retryable_download).to receive(:fetch).and_raise(Resource::BottleManifest::Error.new("manifest missing"))
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect(download_queue).to receive(:cancel)
|
|
expect { download_queue.fetch }.to raise_error(Resource::BottleManifest::Error, /manifest missing/)
|
|
end
|
|
|
|
it "brackets TTY redraw frames in a DEC 2026 synchronized update" do
|
|
allow($stdout).to receive(:tty?).and_return(true)
|
|
ENV["TERM"] = "xterm-256color"
|
|
allow(downloadable).to receive(:fetched_size).and_return(nil)
|
|
allow(retryable_download).to receive(:fetch).and_return(cached_download)
|
|
|
|
# Build the queue while stdout is a TTY so it captures the TTY render path,
|
|
# before the output matcher swaps $stdout to capture the redraw frames.
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect { download_queue.fetch }.to output(
|
|
include("\e[?2026h").and(
|
|
satisfy("closes every synchronized update it opens") do |out|
|
|
last_open = out.rindex("\e[?2026h")
|
|
last_close = out.rindex("\e[?2026l")
|
|
out.scan("\e[?2026h").length <= out.scan("\e[?2026l").length &&
|
|
!last_open.nil? && !last_close.nil? && last_close > last_open
|
|
end,
|
|
),
|
|
).to_stdout
|
|
end
|
|
|
|
it "leaves the final terminal column empty when rendering progress" do
|
|
allow($stdout).to receive(:tty?).and_return(true)
|
|
ENV["TERM"] = "xterm-256color"
|
|
allow(Tty).to receive(:width).and_return(80)
|
|
allow(downloadable).to receive_messages(fetched_size: 559_300_000, total_size: 559_300_000, phase: :downloading)
|
|
allow(retryable_download).to receive(:fetch).and_return(cached_download)
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
rendered_lines = []
|
|
allow(download_queue).to receive(:stdout_print_and_flush) do |message|
|
|
rendered_lines << message if message.include?("559.3MB")
|
|
end
|
|
download_queue.fetch
|
|
|
|
expect(Tty.strip_ansi(rendered_lines.fetch(0)).chomp.each_grapheme_cluster.count).to eq(Tty.width - 1)
|
|
end
|
|
|
|
it "emits no synchronized update sequences when stdout is not a TTY" do
|
|
allow($stdout).to receive(:tty?).and_return(false)
|
|
allow(retryable_download).to receive(:fetch).and_return(cached_download)
|
|
|
|
download_queue.enqueue(downloadable)
|
|
|
|
expect { download_queue.fetch }.not_to output(/\e\[\?2026/).to_stdout
|
|
end
|
|
|
|
it "wakes when downloads complete instead of polling with sleep" do
|
|
allow($stdout).to receive(:tty?).and_return(false)
|
|
allow(retryable_download).to receive(:fetch) do
|
|
sleep 0.1
|
|
cached_download
|
|
end
|
|
|
|
expect(download_queue).not_to receive(:sleep)
|
|
|
|
expect do
|
|
download_queue.enqueue(downloadable)
|
|
download_queue.fetch
|
|
end.to output(/✔︎ Bottle testball/).to_stderr
|
|
end
|
|
|
|
it "skips fetching already downloaded files with a valid checksum" do
|
|
cached_download.dirname.mkpath
|
|
cached_download.write("already downloaded")
|
|
|
|
allow(downloadable).to receive(:downloaded_and_valid?).and_return(true)
|
|
|
|
expect(retryable_download).not_to receive(:fetch)
|
|
expect(downloadable).to receive(:downloader).and_return(nil)
|
|
|
|
download_queue.enqueue(downloadable)
|
|
download_queue.fetch
|
|
end
|
|
|
|
it "runs queued staging before completing the fetch" do
|
|
allow(retryable_download).to receive(:fetch).and_return(cached_download)
|
|
|
|
expect(downloadable).to receive(:stage_from_download_queue?).with(cached_download, pour: false).and_return(true)
|
|
expect(downloadable).to receive(:extracting!).ordered
|
|
expect(downloadable).to receive(:stage_from_download_queue).with(cached_download, pour: false).ordered
|
|
expect(downloadable).to receive(:downloaded!).ordered
|
|
|
|
download_queue.enqueue(downloadable, stage: true)
|
|
download_queue.fetch
|
|
end
|
|
|
|
it "uses the fetched path for queued staging when it changes during fetch" do
|
|
fetched_download = HOMEBREW_CACHE/"downloads/fetched-testball--0.1.tar.gz"
|
|
allow(retryable_download).to receive(:fetch).and_return(fetched_download)
|
|
|
|
expect(downloadable).to receive(:stage_from_download_queue?).with(fetched_download, pour: false).and_return(true)
|
|
expect(downloadable).to receive(:extracting!).ordered
|
|
expect(downloadable).to receive(:stage_from_download_queue).with(fetched_download, pour: false).ordered
|
|
expect(downloadable).to receive(:downloaded!).ordered
|
|
|
|
download_queue.enqueue(downloadable, stage: true)
|
|
download_queue.fetch
|
|
end
|
|
|
|
it "interrupts queued staging when the fetch is interrupted", timeout: 5 do
|
|
allow(retryable_download).to receive(:fetch).and_return(cached_download)
|
|
allow(downloadable).to receive(:stage_from_download_queue?).and_return(true)
|
|
allow(downloadable).to receive_messages(extracting!: nil, downloaded!: nil)
|
|
fetch_started = Queue.new
|
|
staging_started = Queue.new
|
|
staging_interrupted = Queue.new
|
|
release_fetch = Queue.new
|
|
release_staging = Queue.new
|
|
allow(downloadable).to receive(:download_queue_message) do
|
|
fetch_started << true
|
|
release_fetch.pop
|
|
"Bottle testball"
|
|
end
|
|
allow(downloadable).to receive(:stage_from_download_queue) do
|
|
staging_started << true
|
|
release_staging.pop
|
|
rescue Interrupt
|
|
staging_interrupted << true
|
|
raise
|
|
end
|
|
|
|
download_queue.enqueue(downloadable, stage: true)
|
|
fetch_thread = Thread.current
|
|
interrupter = Thread.new do
|
|
next unless staging_started.pop(timeout: 1)
|
|
next unless fetch_started.pop(timeout: 1)
|
|
|
|
fetch_thread.raise(Interrupt)
|
|
end
|
|
|
|
expect { download_queue.fetch }.to raise_error(Interrupt)
|
|
expect(staging_interrupted.pop(timeout: 1)).to be(true)
|
|
ensure
|
|
release_fetch&.push(true)
|
|
release_staging&.push(true)
|
|
interrupter.kill if interrupter && !interrupter.join(1)
|
|
end
|
|
|
|
it "promotes an in-flight download to queued staging" do
|
|
expect(retryable_download).to receive(:fetch).once.and_return(cached_download)
|
|
expect(downloadable).to receive(:stage_from_download_queue?).with(cached_download, pour: false).and_return(true)
|
|
expect(downloadable).to receive(:extracting!).ordered
|
|
expect(downloadable).to receive(:stage_from_download_queue).with(cached_download, pour: false).ordered
|
|
expect(downloadable).to receive(:downloaded!).ordered
|
|
|
|
download_queue.enqueue(downloadable)
|
|
download_queue.enqueue(downloadable, stage: true)
|
|
download_queue.fetch
|
|
end
|
|
|
|
it "checks attestations for valid cached bottles" do
|
|
bottle = Bottle.allocate
|
|
allow(bottle).to receive_messages(
|
|
cached_download:,
|
|
checksum: nil,
|
|
downloaded_and_valid?: true,
|
|
downloader: nil,
|
|
download_queue_message: "Bottle testball",
|
|
download_queue_name: "testball",
|
|
download_queue_type: "Bottle",
|
|
)
|
|
|
|
expect(Utils::Attestation).to receive(:check_attestation).with(bottle, quiet: true)
|
|
|
|
download_queue.enqueue(bottle, check_attestation: true)
|
|
download_queue.fetch
|
|
end
|
|
end
|