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

- Use Bootsnap compiler namespaces for cache invalidation without changing Bootsnap's cache directory.

## 0.4.1 (2026-07-22)

- Bring back Ruby 2.3 compatibility.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ require "require-hooks/setup"

The _around load_ hooks are executed for all files independently of whether they are cached or not. Source transformation and hijacking is only done for non-cached files.

Thus, if you introduce new source transformers or hijackers, you must invalidate the cache. (We plan to implement automatic invalidation in future versions.)
Require Hooks separates cached instructions by hook configuration. New source transformers and hijackers invalidate affected cache entries automatically.

## Limitations

Expand Down
120 changes: 79 additions & 41 deletions lib/require-hooks/mode/bootsnap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,75 +28,109 @@ def input_to_storage(source, path, *)
end
end

# For new Bootsnap
module CompilerExt
def input_to_storage(source, path, *)
# Bootsnap before 1.24 does not support compiler namespaces.
module LegacyCacheExt
def fetch(path, cache_dir: self.cache_dir)
ctx = RequireHooks.context_for(path)
return super if ctx.empty?
cache_dir = File.join(cache_dir, RequireHooks::Bootsnap.version_hash) unless ctx.empty?

super
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

def initialize(compiler)
@compiler = compiler
@namespace = "#{compiler.namespace}-require-hooks-#{RequireHooks::Bootsnap.version_hash}"
end

def input_to_storage(source, path, *args)
iseq = compile(RequireHooks.context_for(path), path, nil)
return @compiler.input_to_storage(source, path, *args) unless iseq

iseq.to_binary
rescue SyntaxError, TypeError
::Bootsnap::CompileCache::UNCOMPILABLE
end

def storage_to_output(*args)
@compiler.storage_to_output(*args)
end

def input_to_output(source, path, args)
compile(RequireHooks.context_for(path), path, args) || @compiler.input_to_output(source, path, args)
end

private

def compile(ctx, path, args)
new_contents = ctx.perform_source_transform(path)
hijacked = ctx.try_hijack_load(path, new_contents)

if hijacked
raise TypeError, "Unsupported bytecode format for #{path}: #{hijack.class}" unless hijacked.is_a?(::RubyVM::InstructionSequence)
return hijacked.to_binary
elsif new_contents
return RubyVM::InstructionSequence.compile(new_contents, path, path, 1, @compile_options).to_binary
raise TypeError, "Unsupported bytecode format for #{path}: #{hijacked.class}" unless hijacked.is_a?(::RubyVM::InstructionSequence)
return hijacked
end

super
rescue SyntaxError, TypeError
::Bootsnap::CompileCache::UNCOMPILABLE
return unless new_contents

@compiler.input_to_output(new_contents, path, args) ||
RubyVM::InstructionSequence.compile(new_contents, path, path, 1)
end
end

module LoadIseqExt
class << self
attr_accessor :orig_cache_dir
end

# Around hooks must be performed every time we trigger a file load, even if
# the file is already cached.
def load_iseq(path)
ctx = RequireHooks.context_for(path)
# Early-return for non-trackable paths
if ctx.empty?
::Bootsnap::CompileCache::ISeq.cache_dir = LoadIseqExt.orig_cache_dir if LoadIseqExt.orig_cache_dir
return super
end

LoadIseqExt.orig_cache_dir ||= ::Bootsnap::CompileCache::ISeq.cache_dir
::Bootsnap::CompileCache::ISeq.cache_dir = File.join(LoadIseqExt.orig_cache_dir, RequireHooks::Bootsnap.version_hash)
return super if ctx.empty?

ctx.run_around_load_callbacks(path) do
begin
iseq = super
iseq = super

::Bootsnap::CompileCache::ISeq.cache_dir = LoadIseqExt.orig_cache_dir
# Bootsnap returns nil when the coverage is on,
# we fallback to our custom #compile_with_coverage
unless iseq
next unless defined?(Coverage) && Coverage.running?

# Bootsnap returns nil when the coverage is on,
# we fallback to our custom #compile_with_coverage
unless iseq
next unless defined?(Coverage) && Coverage.running?

iseq = RequireHooks::Iseq.compile_with_coverage(ctx, path)
end

iseq.eval
EMPTY_ISEQ
ensure
::Bootsnap::CompileCache::ISeq.cache_dir = LoadIseqExt.orig_cache_dir
iseq = RequireHooks::Iseq.compile_with_coverage(ctx, path)
end

iseq.eval
EMPTY_ISEQ
end
end
end

class << self
def install_compiler_selector
iseq = ::Bootsnap::CompileCache::ISeq
@original_compiler_selector = iseq.compiler_selector
@compilers = {}.compare_by_identity
iseq.compiler_selector = method(:compiler_for)
end

def compiler_for(path)
iseq = ::Bootsnap::CompileCache::ISeq
compiler = @original_compiler_selector&.call(path) || iseq.default_compiler
return compiler if RequireHooks.context_for(path).empty?

@compilers[compiler] ||= VersionedCompiler.new(compiler)
end

def version_hash
@version_hash ||= RequireHooks.contexts.values.map(&:to_cache_key).join("-")
end

attr_writer :version_hash
def version_hash=(version_hash)
@version_hash = version_hash
@compilers&.clear
end
end
end

Expand All @@ -111,6 +145,10 @@ def to_cache_key
end
end

Bootsnap::CompileCache::ISeq.singleton_class.prepend(RequireHooks::Bootsnap::CompileCacheExt)
Bootsnap::CompileCache::ISeq::Compiler.prepend(RequireHooks::Bootsnap::CompilerExt) if defined?(Bootsnap::CompileCache::ISeq::Compiler)
if defined?(Bootsnap::CompileCache::ISeq::Compiler)
RequireHooks::Bootsnap.install_compiler_selector
else
Bootsnap::CompileCache::ISeq.singleton_class.prepend(RequireHooks::Bootsnap::CompileCacheExt)
Bootsnap::CompileCache::ISeq.singleton_class.prepend(RequireHooks::Bootsnap::LegacyCacheExt)
end
RubyVM::InstructionSequence.singleton_class.prepend(RequireHooks::Bootsnap::LoadIseqExt)
13 changes: 13 additions & 0 deletions spec/require-hooks/bootsnap_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
output.should include("Good-bye (false)\n")
output.should include("Good-bye (true)\n")
output.should include("Events: before-hook, before-file, after-file, after-hook")
output.should include("Cache directory unchanged: true\n")

# Only when the Bootsnap mode is used
if @bootsnap_logs_available
Expand Down Expand Up @@ -55,6 +56,18 @@
end
end

if Gem::Version.new(Gem::Specification.find_by_name("bootsnap").version) >= Gem::Version.new("1.24.0")
it "preserves an existing Bootsnap compiler selector" do
run_ruby(
File.join(__dir__, "fixtures", "bootsnap-cache.rb").to_s,
env: {"FROZEN" => "true", "REQUIRE_HOOKS_MODE" => "bootsnap"}
) do |_status, output, _err|
output.should_not include("Good-bye (false)\n")
output.should include("Good-bye (true)\n")
Comment thread
palkan marked this conversation as resolved.
end
end
end

context "coverage" do
# Eval coverage is only avaiable from 3.2
next unless RUBY_VERSION >= "3.2.0"
Expand Down
4 changes: 4 additions & 0 deletions spec/require-hooks/fixtures/bootsnap-cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
compile_cache_yaml: true
)

if ENV["FROZEN"] == "true"
Bootsnap::CompileCache::ISeq.compiler_selector = ->(_) { Bootsnap::CompileCache::ISeq::FROZEN_STRING_LITERAL }
end

$events = []

Bootsnap.instrumentation = ->(event, path) {
Expand Down
9 changes: 6 additions & 3 deletions spec/require-hooks/fixtures/bootsnap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
compile_cache_yaml: true
)

cache_dir = Bootsnap::CompileCache::ISeq.cache_dir

require "require-hooks/setup"

Bootsnap.instrumentation = ->(event, path) {
puts "#{event}: #{File.basename(path)}"
}

RequireHooks.source_transform do |path, source|
next unless path =~ /fixtures\/hello\.rb$/
next unless /fixtures\/hello\.rb$/.match?(path)

source ||= File.read(path)
source.gsub!("Hello", "Good-bye")
Expand All @@ -26,7 +28,7 @@
$events = []

RequireHooks.around_load do |path, &block|
next block.call unless path =~ /fixtures\/hello\.rb$/
next block.call unless /fixtures\/hello\.rb$/.match?(path)

$events << "before-hook"
block.call.tap { $events << "after-hook" }
Expand All @@ -35,7 +37,7 @@
load File.join(__dir__, "hello.rb")

RequireHooks.around_load do |path, &block|
next block.call unless path =~ /fixtures\/hello\.rb$/
next block.call unless /fixtures\/hello\.rb$/.match?(path)

was_frozen_string_literal = RubyVM::InstructionSequence.compile_option[:frozen_string_literal]
begin
Expand All @@ -49,3 +51,4 @@
load File.join(__dir__, "hello.rb")

puts "Events: #{$events.join(", ")}"
puts "Cache directory unchanged: #{Bootsnap::CompileCache::ISeq.cache_dir == cache_dir}"
Loading