From 1e702d96dcebca4c3ff49e05ed8c24dce5cee804 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Wed, 19 Aug 2026 21:06:17 +0100 Subject: [PATCH 01/19] Defer API formula inflation - Skip option-only inflation for API-backed core formula names and mappings while retaining it for custom formulae. - Read cached API names and mappings once per parse, including renames. - `brew prof --timings`: command `cli_parse` fell from 43.434 ms to 7.152 ms; inflation moved to `formula_resolution`. - `hyperfine --warmup 3 --runs 20` on warm no-op `brew install hello`: baseline 315.0 +/- 13.0 ms, deferred 316.8 +/- 20.3 ms (1.01 +/- 0.08x, statistically neutral). - Interleaved 20-pair wall clock was neutral: deferred minus baseline averaged +0.775 ms with a -1.522 ms median. --- Library/Homebrew/api/internal.rb | 3 +- Library/Homebrew/cli/parser.rb | 23 ++++++ Library/Homebrew/test/api/internal_spec.rb | 9 +++ Library/Homebrew/test/cli/parser_spec.rb | 88 ++++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/Library/Homebrew/api/internal.rb b/Library/Homebrew/api/internal.rb index dd023a4c0d..93a9a7e5de 100644 --- a/Library/Homebrew/api/internal.rb +++ b/Library/Homebrew/api/internal.rb @@ -191,7 +191,8 @@ module Homebrew download_and_cache_data! unless data_loaded? Homebrew::API.write_names_file!("formula", regenerate:) { formula_names } - Homebrew::API.write_aliases_file!("formula", regenerate:) { formula_aliases } + # Renames resolve like aliases in lightweight formula lookups. + Homebrew::API.write_aliases_file!("formula", regenerate:) { formula_aliases.merge(formula_renames) } Homebrew::API.write_executables_file!(regenerate:, source: cached_packages_json_file_path) { formula_hashes } end diff --git a/Library/Homebrew/cli/parser.rb b/Library/Homebrew/cli/parser.rb index 3d37960a38..d791664d56 100644 --- a/Library/Homebrew/cli/parser.rb +++ b/Library/Homebrew/cli/parser.rb @@ -1134,12 +1134,35 @@ module Homebrew else :stable end + # homebrew/core formulae cannot define options, so defer their inflation until command execution. + install_from_api = !Homebrew::EnvConfig.no_install_from_api? + api_formula_cache_contents = if install_from_api && named_args.any? + %w[formula_names.txt formula_aliases.txt].filter_map do |file| + path = Homebrew::API::HOMEBREW_CACHE_API/file + path.read if path.file? + end + else + [] + end # Only lowercase names, not paths, bottle filenames or URLs named_args.filter_map do |arg| next if arg.match?(HOMEBREW_CASK_TAP_CASK_REGEX) begin + api_formula_name = arg[HOMEBREW_DEFAULT_TAP_FORMULA_REGEX, :name] + if install_from_api && api_formula_name + api_formula_pattern = /^#{Regexp.escape(api_formula_name)}(?:\||$)/ + cached_api_formula = arg != api_formula_name || api_formula_cache_contents.any? do |contents| + contents.match?(api_formula_pattern) + end + cached_api_formula ||= Homebrew::API::Internal.formula_hashes_cached? && + (Homebrew::API.formula_name?(api_formula_name) || + Homebrew::API.formula_aliases.key?(api_formula_name) || + Homebrew::API.formula_renames.key?(api_formula_name)) + next if cached_api_formula + end + Formulary.factory(arg, spec, flags: argv.select { |a| a.start_with?("--") }) rescue FormulaUnavailableError, FormulaSpecificationError nil diff --git a/Library/Homebrew/test/api/internal_spec.rb b/Library/Homebrew/test/api/internal_spec.rb index 3a95282a30..6aa7324bf1 100644 --- a/Library/Homebrew/test/api/internal_spec.rb +++ b/Library/Homebrew/test/api/internal_spec.rb @@ -219,6 +219,15 @@ RSpec.describe Homebrew::API::Internal do expect((cache_dir/"internal/executables.txt").read).to eq("foo:foo-bin food\n") end + it "writes formula aliases and renames as cached mappings" do + Homebrew::API.write_names_and_aliases + + mappings = formulae_aliases.merge(formulae_renames).map { |old_name, name| "#{old_name}|#{name}" }.sort + expect((cache_dir/"formula_aliases.txt").read.lines(chomp: true)).to eq( + mappings, + ) + end + it "returns the expected cask hashes" do cask_hashes_output = described_class.cask_hashes expect(cask_hashes_output).to eq cask_hashes diff --git a/Library/Homebrew/test/cli/parser_spec.rb b/Library/Homebrew/test/cli/parser_spec.rb index ed5c8dcbb6..9622533c94 100644 --- a/Library/Homebrew/test/cli/parser_spec.rb +++ b/Library/Homebrew/test/cli/parser_spec.rb @@ -399,6 +399,94 @@ RSpec.describe Homebrew::CLI::Parser do end end + describe "formula options" do + subject(:parser) do + described_class.new(Cmd) do + formula_options + end + end + + let(:api_cache) { mktmpdir } + + before do + stub_const("Homebrew::API::HOMEBREW_CACHE_API", api_cache) + allow(Homebrew::EnvConfig).to receive(:no_install_from_api?).and_return(false) + end + + it "does not inflate API-backed formulae" do + (api_cache/"formula_names.txt").write("foo\n") + + expect(Formulary).not_to receive(:factory) + parser.parse(["foo"]) + end + + it "reads API cache files once for multiple formulae" do + (api_cache/"formula_names.txt").write("foo\n") + (api_cache/"formula_aliases.txt").write("bar|foo\n") + reads = Hash.new(0) + allow_any_instance_of(Pathname).to receive(:read).and_wrap_original do |method| + reads[method.receiver.basename.to_s] += 1 + method.call + end + + expect(Formulary).not_to receive(:factory) + parser.parse(%w[foo bar]) + expect(reads).to eq("formula_names.txt" => 1, "formula_aliases.txt" => 1) + end + + it "inflates API-backed formulae when commands access them" do + (api_cache/"formula_names.txt").write("foo\n") + full_formula = formula "foo", path: api_cache/"foo.rb", tap: Tap.fetch("user/repo") do + url "https://brew.sh/foo-1.0.tar.gz" + version "1.0" + end + + expect(Formulary).to receive(:factory).once.and_return(full_formula) + expect(parser.parse(["foo"]).named.to_formulae).to eq([full_formula]) + end + + it "does not inflate API-backed formula aliases or renames" do + (api_cache/"formula_aliases.txt").write("foo-alias|foo\nfoo-old|foo\n") + + expect(Formulary).not_to receive(:factory) + parser.parse(%w[foo-alias foo-old]) + end + + it "uses API metadata already loaded when the cache files are missing" do + allow(Homebrew::API::Internal).to receive(:formula_hashes_cached?).and_return(true) + allow(Homebrew::API).to receive_messages(formula_name?: false, formula_aliases: { "foo-alias" => "foo" }, + formula_renames: { "foo-old" => "foo" }) + allow(Homebrew::API).to receive(:formula_name?).with("foo").and_return(true) + + expect(Formulary).not_to receive(:factory) + parser.parse(%w[foo foo-alias foo-old]) + end + + it "does not inflate a fully qualified core formula without API caches" do + allow(Homebrew::API::Internal).to receive(:formula_hashes_cached?).and_return(false) + + expect(Formulary).not_to receive(:factory) + parser.parse(["homebrew/core/foo"]) + end + + it "inflates other formulae to parse their options" do + allow(Homebrew::API::Internal).to receive(:formula_hashes_cached?).and_return(false) + option = instance_double(Option, flag: "--with-foo", description: "Build with foo") + formula = instance_double(Formula, name: "foo", options: [option]) + + expect(Formulary).to receive(:factory).and_return(formula) + parser.parse(%w[foo --with-foo]) + end + + it "inflates formulae when API installs are disabled" do + allow(Homebrew::EnvConfig).to receive(:no_install_from_api?).and_return(true) + formula = instance_double(Formula, name: "foo", options: []) + + expect(Formulary).to receive(:factory).and_return(formula) + parser.parse(["foo"]) + end + end + describe "usage banner generation" do it "includes `[options]` if more than two non-global options are available" do parser = described_class.new(Cmd) do From f09cd41028b4f1198438197eff3eb426ee62cd6a Mon Sep 17 00:00:00 2001 From: Issy Long Date: Wed, 19 Aug 2026 10:06:06 +0100 Subject: [PATCH 02/19] sorbet: Bump more Cask tests to higher typed levels - Generate typed on_arm and on_intel methods from the Cask DSL compiler instead of maintaining a manual RBI shim. Their blocks are required, because the generated methods call them unconditionally once the architecture matches. - Define Download version and checksum lets in the same scope as the doubles that consume them. --- Library/Homebrew/sorbet/rbi/dsl/cask/dsl.rbi | 6 ++++++ Library/Homebrew/sorbet/tapioca/compilers/cask/dsl.rb | 10 ++++++++++ Library/Homebrew/test/cask/download_spec.rb | 8 +++----- Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Library/Homebrew/sorbet/rbi/dsl/cask/dsl.rbi b/Library/Homebrew/sorbet/rbi/dsl/cask/dsl.rbi index b20d783d31..aa79d89267 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/cask/dsl.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/cask/dsl.rbi @@ -63,6 +63,12 @@ class Cask::DSL sig { params(args: T.anything, kwargs: T.anything).void } def mdimporter(*args, **kwargs); end + sig { params(block: T.proc.bind(Cask::DSL).returns(T.anything)).returns(T.anything) } + def on_arm(&block); end + + sig { params(block: T.proc.bind(Cask::DSL).returns(T.anything)).returns(T.anything) } + def on_intel(&block); end + sig { params(args: T.anything, kwargs: T.anything).void } def pkg(*args, **kwargs); end diff --git a/Library/Homebrew/sorbet/tapioca/compilers/cask/dsl.rb b/Library/Homebrew/sorbet/tapioca/compilers/cask/dsl.rb index f0c9cd8f1f..08ad9a3376 100644 --- a/Library/Homebrew/sorbet/tapioca/compilers/cask/dsl.rb +++ b/Library/Homebrew/sorbet/tapioca/compilers/cask/dsl.rb @@ -48,6 +48,16 @@ module Tapioca return_type: "void", ) end + + OnSystem::ARCH_OPTIONS.each do |arch| + klass.create_method( + "on_#{arch}", + parameters: [ + create_block_param("block", type: "T.proc.bind(Cask::DSL).returns(T.anything)"), + ], + return_type: "T.anything", + ) + end end end diff --git a/Library/Homebrew/test/cask/download_spec.rb b/Library/Homebrew/test/cask/download_spec.rb index f74d643cc0..101ad1dad8 100644 --- a/Library/Homebrew/test/cask/download_spec.rb +++ b/Library/Homebrew/test/cask/download_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true RSpec.describe Cask::Download, :cask do @@ -9,11 +9,10 @@ RSpec.describe Cask::Download, :cask do let(:full_token) { token } let(:url) { instance_double(URL, to_s: url_to_s, specs: {}) } let(:url_to_s) { "https://example.com/app.dmg" } + let(:version) { nil } let(:cask) { instance_double(Cask::Cask, token:, full_token:, version:, url:) } context "when cask has no version" do - let(:version) { nil } - it "returns the cask token" do expect(download_name).to eq "example-cask" end @@ -133,6 +132,7 @@ RSpec.describe Cask::Download, :cask do subject(:verification) { described_class.new(cask).verify_download_integrity(downloaded_path) } let(:tap) { nil } + let(:expected_sha256) { nil } let(:cask) { instance_double(Cask::Cask, token: "cask", sha256: expected_sha256, tap:) } let(:cafebabe) { "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe" } let(:deadbeef) { "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" } @@ -168,8 +168,6 @@ RSpec.describe Cask::Download, :cask do end context "when the expected checksum is nil" do - let(:expected_sha256) { nil } - it "outputs an error" do expect { verification }.to output(/sha256 "#{computed_sha256}"/).to_stderr end diff --git a/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb b/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb index 8354ce52da..4f4e122d9f 100644 --- a/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "cmd/shared_examples/args_parse" From 642ed214faeed44b9c92c5c8a6153c86da76f8ec Mon Sep 17 00:00:00 2001 From: Issy Long Date: Wed, 19 Aug 2026 10:10:25 +0100 Subject: [PATCH 03/19] sorbet: Bump more OS-specific tests to higher typed levels - Define the temporary ld.so configuration directory as an RSpec let so setup, examples and cleanup share a non-nil Pathname. - Bind the anonymous native-library module after extending it, and use a singleton-method block that preserves the extended receiver type. --- Library/Homebrew/test/os/linux/ld_spec.rb | 6 +++--- Library/Homebrew/test/os/mac/ffi/native_library_spec.rb | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Library/Homebrew/test/os/linux/ld_spec.rb b/Library/Homebrew/test/os/linux/ld_spec.rb index b82c6670e4..087882853e 100644 --- a/Library/Homebrew/test/os/linux/ld_spec.rb +++ b/Library/Homebrew/test/os/linux/ld_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "os/linux/ld" @@ -61,9 +61,9 @@ RSpec.describe OS::Linux::Ld do end describe "::library_paths" do - ld_etc = Pathname("") + let(:ld_etc) { Pathname(Dir.mktmpdir("homebrew-tests-ld-etc-", Dir.tmpdir)) } + before do - ld_etc = Pathname(Dir.mktmpdir("homebrew-tests-ld-etc-", Dir.tmpdir)) FileUtils.mkdir [ld_etc/"subdir1", ld_etc/"subdir2"] (ld_etc/"ld.so.conf").write <<~EOS # This line is a comment diff --git a/Library/Homebrew/test/os/mac/ffi/native_library_spec.rb b/Library/Homebrew/test/os/mac/ffi/native_library_spec.rb index 59d24daccb..011104a925 100644 --- a/Library/Homebrew/test/os/mac/ffi/native_library_spec.rb +++ b/Library/Homebrew/test/os/mac/ffi/native_library_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "os/mac/ffi/native_library" @@ -8,9 +8,12 @@ RSpec.describe MacOS::FFI::NativeLibrary, :needs_macos do Module.new do extend MacOS::FFI::NativeLibrary + T.bind(self, T.all(Module, MacOS::FFI::NativeLibrary)) + use_library "/usr/lib/libSystem.B.dylib" - def self.process_id + define_singleton_method(:process_id) do + T.bind(self, T.all(Module, MacOS::FFI::NativeLibrary)) function("getpid", [], Fiddle::TYPE_INT).call end end From d50539c869591a4ac4c484ef105f414006fc8d5b Mon Sep 17 00:00:00 2001 From: Issy Long Date: Wed, 19 Aug 2026 11:11:11 +0100 Subject: [PATCH 04/19] sorbet: Bump more command tests to higher typed levels - Match the runtime-generated command option through have_attributes and report a missing command class as an expectation failure rather than a raised error. - Use the typed hash table-test helper for BundleVersion mappings. - Bind inline Formula blocks in vulnerability-advisory command specs to the Formula DSL receiver. --- Library/Homebrew/test/abstract_command_spec.rb | 8 +++++--- Library/Homebrew/test/bundle_version_spec.rb | 4 ++-- .../test/dev-cmd/generate-vulns-advisories_spec.rb | 14 +++++++++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Library/Homebrew/test/abstract_command_spec.rb b/Library/Homebrew/test/abstract_command_spec.rb index 86fb272c61..1c1bfe9d2f 100644 --- a/Library/Homebrew/test/abstract_command_spec.rb +++ b/Library/Homebrew/test/abstract_command_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "abstract_command" @@ -23,7 +23,7 @@ RSpec.describe Homebrew::AbstractCommand do end it "allows access to args" do - expect(TestCat.new(["--bar", "baz"]).args.bar).to eq("baz") + expect(TestCat.new(["--bar", "baz"]).args).to have_attributes(bar: "baz") end it "raises on invalid args" do @@ -68,7 +68,9 @@ RSpec.describe Homebrew::AbstractCommand do filename = File.basename(file, ".rb") require(file) command = described_class.command(filename) - expect(Pathname(File.join(__dir__, "../#{dir}/#{command.command_name}.rb"))).to exist + expect(command).not_to be_nil, "No command found for #{filename}" + + expect(Pathname(File.join(__dir__, "../#{dir}/#{command&.command_name}.rb"))).to exist end end end diff --git a/Library/Homebrew/test/bundle_version_spec.rb b/Library/Homebrew/test/bundle_version_spec.rb index 62723dd91b..691f325086 100644 --- a/Library/Homebrew/test/bundle_version_spec.rb +++ b/Library/Homebrew/test/bundle_version_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "bundle_version" @@ -41,7 +41,7 @@ RSpec.describe Homebrew::BundleVersion do ["2.5.2(3329)", "3329"] => "2.5.2,3329", } - expected_mappings.each do |(short_version, version), expected_version| + test_each_hash(expected_mappings) do |(short_version, version), expected_version| it "maps (#{short_version.inspect}, #{version.inspect}) to #{expected_version.inspect}" do expect(described_class.new(short_version, version).nice_version) .to eq expected_version diff --git a/Library/Homebrew/test/dev-cmd/generate-vulns-advisories_spec.rb b/Library/Homebrew/test/dev-cmd/generate-vulns-advisories_spec.rb index 140a699f58..05582e789f 100644 --- a/Library/Homebrew/test/dev-cmd/generate-vulns-advisories_spec.rb +++ b/Library/Homebrew/test/dev-cmd/generate-vulns-advisories_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "cmd/shared_examples/args_parse" @@ -19,7 +19,10 @@ RSpec.describe Homebrew::DevCmd::GenerateVulnsAdvisories do resolves "CVE-2015-2305" end end - plain = formula("plain") { url "https://example.com/plain-1.0.tar.gz" } + plain = formula("plain") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/plain-1.0.tar.gz" + end [nvi, plain].each do |f| allow(f).to receive(:to_hash_with_variations) .and_return({ "patches" => f.serialized_patches, "variations" => {} }) @@ -104,7 +107,12 @@ RSpec.describe Homebrew::DevCmd::GenerateVulnsAdvisories do describe "#first_fixed_version" do subject(:cmd) { described_class.new(["out"]) } - let(:current) { formula("x") { url "https://example.com/x-1.2.tar.gz" } } + let(:current) do + formula("x") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/x-1.2.tar.gz" + end + end def with_history(revisions) fv = instance_double(FormulaVersions) From 6d88cacbd5e5102cdeee85ef66d5a4a6ed0b63b2 Mon Sep 17 00:00:00 2001 From: Issy Long Date: Wed, 19 Aug 2026 13:13:10 +0100 Subject: [PATCH 05/19] sorbet: Bump more core tests to higher typed levels - Bind custom Bash and Version matcher blocks to RSpec matcher DSL receivers so their match and failure-message APIs are visible. - Define Requirement tags alongside their subject and bind the anonymous fatal Requirement subclass to its class DSL. --- Library/Homebrew/test/bash_spec.rb | 3 ++- Library/Homebrew/test/requirement_spec.rb | 5 ++++- Library/Homebrew/test/version_spec.rb | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/test/bash_spec.rb b/Library/Homebrew/test/bash_spec.rb index dec719f01a..edfdb9bbd7 100644 --- a/Library/Homebrew/test/bash_spec.rb +++ b/Library/Homebrew/test/bash_spec.rb @@ -1,10 +1,11 @@ -# typed: false +# typed: true # frozen_string_literal: true require "open3" RSpec.describe "Bash" do matcher :have_valid_bash_syntax do + T.bind(self, T.class_of(RSpec::Matchers::DSL::Matcher)) match do |file| stdout, stderr, status = Open3.capture3("/bin/bash", "-n", file) diff --git a/Library/Homebrew/test/requirement_spec.rb b/Library/Homebrew/test/requirement_spec.rb index dbc252a8ed..1643488d05 100644 --- a/Library/Homebrew/test/requirement_spec.rb +++ b/Library/Homebrew/test/requirement_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "extend/ENV" @@ -21,6 +21,8 @@ RSpec.describe Requirement do describe "#tags" do subject(:req) { klass.new(tags) } + let(:tags) { [] } + context "with a single tag" do let(:tags) { ["bar"] } @@ -50,6 +52,7 @@ RSpec.describe Requirement do describe "#fatal true is specified" do let(:klass) do Class.new(described_class) do + T.bind(self, T.class_of(Requirement)) fatal true end end diff --git a/Library/Homebrew/test/version_spec.rb b/Library/Homebrew/test/version_spec.rb index 13e9627b72..23b3625925 100644 --- a/Library/Homebrew/test/version_spec.rb +++ b/Library/Homebrew/test/version_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "version" @@ -433,8 +433,9 @@ RSpec.describe Version do describe "::detect" do matcher :be_detected_from do |url, **specs| + T.bind(self, T.class_of(RSpec::Matchers::DSL::Matcher)) match do |expected| - @detected = described_class.detect(url, **specs) + @detected = Version.detect(url, **specs) @detected == expected end From d60d11afde3102e7ab612d75a4bc15cc4f750974 Mon Sep 17 00:00:00 2001 From: Issy Long Date: Wed, 19 Aug 2026 14:14:30 +0100 Subject: [PATCH 06/19] sorbet: Bump more RuboCop tests to higher typed levels - Define the expected Apache URL inside the shared example so both Sorbet and RSpec resolve it in example scope. --- Library/Homebrew/test/rubocops/urls_spec.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Library/Homebrew/test/rubocops/urls_spec.rb b/Library/Homebrew/test/rubocops/urls_spec.rb index c76bcc7130..4dfe55418d 100644 --- a/Library/Homebrew/test/rubocops/urls_spec.rb +++ b/Library/Homebrew/test/rubocops/urls_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "rubocops/urls" @@ -322,10 +322,9 @@ RSpec.describe RuboCop::Cop::FormulaAudit::Urls do end context "when auditing Apache URLs" do - let(:expected_url) { "https://www.apache.org/dyn/closer.lua?path=apr/apr-1.7.6.tar.bz2" } - shared_examples "offense" do |url| it "registers an offense and corrects" do + expected_url = "https://www.apache.org/dyn/closer.lua?path=apr/apr-1.7.6.tar.bz2" message = "FormulaAudit/Urls: #{url} should be: #{expected_url}" expect_offense(<<~RUBY, url:, message:) From 99e969ea34666f3a85ca47b8b44e9b58cbb0792f Mon Sep 17 00:00:00 2001 From: Issy Long Date: Wed, 19 Aug 2026 16:16:07 +0100 Subject: [PATCH 07/19] sorbet: Bump more utility tests to higher typed levels - Read the archive under test from the example group's `subject` in the unpack-strategy shared examples, so Sorbet can resolve it, and name the per-strategy `subject(:path)` in each including spec. - Assert the bottle runtime dependencies with a single collection matcher, so no type narrowing is needed. --- Library/Homebrew/test/unpack_strategy/bazaar_spec.rb | 3 ++- Library/Homebrew/test/unpack_strategy/bzip2_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/cvs_spec.rb | 3 ++- Library/Homebrew/test/unpack_strategy/dmg_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/git_spec.rb | 3 ++- Library/Homebrew/test/unpack_strategy/gzip_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/jar_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/lha_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/lzip_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/mercurial_spec.rb | 3 ++- Library/Homebrew/test/unpack_strategy/p7zip_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/rar_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/shared_examples.rb | 6 +++--- Library/Homebrew/test/unpack_strategy/subversion_spec.rb | 3 ++- Library/Homebrew/test/unpack_strategy/tar_spec.rb | 4 ++-- Library/Homebrew/test/unpack_strategy/uncompressed_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/xar_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/xz_spec.rb | 2 +- Library/Homebrew/test/unpack_strategy/zip_spec.rb | 4 ++-- Library/Homebrew/test/utils/bottles/bottles_spec.rb | 6 ++---- 20 files changed, 30 insertions(+), 27 deletions(-) diff --git a/Library/Homebrew/test/unpack_strategy/bazaar_spec.rb b/Library/Homebrew/test/unpack_strategy/bazaar_spec.rb index c9cfa72df0..1388c7e78c 100644 --- a/Library/Homebrew/test/unpack_strategy/bazaar_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/bazaar_spec.rb @@ -4,13 +4,14 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Bazaar do + subject(:path) { repo } + let(:repo) do mktmpdir.tap do |repo| FileUtils.touch repo/"test" (repo/".bzr").mkpath end end - let(:path) { repo } include_examples "UnpackStrategy::detect" include_examples "#extract", children: ["test"] diff --git a/Library/Homebrew/test/unpack_strategy/bzip2_spec.rb b/Library/Homebrew/test/unpack_strategy/bzip2_spec.rb index 4d485f3214..ab86ff9956 100644 --- a/Library/Homebrew/test/unpack_strategy/bzip2_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/bzip2_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Bzip2 do - let(:path) { TEST_FIXTURE_DIR/"cask/container.bz2" } + subject(:path) { TEST_FIXTURE_DIR/"cask/container.bz2" } include_examples "UnpackStrategy::detect" include_examples "#extract", children: ["container"] diff --git a/Library/Homebrew/test/unpack_strategy/cvs_spec.rb b/Library/Homebrew/test/unpack_strategy/cvs_spec.rb index c27c6302c8..5b590f3ef7 100644 --- a/Library/Homebrew/test/unpack_strategy/cvs_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/cvs_spec.rb @@ -4,13 +4,14 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Cvs do + subject(:path) { repo } + let(:repo) do mktmpdir.tap do |repo| FileUtils.touch repo/"test" (repo/"CVS").mkpath end end - let(:path) { repo } include_examples "UnpackStrategy::detect" include_examples "#extract", children: ["CVS", "test"] diff --git a/Library/Homebrew/test/unpack_strategy/dmg_spec.rb b/Library/Homebrew/test/unpack_strategy/dmg_spec.rb index ab9b31f57f..27af53426c 100644 --- a/Library/Homebrew/test/unpack_strategy/dmg_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/dmg_spec.rb @@ -5,7 +5,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Dmg, :needs_macos do describe "#mount" do - let(:path) { TEST_FIXTURE_DIR/"cask/container.dmg" } + subject(:path) { TEST_FIXTURE_DIR/"cask/container.dmg" } include_examples "UnpackStrategy::detect" diff --git a/Library/Homebrew/test/unpack_strategy/git_spec.rb b/Library/Homebrew/test/unpack_strategy/git_spec.rb index 5e0484ae4e..16301e897e 100644 --- a/Library/Homebrew/test/unpack_strategy/git_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/git_spec.rb @@ -4,6 +4,8 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Git do + subject(:path) { repo } + let(:repo) do mktmpdir.tap do |repo| system "git", "-C", repo, "init" @@ -13,7 +15,6 @@ RSpec.describe UnpackStrategy::Git do system "git", "-C", repo, "commit", "-m", "Add `test` file." end end - let(:path) { repo } include_examples "UnpackStrategy::detect" include_examples "#extract", children: [".git", "test"] diff --git a/Library/Homebrew/test/unpack_strategy/gzip_spec.rb b/Library/Homebrew/test/unpack_strategy/gzip_spec.rb index 31f295fda3..ef2f724513 100644 --- a/Library/Homebrew/test/unpack_strategy/gzip_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/gzip_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Gzip do - let(:path) { TEST_FIXTURE_DIR/"cask/container.gz" } + subject(:path) { TEST_FIXTURE_DIR/"cask/container.gz" } include_examples "UnpackStrategy::detect" include_examples "#extract", children: ["container"], verbose: true diff --git a/Library/Homebrew/test/unpack_strategy/jar_spec.rb b/Library/Homebrew/test/unpack_strategy/jar_spec.rb index c81033e944..afeec7d989 100644 --- a/Library/Homebrew/test/unpack_strategy/jar_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/jar_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Jar, :needs_unzip do - let(:path) { TEST_FIXTURE_DIR/"test.jar" } + subject(:path) { TEST_FIXTURE_DIR/"test.jar" } include_examples "UnpackStrategy::detect" include_examples "#extract", children: ["test.jar"] diff --git a/Library/Homebrew/test/unpack_strategy/lha_spec.rb b/Library/Homebrew/test/unpack_strategy/lha_spec.rb index f7d2e20c90..026c7b6266 100644 --- a/Library/Homebrew/test/unpack_strategy/lha_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/lha_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Lha do - let(:path) { TEST_FIXTURE_DIR/"test.lha" } + subject(:path) { TEST_FIXTURE_DIR/"test.lha" } include_examples "UnpackStrategy::detect" end diff --git a/Library/Homebrew/test/unpack_strategy/lzip_spec.rb b/Library/Homebrew/test/unpack_strategy/lzip_spec.rb index 59350d6016..65bfdc3f33 100644 --- a/Library/Homebrew/test/unpack_strategy/lzip_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/lzip_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Lzip do - let(:path) { TEST_FIXTURE_DIR/"test.lz" } + subject(:path) { TEST_FIXTURE_DIR/"test.lz" } include_examples "UnpackStrategy::detect" end diff --git a/Library/Homebrew/test/unpack_strategy/mercurial_spec.rb b/Library/Homebrew/test/unpack_strategy/mercurial_spec.rb index 14b57e41bb..a82d463c03 100644 --- a/Library/Homebrew/test/unpack_strategy/mercurial_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/mercurial_spec.rb @@ -4,12 +4,13 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Mercurial do + subject(:path) { repo } + let(:repo) do mktmpdir.tap do |repo| (repo/".hg").mkpath end end - let(:path) { repo } include_examples "UnpackStrategy::detect" end diff --git a/Library/Homebrew/test/unpack_strategy/p7zip_spec.rb b/Library/Homebrew/test/unpack_strategy/p7zip_spec.rb index 6c156b6f46..6bf4b9e32d 100644 --- a/Library/Homebrew/test/unpack_strategy/p7zip_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/p7zip_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::P7Zip do - let(:path) { TEST_FIXTURE_DIR/"cask/container.7z" } + subject(:path) { TEST_FIXTURE_DIR/"cask/container.7z" } include_examples "UnpackStrategy::detect" end diff --git a/Library/Homebrew/test/unpack_strategy/rar_spec.rb b/Library/Homebrew/test/unpack_strategy/rar_spec.rb index 636d9d3eed..89a8c9e504 100644 --- a/Library/Homebrew/test/unpack_strategy/rar_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/rar_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Rar do - let(:path) { TEST_FIXTURE_DIR/"cask/container.rar" } + subject(:path) { TEST_FIXTURE_DIR/"cask/container.rar" } include_examples "UnpackStrategy::detect" end diff --git a/Library/Homebrew/test/unpack_strategy/shared_examples.rb b/Library/Homebrew/test/unpack_strategy/shared_examples.rb index 71f78062e8..f4c1c86008 100644 --- a/Library/Homebrew/test/unpack_strategy/shared_examples.rb +++ b/Library/Homebrew/test/unpack_strategy/shared_examples.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "mktemp" @@ -6,7 +6,7 @@ require "unpack_strategy" RSpec.shared_examples "UnpackStrategy::detect" do it "is correctly detected" do - expect(UnpackStrategy.detect(path)).to be_a described_class + expect(UnpackStrategy.detect(subject)).to be_a described_class end end @@ -14,7 +14,7 @@ RSpec.shared_examples "#extract" do |children: [], verbose: false| specify "#extract" do Mktemp.new("homebrew-test-unpack").run(chdir: false) do |mktemp| unpack_dir = T.must(mktemp.tmpdir) - described_class.new(path).extract(to: unpack_dir, verbose:) + described_class.new(subject).extract(to: unpack_dir, verbose:) expect(unpack_dir.children(false).map(&:to_s)).to match_array children end end diff --git a/Library/Homebrew/test/unpack_strategy/subversion_spec.rb b/Library/Homebrew/test/unpack_strategy/subversion_spec.rb index c7826e68a8..7afa8e1f3a 100644 --- a/Library/Homebrew/test/unpack_strategy/subversion_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/subversion_spec.rb @@ -4,9 +4,10 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Subversion, :needs_svnadmin do + subject(:path) { working_copy } + let(:repo) { mktmpdir } let(:working_copy) { mktmpdir } - let(:path) { working_copy } before do safe_system "svnadmin", "create", repo diff --git a/Library/Homebrew/test/unpack_strategy/tar_spec.rb b/Library/Homebrew/test/unpack_strategy/tar_spec.rb index d125a52f06..2d7c1ad38d 100644 --- a/Library/Homebrew/test/unpack_strategy/tar_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/tar_spec.rb @@ -4,13 +4,13 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Tar do - let(:path) { TEST_FIXTURE_DIR/"cask/container.tar.gz" } + subject(:path) { TEST_FIXTURE_DIR/"cask/container.tar.gz" } include_examples "UnpackStrategy::detect" include_examples "#extract", children: ["container"] context "when TAR archive is corrupted" do - let(:path) do + subject(:path) do (mktmpdir/"test.tar").tap do |path| FileUtils.touch path end diff --git a/Library/Homebrew/test/unpack_strategy/uncompressed_spec.rb b/Library/Homebrew/test/unpack_strategy/uncompressed_spec.rb index 5298651024..0dfd19224a 100644 --- a/Library/Homebrew/test/unpack_strategy/uncompressed_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/uncompressed_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Uncompressed do - let(:path) do + subject(:path) do (mktmpdir/"test").tap do |path| FileUtils.touch path end diff --git a/Library/Homebrew/test/unpack_strategy/xar_spec.rb b/Library/Homebrew/test/unpack_strategy/xar_spec.rb index 6802be9fa2..33bdd67914 100644 --- a/Library/Homebrew/test/unpack_strategy/xar_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/xar_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Xar, :needs_macos do - let(:path) { TEST_FIXTURE_DIR/"cask/container.xar" } + subject(:path) { TEST_FIXTURE_DIR/"cask/container.xar" } include_examples "UnpackStrategy::detect" include_examples "#extract", children: ["container"] diff --git a/Library/Homebrew/test/unpack_strategy/xz_spec.rb b/Library/Homebrew/test/unpack_strategy/xz_spec.rb index 31b61fb4e7..81a1401775 100644 --- a/Library/Homebrew/test/unpack_strategy/xz_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/xz_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Xz do - let(:path) { TEST_FIXTURE_DIR/"cask/container.xz" } + subject(:path) { TEST_FIXTURE_DIR/"cask/container.xz" } include_examples "UnpackStrategy::detect" end diff --git a/Library/Homebrew/test/unpack_strategy/zip_spec.rb b/Library/Homebrew/test/unpack_strategy/zip_spec.rb index afe2619a96..9e8d3536ac 100644 --- a/Library/Homebrew/test/unpack_strategy/zip_spec.rb +++ b/Library/Homebrew/test/unpack_strategy/zip_spec.rb @@ -4,7 +4,7 @@ require_relative "shared_examples" RSpec.describe UnpackStrategy::Zip do - let(:path) { TEST_FIXTURE_DIR/"cask/MyFancyApp.zip" } + subject(:path) { TEST_FIXTURE_DIR/"cask/MyFancyApp.zip" } include_examples "UnpackStrategy::detect" @@ -13,7 +13,7 @@ RSpec.describe UnpackStrategy::Zip do end context "when ZIP archive is corrupted" do - let(:path) do + subject(:path) do (mktmpdir/"test.zip").tap do |path| FileUtils.touch path end diff --git a/Library/Homebrew/test/utils/bottles/bottles_spec.rb b/Library/Homebrew/test/utils/bottles/bottles_spec.rb index 86ac1bd6f1..5518db4d91 100644 --- a/Library/Homebrew/test/utils/bottles/bottles_spec.rb +++ b/Library/Homebrew/test/utils/bottles/bottles_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "utils/bottles" @@ -53,9 +53,7 @@ RSpec.describe Utils::Bottles do runtime_dependencies = described_class.load_tab(formula).runtime_dependencies - expect(runtime_dependencies).not_to be_nil - expect(runtime_dependencies.size).to eq(1) - expect(runtime_dependencies.first).to include("full_name" => "testball1") + expect(runtime_dependencies).to contain_exactly(a_hash_including("full_name" => "testball1")) end end end From ea07d7755d7201849ae29d35bfa9800de7561ed7 Mon Sep 17 00:00:00 2001 From: dmdv Date: Thu, 20 Aug 2026 13:15:30 +0700 Subject: [PATCH 08/19] cache_store: make CacheStoreDatabase.use thread-safe Utils.parallel_map in Cleanup.install_clean! calls Keg#uninstall concurrently. CacheStoreDatabase.use shared a class-level refcount and db handle without a lock, so one thread could delete(:db) while another called write_if_dirty!, raising: undefined method 'write_if_dirty!' for nil `break` from the use block (used when the linkage cache file is absent) also skipped the decrement and leaked the refcount. Protect the refcount with a Mutex, always release it in ensure, and write the store outside the lock when the last user drops. --- Library/Homebrew/cache_store.rb | 46 ++++++++++++++--------- Library/Homebrew/test/cache_store_spec.rb | 29 ++++++++++++++ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/Library/Homebrew/cache_store.rb b/Library/Homebrew/cache_store.rb index 6531292951..ba6cdb1ae4 100644 --- a/Library/Homebrew/cache_store.rb +++ b/Library/Homebrew/cache_store.rb @@ -13,6 +13,9 @@ class CacheStoreDatabase Key = type_member Value = type_member + # Shared by `Utils.parallel_map` during install cleanup. + @mutex = T.let(Thread::Mutex.new, Thread::Mutex) + # Yields the cache store database. # Closes the database after use if it has been loaded. sig { @@ -24,28 +27,37 @@ class CacheStoreDatabase .returns(T.type_parameter(:U)) } def self.use(type, &_blk) - @db_type_reference_hash ||= T.let({}, T.nilable(T::Hash[T.untyped, T.untyped])) - @db_type_reference_hash[type] ||= {} - type_ref = @db_type_reference_hash[type] + type_ref = T.let(nil, T.untyped) + db = T.let(nil, T.nilable(CacheStoreDatabase[T.anything, T.anything])) - type_ref[:count] ||= 0 - type_ref[:count] += 1 + @mutex.synchronize do + @db_type_reference_hash ||= T.let({}, T.nilable(T::Hash[T.untyped, T.untyped])) + @db_type_reference_hash[type] ||= {} + type_ref = @db_type_reference_hash[type] - type_ref[:db] ||= CacheStoreDatabase.new(type) - - return_value = yield(type_ref[:db]) - if type_ref[:count].positive? - type_ref[:count] -= 1 - else - type_ref[:count] = 0 + type_ref[:count] ||= 0 + type_ref[:count] += 1 + type_ref[:db] ||= CacheStoreDatabase.new(type) + db = type_ref[:db] end - if type_ref[:count].zero? - type_ref[:db].write_if_dirty! - type_ref.delete(:db) - end + begin + raise ArgumentError, "CacheStoreDatabase.use failed to allocate #{type} store" if db.nil? - return_value + yield(db) + ensure + # `break` from the block used to skip the decrement and leak the refcount. + to_write = T.let(nil, T.nilable(CacheStoreDatabase[T.anything, T.anything])) + @mutex.synchronize do + if type_ref[:count].positive? + type_ref[:count] -= 1 + else + type_ref[:count] = 0 + end + to_write = type_ref.delete(:db) if type_ref[:count].zero? + end + to_write&.write_if_dirty! + end end # Creates a CacheStoreDatabase. diff --git a/Library/Homebrew/test/cache_store_spec.rb b/Library/Homebrew/test/cache_store_spec.rb index b86340363d..7d412f9f59 100644 --- a/Library/Homebrew/test/cache_store_spec.rb +++ b/Library/Homebrew/test/cache_store_spec.rb @@ -17,6 +17,35 @@ RSpec.describe CacheStoreDatabase do # do nothing end end + + it "releases the refcount when the block breaks" do + cache_store = instance_double(described_class, "cache_store", write_if_dirty!: nil) + allow(described_class).to receive(:new).with(type).and_return(cache_store) + expect(cache_store).to receive(:write_if_dirty!).twice + + described_class.use(type) do |_db| + break + end + described_class.use(type) do |_db| + # do nothing + end + end + + it "does not raise when used concurrently, including `break`" do + threads = Array.new(8) do |i| + Thread.new do + 25.times do |n| + described_class.use(:concurrent_test) do |db| + break if n.odd? + + db.delete("missing-#{i}-#{n}") + end + end + end + end + + expect { threads.each(&:join) }.not_to raise_error + end end describe "#set" do From 94a83812a15de20daad0d5f0c2ccb17e55ff323a Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 20 Aug 2026 09:05:34 +0100 Subject: [PATCH 09/19] Stabilise Bootsnap cache keys - Hash only the core Bundler dependency closure so optional gem groups preserve compiled files. - Invalidate gem resolution separately when installed gems change. - Guard the core list in `vendor-gems` and cover stale cache cleanup. --- Library/Homebrew/dev-cmd/vendor-gems.rb | 15 ++++ Library/Homebrew/startup/bootsnap.rb | 44 ++++++++++- Library/Homebrew/startup/bootsnap.rbi | 9 +++ Library/Homebrew/test/cleanup_spec.rb | 15 ++++ .../test/dev-cmd/install-bundler-gems_spec.rb | 14 ++++ .../Homebrew/test/dev-cmd/vendor-gems_spec.rb | 25 ++++++ .../Homebrew/test/startup/bootsnap_spec.rb | 76 +++++++++++++++++++ Library/Homebrew/utils/gem_setup.rb | 3 +- 8 files changed, 198 insertions(+), 3 deletions(-) diff --git a/Library/Homebrew/dev-cmd/vendor-gems.rb b/Library/Homebrew/dev-cmd/vendor-gems.rb index d47a8733e9..e6c1624ade 100644 --- a/Library/Homebrew/dev-cmd/vendor-gems.rb +++ b/Library/Homebrew/dev-cmd/vendor-gems.rb @@ -45,6 +45,21 @@ module Homebrew ohai "bundle install --standalone" run_bundle "install", "--standalone" + require "bundler" + definition = Bundler::Definition.build(Bundler.default_gemfile, Bundler.default_lockfile, false) + # Bundler ships with Ruby, outside the directory hashed by Bootsnap. + core_gem_names = definition.specs_for([:default]) + .filter_map { |spec| spec.name if spec.name != "bundler" } + .sort + bootsnap_gem_names = Homebrew::Bootsnap.core_gem_names.sort + if core_gem_names != bootsnap_gem_names + raise <<~EOS + Bootsnap core gem list is out of date. + Expected: #{core_gem_names.join(", ")} + Actual: #{bootsnap_gem_names.join(", ")} + EOS + end + if GitHub::Actions.env_set? && HOMEBREW_PREFIX.to_s == HOMEBREW_LINUX_DEFAULT_PREFIX ohai "chmod +t -R /home/linuxbrew/" system "sudo", "chmod", "+t", "-R", "/home/linuxbrew/" diff --git a/Library/Homebrew/startup/bootsnap.rb b/Library/Homebrew/startup/bootsnap.rb index 7438f61c03..a141408c3e 100644 --- a/Library/Homebrew/startup/bootsnap.rb +++ b/Library/Homebrew/startup/bootsnap.rb @@ -3,6 +3,26 @@ module Homebrew module Bootsnap + # This is the default Bundler group and its transitive dependencies. Optional + # groups must not rotate the compile cache used by the core load graph. + CORE_GEM_NAMES = %w[ + bindata + concurrent-ruby + elftools + logger + patchelf + plist + ruby-macho + sorbet-runtime + ].freeze + private_constant :CORE_GEM_NAMES + + def self.core_gem_names = CORE_GEM_NAMES + + private_class_method def self.gem_directories + Dir.children(File.join(Gem.paths.path, "gems")).sort + end + def self.key @key ||= begin require "digest/sha2" @@ -10,7 +30,9 @@ module Homebrew checksum = Digest::SHA256.new checksum << RUBY_VERSION checksum << RUBY_PLATFORM - checksum << Dir.children(File.join(Gem.paths.path, "gems")).join(",") + checksum << gem_directories + .select { |gem| core_gem_names.any? { |name| gem.start_with?("#{name}-") } } + .join(",") checksum.hexdigest end @@ -23,6 +45,10 @@ module Homebrew File.join(cache, "bootsnap", key) end + private_class_method def self.load_path_cache + File.join(cache_dir, "bootsnap/load-path-cache") + end + private_class_method def self.ignore_directories # We never do `require "vendor/bundle/ruby/..."` or `require "vendor/portable-ruby/..."`, # so let's slim the cache a bit by excluding them. @@ -46,6 +72,17 @@ module Homebrew return end + installed_gem_directories = gem_directories.join(",") + gem_directories_cache = File.join(cache_dir, "bootsnap/gem-directories") + if !File.exist?(gem_directories_cache) || File.read(gem_directories_cache) != installed_gem_directories + # The compile cache is shared across optional groups, but Bootsnap treats + # gem load paths as immutable, so invalidate their separate index. + require "fileutils" + FileUtils.rm_f load_path_cache + FileUtils.mkdir_p File.dirname(gem_directories_cache) + File.write(gem_directories_cache, installed_gem_directories) + end + ::Bootsnap.setup( cache_dir:, ignore_directories:, @@ -64,9 +101,12 @@ module Homebrew return unless enabled? ::Bootsnap.unload_cache! + # Gem changes invalidate resolution, but compiled files validate themselves + # against their source contents, so only discard the persisted load path. + require "fileutils" + FileUtils.rm_f load_path_cache @key = nil - # The compile cache doesn't get unloaded so we don't need to load it again! load!(compile_cache: false) end diff --git a/Library/Homebrew/startup/bootsnap.rbi b/Library/Homebrew/startup/bootsnap.rbi index d71df2d278..003728ff5f 100644 --- a/Library/Homebrew/startup/bootsnap.rbi +++ b/Library/Homebrew/startup/bootsnap.rbi @@ -2,12 +2,21 @@ module Homebrew module Bootsnap + sig { returns(T::Array[String]) } + def self.core_gem_names; end + sig { returns(String) } def self.key; end + sig { returns(T::Array[String]) } + private_class_method def self.gem_directories; end + sig { returns(String) } private_class_method def self.cache_dir; end + sig { returns(String) } + private_class_method def self.load_path_cache; end + sig { returns(T::Array[String]) } private_class_method def self.ignore_directories; end diff --git a/Library/Homebrew/test/cleanup_spec.rb b/Library/Homebrew/test/cleanup_spec.rb index 4814820342..1ec756fa72 100644 --- a/Library/Homebrew/test/cleanup_spec.rb +++ b/Library/Homebrew/test/cleanup_spec.rb @@ -491,6 +491,21 @@ RSpec.describe Homebrew::Cleanup do end end + describe "::cleanup_bootsnap" do + it "removes stale keys and keeps the current key" do + cache = mktmpdir + current = cache/"bootsnap/current" + stale = cache/"bootsnap/stale" + current.mkpath + stale.mkpath + allow(Homebrew::Bootsnap).to receive(:key).and_return("current") + + described_class.new(cache:).cleanup_bootsnap + + expect([current.exist?, stale.exist?]).to eq([true, false]) + end + end + describe "::cleanup_cache" do it "removes legacy cask downloads during full cache cleanup", :cask do cask = Cask::CaskLoader.load("local-transmission") diff --git a/Library/Homebrew/test/dev-cmd/install-bundler-gems_spec.rb b/Library/Homebrew/test/dev-cmd/install-bundler-gems_spec.rb index 56b99a3af6..68cc17a04d 100644 --- a/Library/Homebrew/test/dev-cmd/install-bundler-gems_spec.rb +++ b/Library/Homebrew/test/dev-cmd/install-bundler-gems_spec.rb @@ -6,4 +6,18 @@ require "dev-cmd/install-bundler-gems" RSpec.describe Homebrew::DevCmd::InstallBundlerGems do it_behaves_like "parseable arguments" + + it "resets Bootsnap after cleaning gems" do + bundle = "/tmp/bundle" + allow(Homebrew).to receive(:setup_gem_environment!) + allow(Homebrew).to receive_messages(valid_gem_groups: [], user_gem_groups: [], user_vendor_version: 9) + allow(Homebrew).to receive(:find_in_path).with("bundle").and_return("/tmp") + allow(Homebrew).to receive(:`).with("#{bundle} check 2>&1").and_return("") + allow(Homebrew).to receive(:system).with(bundle, "clean", out: :err).and_return(true) + allow(Homebrew).to receive(:write_user_gem_groups) + expect(Homebrew::Bootsnap).to receive(:reset!) + + Kernel.system("true") + with_env(HOMEBREW_TESTS: nil) { Homebrew.install_bundler_gems! } + end end diff --git a/Library/Homebrew/test/dev-cmd/vendor-gems_spec.rb b/Library/Homebrew/test/dev-cmd/vendor-gems_spec.rb index dcefa9c006..04f7b41f0f 100644 --- a/Library/Homebrew/test/dev-cmd/vendor-gems_spec.rb +++ b/Library/Homebrew/test/dev-cmd/vendor-gems_spec.rb @@ -6,4 +6,29 @@ require "dev-cmd/vendor-gems" RSpec.describe Homebrew::DevCmd::VendorGems do it_behaves_like "parseable arguments" + + sig { returns(Homebrew::DevCmd::VendorGems) } + def vendor_gems_command + described_class.new(["--no-commit"]).tap do |command| + allow(command).to receive(:run_bundle) + allow(command).to receive(:ohai) + allow(Homebrew).to receive(:setup_gem_environment!) + allow(Homebrew).to receive(:valid_gem_groups).and_return([]) + end + end + + it "rejects a stale Bootsnap core gem list" do + command = vendor_gems_command + allow(Homebrew::Bootsnap).to receive(:core_gem_names).and_return([]) + + expect { command.run }.to raise_error(RuntimeError, /Bootsnap core gem list is out of date/) + end + + it "accepts reordered Bootsnap core gems" do + command = vendor_gems_command + core_gem_names = Homebrew::Bootsnap.core_gem_names.reverse + allow(Homebrew::Bootsnap).to receive(:core_gem_names).and_return(core_gem_names) + + expect { command.run }.not_to raise_error + end end diff --git a/Library/Homebrew/test/startup/bootsnap_spec.rb b/Library/Homebrew/test/startup/bootsnap_spec.rb index 1f36ab36f9..87b5fa28fa 100644 --- a/Library/Homebrew/test/startup/bootsnap_spec.rb +++ b/Library/Homebrew/test/startup/bootsnap_spec.rb @@ -2,12 +2,88 @@ # frozen_string_literal: true RSpec.describe Homebrew::Bootsnap do + sig { params(gem_home: Pathname).returns(String) } + def bootsnap_key(gem_home) + stdout, stderr, status = Open3.capture3( + { "HOMEBREW_NO_BOOTSNAP" => "1" }, + *HOMEBREW_RUBY_EXEC_ARGS, + "-rrubygems", "-e", <<~RUBY, gem_home, HOMEBREW_LIBRARY_PATH/"startup/bootsnap.rb" + Gem.paths = { "GEM_HOME" => ARGV.fetch(0), "GEM_PATH" => ARGV.fetch(0) } + require ARGV.fetch(1) + puts Homebrew::Bootsnap.key + RUBY + ) + raise stderr unless status.success? + + stdout.chomp + end + + describe "::key" do + it "ignores optional gems and changes when a core gem changes" do + gem_home = mktmpdir + gems = gem_home/"gems" + (gems/"sorbet-runtime-1.0").mkpath + original_key = bootsnap_key(gem_home) + + (gems/"rspec-1.0").mkpath + optional_gem_key = bootsnap_key(gem_home) + + (gems/"sorbet-runtime-1.0").rmdir + (gems/"sorbet-runtime-2.0").mkpath + core_gem_key = bootsnap_key(gem_home) + + expect([optional_gem_key == original_key, core_gem_key == original_key]).to eq([true, false]) + end + end + describe "::load!" do it "does not error when the configured gem path is unavailable" do with_env(HOMEBREW_BOOTSNAP_GEM_PATH: "#{TEST_TMPDIR}/missing-bootsnap", HOMEBREW_NO_BOOTSNAP: nil) do expect { described_class.load! }.not_to raise_error end end + + it "invalidates stale gem load paths without removing compiled files" do + cache = mktmpdir + load_path_cache = cache/"bootsnap/load-path-cache" + gem_directories = cache/"bootsnap/gem-directories" + compile_cache = cache/"bootsnap/compile-cache-iseq/cache" + load_path_cache.dirname.mkpath + load_path_cache.write("stale") + gem_directories.write("old-gem-1.0") + compile_cache.dirname.mkpath + compile_cache.write("") + allow(described_class).to receive_messages(enabled?: true, cache_dir: cache.to_s) + allow(Dir).to receive(:children).with(File.join(Gem.paths.path, "gems")).and_return(["new-gem-1.0"]) + allow(Bootsnap).to receive(:setup) + + described_class.load! + stale_cache_removed = !load_path_cache.exist? + load_path_cache.write("current") + described_class.load! + + expect([stale_cache_removed, load_path_cache.read, gem_directories.read, compile_cache.exist?]) + .to eq([true, "current", "new-gem-1.0", true]) + end + end + + describe "::reset!" do + it "removes the load path cache and keeps the compile cache" do + cache = mktmpdir + load_path_cache = cache/"bootsnap/load-path-cache" + compile_cache = cache/"bootsnap/compile-cache-iseq/cache" + load_path_cache.dirname.mkpath + load_path_cache.write("") + compile_cache.dirname.mkpath + compile_cache.write("") + allow(described_class).to receive_messages(enabled?: true, cache_dir: cache.to_s) + allow(Bootsnap).to receive(:unload_cache!) + allow(described_class).to receive(:load!) + + described_class.reset! + + expect([load_path_cache.exist?, compile_cache.exist?]).to eq([false, true]) + end end describe "::prewarm!" do diff --git a/Library/Homebrew/utils/gem_setup.rb b/Library/Homebrew/utils/gem_setup.rb index e6c193cfd0..0266e58d9e 100644 --- a/Library/Homebrew/utils/gem_setup.rb +++ b/Library/Homebrew/utils/gem_setup.rb @@ -274,7 +274,6 @@ module Homebrew exec bundle, "install", out: :err end) if $CHILD_STATUS.success? - Homebrew::Bootsnap.reset! if defined?(Homebrew::Bootsnap) # Gem install can run before Bootsnap loads true else message = <<~EOS @@ -302,6 +301,8 @@ module Homebrew end if bundle_installed + # `bundle clean` can remove load paths just as `bundle install` can add them. + Homebrew::Bootsnap.reset! if defined?(Homebrew::Bootsnap) # Gem setup can run before Bootsnap loads write_user_gem_groups(groups) @bundle_installed_groups = groups end From b0310e2579cb5c8a1c9e76f89e5f09d25c2e4f5e Mon Sep 17 00:00:00 2001 From: Sakutaro Shiina Date: Thu, 20 Aug 2026 17:17:11 +0900 Subject: [PATCH 10/19] Fix cask extraction integrity setting --- Library/Homebrew/cask/installer.rb | 2 ++ Library/Homebrew/test/cask/installer_spec.rb | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Library/Homebrew/cask/installer.rb b/Library/Homebrew/cask/installer.rb index 0e3bcb8d4f..f379963d56 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -308,6 +308,8 @@ on_request: true) sig { params(to: Pathname).void } def extract_primary_container(to: @cask.staged_path) + download(quiet: true) if @cask.download.nil? + downloader.extract_primary_container(to:, verbose: verbose?) end diff --git a/Library/Homebrew/test/cask/installer_spec.rb b/Library/Homebrew/test/cask/installer_spec.rb index 4fee9e5f50..6ad7424d14 100644 --- a/Library/Homebrew/test/cask/installer_spec.rb +++ b/Library/Homebrew/test/cask/installer_spec.rb @@ -10,6 +10,25 @@ RSpec.describe Cask::Installer, :cask do end end + describe "#extract_primary_container" do + it "respects the installer's download integrity setting" do + cask = Cask::CaskLoader.load(cask_path("local-caffeine")) + installer = described_class.new(cask, verify_download_integrity: false) + download = instance_double(Cask::Download) + downloaded_path = Pathname("/path/to/downloaded/cask") + + allow(installer).to receive(:downloader).and_return(download) + expect(download).to receive(:fetch) + .with(quiet: true, verify_download_integrity: false, timeout: nil) + .and_return(downloaded_path) + expect(download).to receive(:extract_primary_container).with(to: cask.staged_path, verbose: false) + + installer.extract_primary_container + + expect(cask.download).to eq(downloaded_path) + end + end + describe "#save_caskfile" do it "stores casks loaded from Ruby source as JSON metadata" do cask = Cask::CaskLoader.load(cask_path("local-caffeine")) From 61c9444011c4758f728ef0a941fac8a0d20bf34d Mon Sep 17 00:00:00 2001 From: Sakutaro Shiina Date: Thu, 20 Aug 2026 17:36:26 +0900 Subject: [PATCH 11/19] Load install helpers in cask installer specs --- Library/Homebrew/test/cask/installer_spec.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Library/Homebrew/test/cask/installer_spec.rb b/Library/Homebrew/test/cask/installer_spec.rb index 6ad7424d14..9307808840 100644 --- a/Library/Homebrew/test/cask/installer_spec.rb +++ b/Library/Homebrew/test/cask/installer_spec.rb @@ -1,6 +1,8 @@ # typed: false # frozen_string_literal: true +Warnings.ignore(/circular require considered harmful/) { require "install" } + RSpec.describe Cask::Installer, :cask do def stub_dmg_extraction allow(UnpackStrategy::Dmg).to receive(:can_extract?).and_return(true) From 9fe057afacc7e5b1025a887653b0044580e77486 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 20 Aug 2026 10:05:10 +0100 Subject: [PATCH 12/19] cask/installer: improve require handling. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Library/Homebrew/test/cask/installer_spec.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Library/Homebrew/test/cask/installer_spec.rb b/Library/Homebrew/test/cask/installer_spec.rb index 9307808840..5cbfb7cff0 100644 --- a/Library/Homebrew/test/cask/installer_spec.rb +++ b/Library/Homebrew/test/cask/installer_spec.rb @@ -1,8 +1,7 @@ # typed: false # frozen_string_literal: true -Warnings.ignore(/circular require considered harmful/) { require "install" } - +require "cask/installer" RSpec.describe Cask::Installer, :cask do def stub_dmg_extraction allow(UnpackStrategy::Dmg).to receive(:can_extract?).and_return(true) From e2cb4a9eb207a95251122bdc9716c4db3cc5fd62 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 20 Aug 2026 10:30:59 +0100 Subject: [PATCH 13/19] Serialise cache store final writes - Model each cache type's lifecycle state explicitly. - Prevent replacement databases overlapping final writes. - Preserve block return values explicitly. --- Library/Homebrew/cache_store.rb | 49 ++++++++++++----------- Library/Homebrew/test/cache_store_spec.rb | 38 +++++++++++++++++- 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/Library/Homebrew/cache_store.rb b/Library/Homebrew/cache_store.rb index ba6cdb1ae4..21a2933838 100644 --- a/Library/Homebrew/cache_store.rb +++ b/Library/Homebrew/cache_store.rb @@ -13,8 +13,17 @@ class CacheStoreDatabase Key = type_member Value = type_member - # Shared by `Utils.parallel_map` during install cleanup. - @mutex = T.let(Thread::Mutex.new, Thread::Mutex) + # Tracks the active users and shared database for one cache type. + class TypeReference < T::Struct + const :mutex, Thread::Mutex + prop :active_users, Integer, default: 0 + prop :database, T.nilable(CacheStoreDatabase[T.anything, T.anything]), default: nil + end + private_constant :TypeReference + + # Only reference creation is global; each cache type has its own lifecycle lock. + @type_references_mutex = T.let(Thread::Mutex.new, Thread::Mutex) + @type_references = T.let({}, T::Hash[Symbol, TypeReference]) # Yields the cache store database. # Closes the database after use if it has been loaded. @@ -27,36 +36,28 @@ class CacheStoreDatabase .returns(T.type_parameter(:U)) } def self.use(type, &_blk) - type_ref = T.let(nil, T.untyped) - db = T.let(nil, T.nilable(CacheStoreDatabase[T.anything, T.anything])) + type_ref = @type_references_mutex.synchronize do + @type_references[type] ||= TypeReference.new(mutex: Thread::Mutex.new) + end - @mutex.synchronize do - @db_type_reference_hash ||= T.let({}, T.nilable(T::Hash[T.untyped, T.untyped])) - @db_type_reference_hash[type] ||= {} - type_ref = @db_type_reference_hash[type] - - type_ref[:count] ||= 0 - type_ref[:count] += 1 - type_ref[:db] ||= CacheStoreDatabase.new(type) - db = type_ref[:db] + db = type_ref.mutex.synchronize do + type_ref.active_users += 1 + type_ref.database ||= CacheStoreDatabase.new(type) end begin - raise ArgumentError, "CacheStoreDatabase.use failed to allocate #{type} store" if db.nil? - - yield(db) + return_value = yield(db) + return_value ensure # `break` from the block used to skip the decrement and leak the refcount. - to_write = T.let(nil, T.nilable(CacheStoreDatabase[T.anything, T.anything])) - @mutex.synchronize do - if type_ref[:count].positive? - type_ref[:count] -= 1 - else - type_ref[:count] = 0 + type_ref.mutex.synchronize do + type_ref.active_users -= 1 + if type_ref.active_users.zero? + # Prevent a replacement database from opening until the final write completes. + type_ref.database&.write_if_dirty! + type_ref.database = nil end - to_write = type_ref.delete(:db) if type_ref[:count].zero? end - to_write&.write_if_dirty! end end diff --git a/Library/Homebrew/test/cache_store_spec.rb b/Library/Homebrew/test/cache_store_spec.rb index 7d412f9f59..ea56896ee2 100644 --- a/Library/Homebrew/test/cache_store_spec.rb +++ b/Library/Homebrew/test/cache_store_spec.rb @@ -31,6 +31,42 @@ RSpec.describe CacheStoreDatabase do end end + it "returns the block value" do + expect(described_class.use(type) { :return_value }).to eq(:return_value) + end + + it "finishes writing before opening a replacement database" do + events = Queue.new + write_started = Queue.new + finish_write = Queue.new + first_cache_store = instance_double(described_class, "first_cache_store") + second_cache_store = instance_double(described_class, "second_cache_store", write_if_dirty!: nil) + allow(first_cache_store).to receive(:write_if_dirty!) do + events << :write_started + write_started << true + finish_write.pop + events << :write_finished + end + allow(described_class).to receive(:new).with(type).and_return(first_cache_store, second_cache_store) + + first_use = Thread.new { described_class.use(type) { nil } } + write_started.pop + second_use = Thread.new do + events << :second_use_started + described_class.use(type) { events << :second_database_opened } + end + Thread.pass while second_use.status == "run" + finish_write << true + [first_use, second_use].each(&:value) + + expect(Array.new(4) { events.pop }).to eq([ + :write_started, + :second_use_started, + :write_finished, + :second_database_opened, + ]) + end + it "does not raise when used concurrently, including `break`" do threads = Array.new(8) do |i| Thread.new do @@ -44,7 +80,7 @@ RSpec.describe CacheStoreDatabase do end end - expect { threads.each(&:join) }.not_to raise_error + expect { threads.each(&:value) }.not_to raise_error end end From b6d4987ad92cbed1204700d782160bf78c1c34df Mon Sep 17 00:00:00 2001 From: BrewTestBot <1589480+BrewTestBot@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:51:26 +0000 Subject: [PATCH 14/19] Update maintainers. Autogenerated by the [sponsors-maintainers-man-completions](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/sponsors-maintainers-man-completions.yml) workflow. --- README.md | 2 +- docs/Manpage.md | 2 +- manpages/brew.1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0060815898..43bfcc0a7e 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Homebrew's [Project Leader](https://docs.brew.sh/Homebrew-Governance#project-lea Homebrew's [Lead Maintainers](https://docs.brew.sh/Homebrew-Governance#lead-maintainer) are [Bevan Kay](https://github.com/bevanjkay), [Carlo Cabrera](https://github.com/carlocab), [Issy Long](https://github.com/issyl0), [Justin Krehel](https://github.com/krehel), [Michael Cho](https://github.com/cho-m), [Mike McQuaid](https://github.com/MikeMcQuaid), [Nanda H Krishna](https://github.com/nandahkrishna), [Patrick Linnane](https://github.com/p-linnane), [Rui Chen](https://github.com/chenrui333), [Ruoyu Zhong](https://github.com/ZhongRuoyu), [Sam Ford](https://github.com/samford) and [Sean Molenaar](https://github.com/SMillerDev). -Homebrew's other Maintainers are [Andrew Nesbitt](https://github.com/andrew), [Anton Melnikov](https://github.com/botantony), [Bo Anderson](https://github.com/Bo98), [Branch Vincent](https://github.com/branchv), [Caleb Xu](https://github.com/alebcay), [Daeho Ro](https://github.com/daeho-ro), [Douglas Eichelberger](https://github.com/dduugg), [Dustin Rodrigues](https://github.com/dtrodrigues), [FX Coudert](https://github.com/fxcoudert), [Klaus Hipp](https://github.com/khipp), [Markus Reiter](https://github.com/reitermarkus), [Michka Popoff](https://github.com/iMichka), [Rylan Polster](https://github.com/Rylan12), [Stefan Baebler](https://github.com/stefanb), [Thierry Moisan](https://github.com/Moisan) and [William Woodruff](https://github.com/woodruffw). +Homebrew's other Maintainers are [Andrew Nesbitt](https://github.com/andrew), [Anton Melnikov](https://github.com/botantony), [Bo Anderson](https://github.com/Bo98), [Branch Vincent](https://github.com/branchv), [Caleb Xu](https://github.com/alebcay), [Daeho Ro](https://github.com/daeho-ro), [Douglas Eichelberger](https://github.com/dduugg), [Dustin Rodrigues](https://github.com/dtrodrigues), [FX Coudert](https://github.com/fxcoudert), [Klaus Hipp](https://github.com/khipp), [Markus Reiter](https://github.com/reitermarkus), [Michka Popoff](https://github.com/iMichka), [Rylan Polster](https://github.com/Rylan12), [Štefan Baebler](https://github.com/stefanb), [Thierry Moisan](https://github.com/Moisan) and [William Woodruff](https://github.com/woodruffw). Homebrew was originally created by [Max Howell](https://github.com/mxcl). diff --git a/docs/Manpage.md b/docs/Manpage.md index b967eac684..f4a9884f05 100644 --- a/docs/Manpage.md +++ b/docs/Manpage.md @@ -5148,7 +5148,7 @@ Ruoyu Zhong, Sam Ford and Sean Molenaar. Homebrew's other Maintainers are Andrew Nesbitt, Anton Melnikov, Bo Anderson, Branch Vincent, Caleb Xu, Daeho Ro, Douglas Eichelberger, Dustin Rodrigues, FX -Coudert, Klaus Hipp, Markus Reiter, Michka Popoff, Rylan Polster, Stefan +Coudert, Klaus Hipp, Markus Reiter, Michka Popoff, Rylan Polster, Štefan Baebler, Thierry Moisan and William Woodruff. ## BUGS diff --git a/manpages/brew.1 b/manpages/brew.1 index 6d42f142ae..792ad902e2 100644 --- a/manpages/brew.1 +++ b/manpages/brew.1 @@ -3352,7 +3352,7 @@ Homebrew\[u2019]s Project Leader is Mike McQuaid\. .P Homebrew\[u2019]s Lead Maintainers are Bevan Kay, Carlo Cabrera, Issy Long, Justin Krehel, Michael Cho, Mike McQuaid, Nanda H Krishna, Patrick Linnane, Rui Chen, Ruoyu Zhong, Sam Ford and Sean Molenaar\. .P -Homebrew\[u2019]s other Maintainers are Andrew Nesbitt, Anton Melnikov, Bo Anderson, Branch Vincent, Caleb Xu, Daeho Ro, Douglas Eichelberger, Dustin Rodrigues, FX Coudert, Klaus Hipp, Markus Reiter, Michka Popoff, Rylan Polster, Stefan Baebler, Thierry Moisan and William Woodruff\. +Homebrew\[u2019]s other Maintainers are Andrew Nesbitt, Anton Melnikov, Bo Anderson, Branch Vincent, Caleb Xu, Daeho Ro, Douglas Eichelberger, Dustin Rodrigues, FX Coudert, Klaus Hipp, Markus Reiter, Michka Popoff, Rylan Polster, Štefan Baebler, Thierry Moisan and William Woodruff\. .SH "BUGS" See our issues on GitHub: .TP From 2ac626cdaa9cad6cd6c72ab8a882188fd3307731 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 20 Aug 2026 11:46:39 +0100 Subject: [PATCH 15/19] Stabilise install-dependent tests - Avoid relying on shard order to load full install helpers. - Keep the corrupt bottle test on the installer public API. --- Library/Homebrew/test/cask/installer_spec.rb | 2 ++ Library/Homebrew/test/formula_installer_bottle_spec.rb | 2 +- Library/Homebrew/test/formula_installer_spec.rb | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Library/Homebrew/test/cask/installer_spec.rb b/Library/Homebrew/test/cask/installer_spec.rb index 5cbfb7cff0..e36edc94fd 100644 --- a/Library/Homebrew/test/cask/installer_spec.rb +++ b/Library/Homebrew/test/cask/installer_spec.rb @@ -2,6 +2,8 @@ # frozen_string_literal: true require "cask/installer" +Warnings.ignore(/circular require considered harmful/) { require "install" } + RSpec.describe Cask::Installer, :cask do def stub_dmg_extraction allow(UnpackStrategy::Dmg).to receive(:can_extract?).and_return(true) diff --git a/Library/Homebrew/test/formula_installer_bottle_spec.rb b/Library/Homebrew/test/formula_installer_bottle_spec.rb index e36ae3a4d7..43b846c9bc 100644 --- a/Library/Homebrew/test/formula_installer_bottle_spec.rb +++ b/Library/Homebrew/test/formula_installer_bottle_spec.rb @@ -118,7 +118,7 @@ RSpec.describe FormulaInstaller do formula_installer = described_class.new(formula) begin expect do - Homebrew::Install.fetch_formulae([formula_installer]) + formula_installer.fetch formula_installer.install end.to output(/Removing corrupt cached download/).to_stderr diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index 716ad380e8..726de4d3ad 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -3,6 +3,7 @@ require "formula" require "formula_installer" +Warnings.ignore(/circular require considered harmful/) { require "install" } require "keg" require "sandbox" require "tab" From f36365aa7c924efa42cffe526692e7051b1a1635 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 20 Aug 2026 15:29:07 +0100 Subject: [PATCH 16/19] Load cask loader for list versions - Restore cask version listings after cask loading became lazy. - Ignore dangling Caskroom aliases when listing versions. - Cover `brew list --versions` in a fresh process. --- Library/Homebrew/cmd/list.rb | 4 +++- Library/Homebrew/test/cmd/list_spec.rb | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Library/Homebrew/cmd/list.rb b/Library/Homebrew/cmd/list.rb index b167a92481..908d757c6d 100644 --- a/Library/Homebrew/cmd/list.rb +++ b/Library/Homebrew/cmd/list.rb @@ -315,7 +315,9 @@ module Homebrew sig { void } def list_casks casks = if args.no_named? - cask_paths = Cask::Caskroom.path.children.reject(&:file?).map do |path| + require "cask/cask_loader" + + cask_paths = Cask::Caskroom.path.children.select(&:directory?).map do |path| if path.symlink? real_path = path.realpath real_path.basename.to_s diff --git a/Library/Homebrew/test/cmd/list_spec.rb b/Library/Homebrew/test/cmd/list_spec.rb index 4f2425d1bf..1d515bd222 100644 --- a/Library/Homebrew/test/cmd/list_spec.rb +++ b/Library/Homebrew/test/cmd/list_spec.rb @@ -297,11 +297,17 @@ RSpec.describe Homebrew::Cmd::List do .to raise_error(UsageError, /`brew list --json` requires `--versions`\./) end - it "prints pinned formulae and casks", :cask, :integration_test do + it "prints formula and cask versions", :cask, :integration_test do setup_test_formula "testball", tab_attributes: { installed_on_request: true } Formula["testball"].pin cask = Cask::CaskLoader.load("local-caffeine") InstallHelper.stub_cask_installation(cask) + FileUtils.ln_s "missing-cask", Cask::Caskroom.path/"dangling-alias" + + expect { brew "list", "--versions" } + .to output("testball 0.1\nlocal-caffeine 1.2.3\n").to_stdout + .and be_a_success + cask.pin expect { brew "list", "--pinned", "--versions" } From dbd3b992223ba7e6da9924c150b4001e052c2beb Mon Sep 17 00:00:00 2001 From: Michael Cho Date: Thu, 20 Aug 2026 13:55:07 -0400 Subject: [PATCH 17/19] keg_relocate: skip running patchelf on bun binaries The `.bun` section needs to occur at a specific location. Upstream does support relocating interpreter (at least via NixOS/patchelf) but either patchelf.rb or RUNPATH relocation results in broken binary. --- Library/Homebrew/extend/os/linux/keg_relocate.rb | 13 +++++++------ Library/Homebrew/extend/os/mac/keg_relocate.rb | 4 ++-- Library/Homebrew/keg_relocate.rb | 6 +++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Library/Homebrew/extend/os/linux/keg_relocate.rb b/Library/Homebrew/extend/os/linux/keg_relocate.rb index 419cab80e1..70351887a7 100644 --- a/Library/Homebrew/extend/os/linux/keg_relocate.rb +++ b/Library/Homebrew/extend/os/linux/keg_relocate.rb @@ -10,8 +10,8 @@ module OS requires_ancestor { ::Keg } - sig { params(relocation: ::Keg::Relocation, skip_protodesc_cold: T::Boolean).void } - def relocate_dynamic_linkage(relocation, skip_protodesc_cold: false) + sig { params(relocation: ::Keg::Relocation, with_placeholders: T::Boolean).void } + def relocate_dynamic_linkage(relocation, with_placeholders: false) # Patching the dynamic linker of glibc breaks it. return if name.match? Version.formula_optionally_versioned_regex(:glibc) @@ -19,22 +19,23 @@ module OS elf_files.each do |file| file.ensure_writable do - change_rpath!(file, old_prefix, new_prefix, skip_protodesc_cold:) + change_rpath!(file, old_prefix, new_prefix, with_placeholders:) end end end sig { params(file: ELFShim, old_prefix: T.any(String, Regexp), new_prefix: String, - skip_protodesc_cold: T::Boolean).returns(T::Boolean) + with_placeholders: T::Boolean).returns(T::Boolean) } - def change_rpath!(file, old_prefix, new_prefix, skip_protodesc_cold: false) + def change_rpath!(file, old_prefix, new_prefix, with_placeholders: false) return false if !file.elf? || !file.dynamic_elf? # Skip relocation of files with `protodesc_cold` sections because patchelf.rb seems to break them, # but only when bottling (as we don't want to break existing bottles that require relocation). # https://github.com/Homebrew/homebrew-core/pull/232490#issuecomment-3161362452 - return false if skip_protodesc_cold && file.section_names.include?("protodesc_cold") + # Also skip relocation of files with `.bun` sections + return false if with_placeholders && file.section_names.intersect?(["protodesc_cold", ".bun"]) updated = {} old_rpath = file.rpath diff --git a/Library/Homebrew/extend/os/mac/keg_relocate.rb b/Library/Homebrew/extend/os/mac/keg_relocate.rb index 0a94af8ed2..dd367e93f1 100644 --- a/Library/Homebrew/extend/os/mac/keg_relocate.rb +++ b/Library/Homebrew/extend/os/mac/keg_relocate.rb @@ -23,8 +23,8 @@ module OS end end - sig { params(relocation: ::Keg::Relocation, skip_protodesc_cold: T::Boolean).void } - def relocate_dynamic_linkage(relocation, skip_protodesc_cold: false) + sig { params(relocation: ::Keg::Relocation, with_placeholders: T::Boolean).void } + def relocate_dynamic_linkage(relocation, with_placeholders: false) mach_o_files.each do |file| file.ensure_writable do modified = T.let(false, T::Boolean) diff --git a/Library/Homebrew/keg_relocate.rb b/Library/Homebrew/keg_relocate.rb index 260b5d98a0..018377fa47 100644 --- a/Library/Homebrew/keg_relocate.rb +++ b/Library/Homebrew/keg_relocate.rb @@ -85,8 +85,8 @@ class Keg end end - sig { params(_relocation: Relocation, skip_protodesc_cold: T::Boolean).void } - def relocate_dynamic_linkage(_relocation, skip_protodesc_cold: false); end + sig { params(_relocation: Relocation, with_placeholders: T::Boolean).void } + def relocate_dynamic_linkage(_relocation, with_placeholders: false); end JAVA_REGEX = %r{#{HOMEBREW_PREFIX}/opt/openjdk(@\d+(\.\d+)*)?/libexec(/openjdk\.jdk/Contents/Home)?} @@ -172,7 +172,7 @@ class Keg sig { returns(T::Array[Pathname]) } def replace_locations_with_placeholders relocation = prepare_relocation_to_placeholders.freeze - relocate_dynamic_linkage(relocation, skip_protodesc_cold: true) + relocate_dynamic_linkage(relocation, with_placeholders: true) replace_text_in_files(relocation) end From 2853c515cb76b5df0edbee4b1ba0417fc807de29 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 20 Aug 2026 20:28:23 +0100 Subject: [PATCH 18/19] Cover profiler cache key stability - Exercise the optional-gem cache key with `stackprof`. - Tie the regression directly to `brew prof` first-run behaviour. --- Library/Homebrew/test/startup/bootsnap_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/test/startup/bootsnap_spec.rb b/Library/Homebrew/test/startup/bootsnap_spec.rb index 87b5fa28fa..f5328c2b64 100644 --- a/Library/Homebrew/test/startup/bootsnap_spec.rb +++ b/Library/Homebrew/test/startup/bootsnap_spec.rb @@ -19,20 +19,20 @@ RSpec.describe Homebrew::Bootsnap do end describe "::key" do - it "ignores optional gems and changes when a core gem changes" do + it "ignores profiler gems and changes when a core gem changes" do gem_home = mktmpdir gems = gem_home/"gems" (gems/"sorbet-runtime-1.0").mkpath original_key = bootsnap_key(gem_home) - (gems/"rspec-1.0").mkpath - optional_gem_key = bootsnap_key(gem_home) + (gems/"stackprof-1.0").mkpath + profiler_gem_key = bootsnap_key(gem_home) (gems/"sorbet-runtime-1.0").rmdir (gems/"sorbet-runtime-2.0").mkpath core_gem_key = bootsnap_key(gem_home) - expect([optional_gem_key == original_key, core_gem_key == original_key]).to eq([true, false]) + expect([profiler_gem_key == original_key, core_gem_key == original_key]).to eq([true, false]) end end From 8fd0ca6f5573c3dde8a9714d6cbfa11ecb8831d7 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 20 Aug 2026 21:24:17 +0100 Subject: [PATCH 19/19] Skip warm GHCR manifest cache discovery - Avoid globbing when an API-resolved manifest cache path exists. - Retain generic discovery for mirrors and alternate filenames. --- .../curl_github_packages_download_strategy.rb | 8 +-- .../curl_github_packages_spec.rb | 50 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/download_strategy/curl_github_packages_download_strategy.rb b/Library/Homebrew/download_strategy/curl_github_packages_download_strategy.rb index 9812cbb540..4ccbd9c4f8 100644 --- a/Library/Homebrew/download_strategy/curl_github_packages_download_strategy.rb +++ b/Library/Homebrew/download_strategy/curl_github_packages_download_strategy.rb @@ -27,12 +27,14 @@ class CurlGitHubPackagesDownloadStrategy < CurlDownloadStrategy sig { override.returns(Pathname) } def cached_location - return super unless immutable_bottle_blob? - cached_location = @cached_location return cached_location unless cached_location.nil? + return super if @resolved_basename.blank? - @cached_location = HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--#{Utils.safe_filename(@resolved_basename.to_s)}" + cached_location = HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--#{Utils.safe_filename(@resolved_basename.to_s)}" + return super if !immutable_bottle_blob? && (!mirrors.empty? || !cached_location.exist?) + + @cached_location = cached_location end sig { returns(T::Boolean) } diff --git a/Library/Homebrew/test/download_strategies/curl_github_packages_spec.rb b/Library/Homebrew/test/download_strategies/curl_github_packages_spec.rb index a69c8da77b..04d6386a90 100644 --- a/Library/Homebrew/test/download_strategies/curl_github_packages_spec.rb +++ b/Library/Homebrew/test/download_strategies/curl_github_packages_spec.rb @@ -88,6 +88,52 @@ RSpec.describe CurlGitHubPackagesDownloadStrategy do .to eq(HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--foo--1.2.3.arm64_ventura.bottle.tar.gz") end + context "with an existing resolved cache location" do + let(:url) { "https://#{GitHubPackages::URL_DOMAIN}/v2/homebrew/core/foo/manifests/1.2.3" } + let(:specs) { {} } + + it "uses it without discovering cache files" do + strategy.resolved_basename = "foo-1.2.3.bottle_manifest.json" + cached_location = HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--foo-1.2.3.bottle_manifest.json" + cached_location.dirname.mkpath + cached_location.write("cached") + + expect(Pathname).not_to receive(:glob) + + expect(strategy.cached_location).to eq(cached_location) + end + + it "does not discover cache files for 100 cached manifests" do + strategies = (1..100).map do |index| + url = "https://#{GitHubPackages::URL_DOMAIN}/v2/homebrew/core/foo#{index}/manifests/1.2.3" + strategy = described_class.new(url, "foo#{index}", version, **specs) + strategy.resolved_basename = "foo#{index}-1.2.3.bottle_manifest.json" + cached_location = HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--foo#{index}-1.2.3.bottle_manifest.json" + cached_location.dirname.mkpath + cached_location.write("cached") + strategy + end + + expect(Pathname).not_to receive(:glob) + + strategies.each(&:cached_location) + end + end + + context "with a cached download using another basename" do + let(:url) { "https://#{GitHubPackages::URL_DOMAIN}/v2/homebrew/core/foo/manifests/1.2.4" } + let(:specs) { {} } + + it "uses generic cache discovery" do + strategy.resolved_basename = "foo-1.2.4.bottle_manifest.json" + cached_location = HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--server-filename.json" + cached_location.dirname.mkpath + cached_location.write("cached") + + expect(strategy.cached_location).to eq(cached_location) + end + end + context "with a custom cache" do let(:cache) { HOMEBREW_CACHE/"custom-cache" } let(:specs) { { bottle: true, cache: } } @@ -107,7 +153,9 @@ RSpec.describe CurlGitHubPackagesDownloadStrategy do it "uses generic cache discovery" do strategy.resolved_basename = "foo--1.2.3.arm64_ventura.bottle.tar.gz" - expect(strategy.immutable_bottle_blob?).to be false + expect(Pathname).to receive(:glob).and_call_original + + strategy.cached_location end end end