From 308df2c1da40f4acf8017fdd91b0ed17e0a619f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 26 Aug 2026 13:57:39 -0400 Subject: [PATCH 1/2] Add external Bootsnap cache inputs Require Hooks source transforms can depend on external libraries or configuration. The existing version_hash= API sets one complete, static key and replaces the automatic hook key. For example: RequireHooks::Bootsnap.version_hash = Parser::VERSION RequireHooks.source_transform do |_path, source| source.gsub("...", options_digest) end If options_digest changes while Parser::VERSION stays the same, Bootsnap reuses the old transformed entry because version_hash= does not include the transform option. A second version_hash= assignment would instead discard the first dependency. Applications can register both inputs with: RequireHooks::Bootsnap.add_version_hash do "parser-#{Parser::VERSION}-#{options_digest}" end Require Hooks evaluates each callable when it computes the key, hashes each contribution, and combines all contributions with the hook configuration. It then isolates transformed entries with Bootsnap compiler namespaces on modern Bootsnap and versioned cache directories on older versions. --- CHANGELOG.md | 2 + README.md | 8 ++++ lib/require-hooks/mode/bootsnap.rb | 41 ++++++++++++++++--- spec/require-hooks/bootsnap_spec.rb | 41 ++++++++++++++++++- .../fixtures/bootsnap-cache-key.rb | 30 ++++++++++++++ spec/require-hooks/fixtures/bootsnap-cache.rb | 15 ++++++- 6 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 spec/require-hooks/fixtures/bootsnap-cache-key.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 0270caa..344d4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## master +- Add `RequireHooks::Bootsnap.add_version_hash` for external cache inputs. + ## 0.5.0 (2026-08-26) - Use Bootsnap compiler namespaces for cache invalidation without changing Bootsnap's cache directory. diff --git a/README.md b/README.md index 7fe0fec..4491d4e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ The _around load_ hooks are executed for all files independently of whether they Require Hooks separates cached instructions by hook configuration. New source transformers and hijackers invalidate affected cache entries automatically. +If a transform depends on an external library or configuration, add that input to the cache key: + +```ruby +RequireHooks::Bootsnap.add_version_hash { "parser-#{Parser::VERSION}-#{options_digest}" } +``` + +The value can be a String or a callable that returns a String. Require Hooks hashes each contribution and combines all contributions with its hook key. The callable runs when Require Hooks computes the key, so it does not freeze the key before later hooks register. + ## Limitations - Coverage tracking is only supported in Ruby 4.0.4+ (or 3.4.10+ for 3.4.x series); for older versions in 3.4 and 4.0 series, you can enable `eval` coverage tracking to make it work with Require Hooks (`Coverage.start(eval: true, ...)` or `SimpleCov.enable_coverage :eval`). For Ruby 3.2.x and 3.3.x, only around hooks are supported. diff --git a/lib/require-hooks/mode/bootsnap.rb b/lib/require-hooks/mode/bootsnap.rb index d53d038..d7fd9d2 100644 --- a/lib/require-hooks/mode/bootsnap.rb +++ b/lib/require-hooks/mode/bootsnap.rb @@ -34,18 +34,21 @@ def fetch(path, cache_dir: self.cache_dir) ctx = RequireHooks.context_for(path) cache_dir = File.join(cache_dir, RequireHooks::Bootsnap.version_hash) unless ctx.empty? - super + # standard:disable Style/SuperArguments + super(path, cache_dir: cache_dir) + # standard:enable Style/SuperArguments end end # Bootsnap 1.24+ compiler wrapper. Its namespace isolates transformed ISeqs # without changing Bootsnap's process-wide cache directory. class VersionedCompiler - attr_reader :namespace + attr_reader :namespace, :version_hash - def initialize(compiler) + def initialize(compiler, version_hash) @compiler = compiler - @namespace = "#{compiler.namespace}-require-hooks-#{RequireHooks::Bootsnap.version_hash}" + @version_hash = version_hash + @namespace = "#{compiler.namespace}-require-hooks-#{version_hash}" end def input_to_storage(source, path, *args) @@ -120,11 +123,37 @@ def compiler_for(path) compiler = @original_compiler_selector&.call(path) || iseq.default_compiler return compiler if RequireHooks.context_for(path).empty? - @compilers[compiler] ||= VersionedCompiler.new(compiler) + version_hash = RequireHooks::Bootsnap.version_hash + cached = @compilers[compiler] + return cached if cached&.version_hash == version_hash + + @compilers[compiler] = VersionedCompiler.new(compiler, version_hash) + end + + def add_version_hash(value = nil, &block) + contributor = block || value + unless contributor.is_a?(String) || contributor.respond_to?(:call) + raise ArgumentError, "version hash contribution must be a String or callable" + end + + @version_hash_contributors ||= [] + @version_hash_contributors << (contributor.is_a?(String) ? contributor.dup.freeze : contributor) + @compilers&.clear + contributor end def version_hash - @version_hash ||= RequireHooks.contexts.values.map(&:to_cache_key).join("-") + return @version_hash unless @version_hash.nil? + + context_keys = RequireHooks.contexts.values.map(&:to_cache_key) + contribution_keys = Array(@version_hash_contributors).map do |contributor| + value = contributor.respond_to?(:call) ? contributor.call : contributor + raise TypeError, "version hash contribution must return a String" unless value.is_a?(String) + + Zlib.crc32(value).to_s + end.sort + + (context_keys + contribution_keys).join("-") end def version_hash=(version_hash) diff --git a/spec/require-hooks/bootsnap_spec.rb b/spec/require-hooks/bootsnap_spec.rb index 27b8c41..9899bb6 100644 --- a/spec/require-hooks/bootsnap_spec.rb +++ b/spec/require-hooks/bootsnap_spec.rb @@ -32,7 +32,7 @@ misses = output.scan(/miss: (.*)$/).flatten # Since we use different folders for different hook configuration, # we expect to see miss, not stale - misses.size.should == 2 + misses.size.should == 4 end end end @@ -46,6 +46,45 @@ end end + it "invalidates transformed code when an external cache key changes" do + run_ruby( + File.join(__dir__, "fixtures", "bootsnap-cache.rb").to_s, + env: {"TRANSFORM_VALUE" => "First", "CACHE_KEY" => "first", "CACHE_KEY_CALLABLE" => "true", "REQUIRE_HOOKS_MODE" => "bootsnap"} + ) do |_status, output, _err| + output.should include("First (false)\n") + end + + run_ruby( + File.join(__dir__, "fixtures", "bootsnap-cache.rb").to_s, + env: {"TRANSFORM_VALUE" => "Second", "CACHE_KEY" => "second", "CACHE_KEY_CALLABLE" => "true", "REQUIRE_HOOKS_MODE" => "bootsnap"} + ) do |_status, output, _err| + output.should include("Second (false)\n") + end + end + + it "includes hooks registered after a version hash read" do + run_ruby( + File.join(__dir__, "fixtures", "bootsnap-cache-key.rb").to_s, + env: {"EARLY_READ" => "true"} + ) do |_status, output, _err| + output.should include("early_read_changed=true\n") + end + end + + it "does not depend on version hash contribution order" do + keys = [] + ["first,second", "second,first"].each do |cache_keys| + run_ruby( + File.join(__dir__, "fixtures", "bootsnap-cache-key.rb").to_s, + env: {"CACHE_KEYS" => cache_keys} + ) do |_status, output, _err| + keys << output[/version_hash=(.*)$/, 1] + end + end + + keys[0].should == keys[1] + end + it "re-raises syntax errors" do run_ruby( File.join(__dir__, "fixtures", "bootsnap-syntax-error.rb").to_s, diff --git a/spec/require-hooks/fixtures/bootsnap-cache-key.rb b/spec/require-hooks/fixtures/bootsnap-cache-key.rb new file mode 100644 index 0000000..3d9d7e5 --- /dev/null +++ b/spec/require-hooks/fixtures/bootsnap-cache-key.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "bootsnap" +Bootsnap.setup( + cache_dir: File.join(__dir__, "tmp/cache"), + development_mode: true, + load_path_cache: false, + compile_cache_iseq: true, + compile_cache_yaml: false +) + +ENV["REQUIRE_HOOKS_MODE"] = "bootsnap" +require "require-hooks/setup" + +if (keys = ENV["CACHE_KEYS"]) + keys.split(",").reject(&:empty?).each do |key| + RequireHooks::Bootsnap.add_version_hash(key) + end +end +if ENV["EARLY_READ"] == "true" + RequireHooks.source_transform(patterns: ["*/fixtures/cache-key-a/**/*.rb"]) { |_path, source| source } + first = RequireHooks::Bootsnap.version_hash + + RequireHooks.source_transform(patterns: ["*/fixtures/cache-key-b/**/*.rb"]) { |_path, source| source } + second = RequireHooks::Bootsnap.version_hash + + puts "early_read_changed=#{first != second}" +end + +puts "version_hash=#{RequireHooks::Bootsnap.version_hash}" diff --git a/spec/require-hooks/fixtures/bootsnap-cache.rb b/spec/require-hooks/fixtures/bootsnap-cache.rb index fc0a99a..4ade058 100644 --- a/spec/require-hooks/fixtures/bootsnap-cache.rb +++ b/spec/require-hooks/fixtures/bootsnap-cache.rb @@ -9,6 +9,8 @@ compile_cache_yaml: true ) +transform_value = ENV.fetch("TRANSFORM_VALUE", "Good-bye") + if ENV["FROZEN"] == "true" Bootsnap::CompileCache::ISeq.compiler_selector = ->(_) { Bootsnap::CompileCache::ISeq::FROZEN_STRING_LITERAL } end @@ -22,9 +24,18 @@ unless ENV["HOOKS"] == "false" || ARGV.include?("--no-hooks") require "require-hooks/setup" + if ENV["CACHE_KEY"] + contribution = if ENV["CACHE_KEY_CALLABLE"] == "true" + -> { ENV["CACHE_KEY"] } + else + ENV["CACHE_KEY"] + end + RequireHooks::Bootsnap.add_version_hash(contribution) + end + RequireHooks.source_transform(patterns: ["*/fixtures/hello.rb"]) do |path, source| source ||= File.read(path) - source.gsub!("Hello", "Good-bye") + source.gsub!("Hello", transform_value) source end @@ -40,7 +51,7 @@ if ENV["HOOKS"] == "double-transform" RequireHooks.source_transform(patterns: ["*/fixtures/hello.rb"]) do |path, source| source ||= File.read(path) - source.gsub!("Good-bye", "Ciao") + source.gsub!(transform_value.to_s, "Ciao") source end end From bb745ee48b6fc4d653204256e8af5369238e3d66 Mon Sep 17 00:00:00 2001 From: Vladimir Dementyev Date: Wed, 26 Aug 2026 11:29:27 -0700 Subject: [PATCH 2/2] Update lib/require-hooks/mode/bootsnap.rb --- lib/require-hooks/mode/bootsnap.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/require-hooks/mode/bootsnap.rb b/lib/require-hooks/mode/bootsnap.rb index d7fd9d2..dc77648 100644 --- a/lib/require-hooks/mode/bootsnap.rb +++ b/lib/require-hooks/mode/bootsnap.rb @@ -34,9 +34,7 @@ def fetch(path, cache_dir: self.cache_dir) ctx = RequireHooks.context_for(path) cache_dir = File.join(cache_dir, RequireHooks::Bootsnap.version_hash) unless ctx.empty? - # standard:disable Style/SuperArguments - super(path, cache_dir: cache_dir) - # standard:enable Style/SuperArguments + super end end