Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 32 additions & 5 deletions lib/require-hooks/mode/bootsnap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ def fetch(path, cache_dir: self.cache_dir)
# 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)
Expand Down Expand Up @@ -120,11 +121,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)
Expand Down
41 changes: 40 additions & 1 deletion spec/require-hooks/bootsnap_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions spec/require-hooks/fixtures/bootsnap-cache-key.rb
Original file line number Diff line number Diff line change
@@ -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}"
15 changes: 13 additions & 2 deletions spec/require-hooks/fixtures/bootsnap-cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
Loading