Skip to content

Commit e2e4517

Browse files
committed
Extract queue staging hooks
- Keep bottle pre-pouring behaviour while moving it behind `Downloadable` queue staging hooks. - Move cask extraction and rename helpers onto `Cask::Download` so installer orchestration stays thinner. - Leave cask downloads as a no-op for queued staging so this stays behaviour-neutral before adding new queue work.
1 parent decec77 commit e2e4517

8 files changed

Lines changed: 158 additions & 108 deletions

File tree

Library/Homebrew/bottle.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# typed: strict
22
# frozen_string_literal: true
33

4+
require "unpack_strategy"
5+
46
class Bottle
57
include Downloadable
68

@@ -247,6 +249,55 @@ def sbom_supplement
247249
sig { returns(Filename) }
248250
def filename = Filename.new(@name, @pkg_version, @tag, @spec.rebuild)
249251

252+
sig { returns(Pathname) }
253+
def staged_path_from_download_queue
254+
bottle_filename = filename
255+
HOMEBREW_TEMP_CELLAR/bottle_filename.name/bottle_filename.version.to_s
256+
end
257+
258+
sig { returns(Pathname) }
259+
def staged_path_from_download_queue_marker
260+
Pathname("#{staged_path_from_download_queue}.poured")
261+
end
262+
263+
sig { override.params(_download: Pathname, pour: T::Boolean).returns(T::Boolean) }
264+
def stage_from_download_queue?(_download, pour:)
265+
pour
266+
end
267+
268+
sig { override.params(download: Pathname, pour: T::Boolean).void }
269+
def stage_from_download_queue(download, pour:)
270+
return unless pour
271+
272+
bottle_tmp_keg = staged_path_from_download_queue
273+
bottle_poured_file = staged_path_from_download_queue_marker
274+
275+
begin
276+
HOMEBREW_TEMP_CELLAR.mkpath
277+
278+
return if bottle_poured_file.exist?
279+
280+
FileUtils.rm(bottle_poured_file) if bottle_poured_file.symlink?
281+
FileUtils.rm_r(bottle_tmp_keg) if bottle_tmp_keg.directory?
282+
283+
UnpackStrategy.detect(download, prioritize_extension: true)
284+
.extract_nestedly(to: HOMEBREW_TEMP_CELLAR)
285+
286+
# Create a separate file to mark a completed extraction. This avoids
287+
# a potential race condition if a user interrupts the install.
288+
# We use a symlink to easily check that both this extra status file
289+
# and the real extracted directory exist via `Pathname#exist?`.
290+
FileUtils.ln_s(bottle_tmp_keg, bottle_poured_file)
291+
# Catch any exception type here to clean up partial queued extractions.
292+
rescue Exception # rubocop:disable Lint/RescueException
293+
ignore_interrupts do
294+
FileUtils.rm_r(bottle_tmp_keg) if bottle_tmp_keg.directory?
295+
bottle_tmp_keg.parent.rmdir_if_possible
296+
end
297+
raise
298+
end
299+
end
300+
250301
sig { returns(T.nilable(Resource::BottleManifest)) }
251302
def github_packages_manifest_resource
252303
return if @resource.download_strategy != CurlGitHubPackagesDownloadStrategy

Library/Homebrew/cask/download.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
require "downloadable"
55
require "fileutils"
6+
require "unpack_strategy"
67
require "cask/cache"
78
require "cask/quarantine"
89

@@ -87,6 +88,58 @@ def basename
8788
downloader.basename
8889
end
8990

91+
sig { returns(UnpackStrategy) }
92+
def primary_container
93+
@primary_container ||= T.let(
94+
begin
95+
downloaded_path = cask.download || fetch(quiet: true)
96+
UnpackStrategy.detect(downloaded_path, type: cask.container&.type, merge_xattrs: true)
97+
end,
98+
T.nilable(UnpackStrategy),
99+
)
100+
end
101+
102+
sig { params(to: Pathname, verbose: T::Boolean).void }
103+
def extract_primary_container(to:, verbose:)
104+
odebug "Extracting primary container"
105+
106+
container = primary_container
107+
raise "unexpected nil primary_container" unless container
108+
109+
odebug "Using container class #{container.class} for #{container.path}"
110+
111+
if (nested_container = cask.container&.nested)
112+
Dir.mktmpdir("cask-installer", HOMEBREW_TEMP) do |tmpdir|
113+
tmpdir = Pathname(tmpdir)
114+
container.extract(to: tmpdir, basename:, verbose:)
115+
116+
FileUtils.chmod_R "+rw", tmpdir/nested_container, force: true, verbose: verbose
117+
118+
UnpackStrategy.detect(tmpdir/nested_container, merge_xattrs: true)
119+
.extract_nestedly(to:, verbose:)
120+
end
121+
else
122+
container.extract_nestedly(to:, basename:, verbose:)
123+
end
124+
125+
return unless @quarantine
126+
return unless Quarantine.available?
127+
128+
Quarantine.propagate(from: container.path, to:)
129+
end
130+
131+
sig { params(target_dir: Pathname).void }
132+
def process_rename_operations(target_dir:)
133+
return if cask.rename.empty?
134+
135+
odebug "Processing rename operations in #{target_dir}"
136+
137+
cask.rename.each do |rename_operation|
138+
odebug "Renaming #{rename_operation.from} to #{rename_operation.to}"
139+
rename_operation.perform_rename(target_dir)
140+
end
141+
end
142+
90143
sig { override.returns(T::Boolean) }
91144
def downloaded_and_valid?
92145
return false unless super

Library/Homebrew/cask/installer.rb

Lines changed: 5 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,9 @@ def download(quiet: nil, timeout: nil)
264264

265265
sig { returns(UnpackStrategy) }
266266
def primary_container
267-
@primary_container ||= T.let(
268-
begin
269-
downloaded_path = download(quiet: true)
270-
UnpackStrategy.detect(downloaded_path, type: @cask.container&.type, merge_xattrs: true)
271-
end,
272-
T.nilable(UnpackStrategy),
273-
)
267+
download(quiet: true) if @cask.download.nil?
268+
269+
downloader.primary_container
274270
end
275271

276272
sig { returns(ArtifactSet) }
@@ -280,46 +276,12 @@ def artifacts
280276

281277
sig { params(to: Pathname).void }
282278
def extract_primary_container(to: @cask.staged_path)
283-
odebug "Extracting primary container"
284-
285-
container = primary_container
286-
raise "unexpected nil primary_container" unless container
287-
288-
odebug "Using container class #{container.class} for #{container.path}"
289-
290-
basename = downloader.basename
291-
292-
if (nested_container = @cask.container&.nested)
293-
Dir.mktmpdir("cask-installer", HOMEBREW_TEMP) do |tmpdir|
294-
tmpdir = Pathname(tmpdir)
295-
container.extract(to: tmpdir, basename:, verbose: verbose?)
296-
297-
FileUtils.chmod_R "+rw", tmpdir/nested_container, force: true, verbose: verbose?
298-
299-
UnpackStrategy.detect(tmpdir/nested_container, merge_xattrs: true)
300-
.extract_nestedly(to:, verbose: verbose?)
301-
end
302-
else
303-
container.extract_nestedly(to:, basename:, verbose: verbose?)
304-
end
305-
306-
return unless quarantine?
307-
return unless Quarantine.available?
308-
309-
Quarantine.propagate(from: container.path, to:)
279+
downloader.extract_primary_container(to:, verbose: verbose?)
310280
end
311281

312282
sig { params(target_dir: T.nilable(Pathname)).void }
313283
def process_rename_operations(target_dir: nil)
314-
return if @cask.rename.empty?
315-
316-
working_dir = target_dir || @cask.staged_path
317-
odebug "Processing rename operations in #{working_dir}"
318-
319-
@cask.rename.each do |rename_operation|
320-
odebug "Renaming #{rename_operation.from} to #{rename_operation.to}"
321-
rename_operation.perform_rename(working_dir)
322-
end
284+
downloader.process_rename_operations(target_dir: target_dir || @cask.staged_path)
323285
end
324286

325287
sig { params(predecessor: T.nilable(Cask)).void }

Library/Homebrew/download_queue.rb

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,19 @@ def initialize(retries: 1, force: false, pour: false)
4040
params(
4141
downloadable: Downloadable,
4242
check_attestation: T::Boolean,
43+
stage: T::Boolean,
4344
).void
4445
}
45-
def enqueue(downloadable, check_attestation: false)
46+
def enqueue(downloadable, check_attestation: false, stage: pour)
4647
@cancelled.make_false
4748
cached_location = downloadable.cached_download
4849

4950
@symlink_targets[cached_location] ||= Set.new
5051
targets = @symlink_targets.fetch(cached_location)
5152
targets << downloadable
5253

53-
@downloads_by_location[cached_location] ||= Concurrent::Promises.future_on(
54-
pool, RetryableDownload.new(downloadable, tries:, pour:),
54+
download = @downloads_by_location[cached_location] ||= Concurrent::Promises.future_on(
55+
pool, RetryableDownload.new(downloadable, tries:),
5556
@cancelled, force, quiet, check_attestation
5657
) do |download, cancelled, force, quiet, check_attestation|
5758
raise CancelledDownloadError if cancelled.true?
@@ -70,14 +71,28 @@ def enqueue(downloadable, check_attestation: false)
7071

7172
check_bottle_attestation(downloadable, check_attestation:)
7273
create_symlinks_for_shared_download(cached_location)
74+
cached_location
7375
rescue Interrupt
7476
raise CancelledDownloadError
7577
ensure
7678
@download_threads.delete(Thread.current)
7779
end
7880
end
7981

80-
downloads[downloadable] = @downloads_by_location.fetch(cached_location)
82+
downloads[downloadable] = if stage
83+
download.then_on(
84+
pool, downloadable, pour
85+
) do |downloaded_path, queued_downloadable, queue_pour|
86+
if queued_downloadable.stage_from_download_queue?(downloaded_path, pour: queue_pour)
87+
queued_downloadable.extracting!
88+
queued_downloadable.stage_from_download_queue(downloaded_path, pour: queue_pour)
89+
queued_downloadable.downloaded!
90+
end
91+
downloaded_path
92+
end
93+
else
94+
download
95+
end
8196
end
8297

8398
sig { void }

Library/Homebrew/downloadable.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,14 @@ def fetch(verify_download_integrity: true, timeout: nil, quiet: false)
168168
download
169169
end
170170

171+
sig { overridable.params(_download: Pathname, pour: T::Boolean).returns(T::Boolean) }
172+
def stage_from_download_queue?(_download, pour:)
173+
false
174+
end
175+
176+
sig { overridable.params(_download: Pathname, pour: T::Boolean).void }
177+
def stage_from_download_queue(_download, pour:); end
178+
171179
sig { overridable.params(filename: Pathname).void }
172180
def verify_download_integrity(filename)
173181
verifying!

Library/Homebrew/formula_installer.rb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,22 +1570,21 @@ def api_bottle
15701570
sig { void }
15711571
def pour
15721572
HOMEBREW_CELLAR.cd do
1573-
ohai "Pouring #{downloadable.downloader.basename}"
1573+
downloadable_object = downloadable
1574+
ohai "Pouring #{downloadable_object.downloader.basename}"
15741575

15751576
formula.rack.mkpath
15761577

15771578
# Download queue may have already extracted the bottle to a temporary directory.
15781579
# We cannot rely on `download_queue` here as dependencies may be poured by another installer.
1579-
formula_prefix_relative_to_cellar = formula.prefix.relative_path_from(HOMEBREW_CELLAR)
1580-
bottle_tmp_keg = HOMEBREW_TEMP_CELLAR/formula_prefix_relative_to_cellar
1581-
bottle_poured_file = Pathname("#{bottle_tmp_keg}.poured")
1582-
1583-
if bottle_poured_file.exist?
1580+
if downloadable_object.is_a?(Bottle) &&
1581+
(bottle_poured_file = downloadable_object.staged_path_from_download_queue_marker).exist?
1582+
bottle_tmp_keg = downloadable_object.staged_path_from_download_queue
15841583
FileUtils.rm(bottle_poured_file)
15851584
FileUtils.mv(bottle_tmp_keg, formula.prefix)
15861585
bottle_tmp_keg.parent.rmdir_if_possible
15871586
else
1588-
downloadable.downloader.stage
1587+
downloadable_object.downloader.stage
15891588
end
15901589
end
15911590

Library/Homebrew/retryable_download.rb

Lines changed: 4 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,13 @@ def checksum = downloadable.checksum
1919
sig { override.returns(T::Array[String]) }
2020
def mirrors = downloadable.mirrors
2121

22-
sig { params(downloadable: Downloadable, tries: Integer, pour: T::Boolean).void }
23-
def initialize(downloadable, tries:, pour: false)
22+
sig { params(downloadable: Downloadable, tries: Integer).void }
23+
def initialize(downloadable, tries:)
2424
super()
2525

2626
@downloadable = downloadable
2727
@try = T.let(0, Integer)
2828
@tries = tries
29-
@pour = pour
3029
end
3130

3231
sig { override.returns(String) }
@@ -58,8 +57,6 @@ def downloader = downloadable.downloader
5857
).returns(Pathname)
5958
}
6059
def fetch(verify_download_integrity: true, timeout: nil, quiet: false)
61-
bottle_tmp_keg = nil
62-
6360
@try += 1
6461

6562
downloadable.downloading!
@@ -84,41 +81,12 @@ def fetch(verify_download_integrity: true, timeout: nil, quiet: false)
8481
json_download = downloadable.is_a?(API::JSONDownload)
8582
downloadable.verify_download_integrity(download) if verify_download_integrity && !json_download
8683

87-
if pour && downloadable.is_a?(Bottle)
88-
downloadable.extracting!
89-
90-
HOMEBREW_TEMP_CELLAR.mkpath
91-
92-
bottle_filename = T.cast(downloadable, Bottle).filename
93-
bottle_tmp_keg = HOMEBREW_TEMP_CELLAR/bottle_filename.name/bottle_filename.version.to_s
94-
bottle_poured_file = Pathname("#{bottle_tmp_keg}.poured")
95-
96-
unless bottle_poured_file.exist?
97-
FileUtils.rm(bottle_poured_file) if bottle_poured_file.symlink?
98-
FileUtils.rm_r(bottle_tmp_keg) if bottle_tmp_keg.directory?
99-
100-
UnpackStrategy.detect(download, prioritize_extension: true)
101-
.extract_nestedly(to: HOMEBREW_TEMP_CELLAR)
102-
103-
# Create a separate file to mark a completed extraction. This avoids
104-
# a potential race condition if a user interrupts the install.
105-
# We use a symlink to easily check that both this extra status file
106-
# and the real extracted directory exist via `Pathname#exist?`.
107-
FileUtils.ln_s(bottle_tmp_keg, bottle_poured_file)
108-
end
109-
110-
downloadable.downloaded!
111-
elsif json_download
112-
FileUtils.touch(download, mtime: Time.now)
113-
end
84+
FileUtils.touch(download, mtime: Time.now) if json_download
11485

11586
download
11687
rescue DownloadError, ChecksumMismatchError, Resource::BottleManifest::Error => e
11788
tries_remaining = @tries - @try
118-
if tries_remaining.zero?
119-
cleanup_partial_installation_on_error!(bottle_tmp_keg)
120-
raise
121-
end
89+
raise if tries_remaining.zero?
12290

12391
wait = 2 ** @try
12492
unless quiet
@@ -132,10 +100,6 @@ def fetch(verify_download_integrity: true, timeout: nil, quiet: false)
132100
# fully-downloaded file is known-bad (checksum or manifest mismatch).
133101
downloadable.clear_cache unless e.is_a?(DownloadError)
134102
retry
135-
# Catch any other types of exceptions as they leave us with partial installations.
136-
rescue Exception # rubocop:disable Lint/RescueException
137-
cleanup_partial_installation_on_error!(bottle_tmp_keg)
138-
raise
139103
end
140104

141105
sig { override.params(filename: Pathname).void }
@@ -145,19 +109,5 @@ def verify_download_integrity(filename) = downloadable.verify_download_integrity
145109

146110
sig { returns(Downloadable) }
147111
attr_reader :downloadable
148-
149-
sig { returns(T::Boolean) }
150-
attr_reader :pour
151-
152-
sig { params(path: T.nilable(Pathname)).void }
153-
def cleanup_partial_installation_on_error!(path)
154-
return if path.nil?
155-
return unless path.directory?
156-
157-
ignore_interrupts do
158-
FileUtils.rm_r(path)
159-
path.parent.rmdir_if_possible
160-
end
161-
end
162112
end
163113
end

0 commit comments

Comments
 (0)