From 798f1bf58269e65c5bda72a2d6ae3561d8badc6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 25 Aug 2026 23:44:39 -0400 Subject: [PATCH 1/2] Use Bootsnap namespaces for hook caches Bootsnap 1.24 adds a namespace to each compiler cache. Require Hooks now wraps the selected compiler for hooked paths and uses the hook configuration hash as its namespace. This avoids changes to the process-wide cache directory. Older Bootsnap versions use the fetch cache_dir argument instead. Preserve prior compiler selectors and add regression coverage. Fixes #3 --- CHANGELOG.md | 2 + README.md | 2 +- lib/require-hooks/mode/bootsnap.rb | 122 ++++++++++++------ spec/require-hooks/bootsnap_spec.rb | 12 ++ spec/require-hooks/fixtures/bootsnap-cache.rb | 4 + spec/require-hooks/fixtures/bootsnap.rb | 9 +- 6 files changed, 105 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b55f4de..b9a9998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 6f68699..7fe0fec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/require-hooks/mode/bootsnap.rb b/lib/require-hooks/mode/bootsnap.rb index fca7f4a..d53d038 100644 --- a/lib/require-hooks/mode/bootsnap.rb +++ b/lib/require-hooks/mode/bootsnap.rb @@ -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_key ||= RequireHooks.contexts.values.map(&:to_cache_key).join("-") + @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 @@ -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) diff --git a/spec/require-hooks/bootsnap_spec.rb b/spec/require-hooks/bootsnap_spec.rb index b2845fb..cdf3b93 100644 --- a/spec/require-hooks/bootsnap_spec.rb +++ b/spec/require-hooks/bootsnap_spec.rb @@ -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 @@ -46,6 +47,17 @@ 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 include("Good-bye (true)\n") + end + end + end + context "coverage" do # Eval coverage is only avaiable from 3.2 next unless RUBY_VERSION >= "3.2.0" diff --git a/spec/require-hooks/fixtures/bootsnap-cache.rb b/spec/require-hooks/fixtures/bootsnap-cache.rb index 34c4f13..fc0a99a 100644 --- a/spec/require-hooks/fixtures/bootsnap-cache.rb +++ b/spec/require-hooks/fixtures/bootsnap-cache.rb @@ -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) { diff --git a/spec/require-hooks/fixtures/bootsnap.rb b/spec/require-hooks/fixtures/bootsnap.rb index 0ec3eb3..292f992 100644 --- a/spec/require-hooks/fixtures/bootsnap.rb +++ b/spec/require-hooks/fixtures/bootsnap.rb @@ -9,6 +9,8 @@ compile_cache_yaml: true ) +cache_dir = Bootsnap::CompileCache::ISeq.cache_dir + require "require-hooks/setup" Bootsnap.instrumentation = ->(event, path) { @@ -16,7 +18,7 @@ } 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") @@ -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" } @@ -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 @@ -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}" From 0e7e2aa897842ea5c727a83cf27fd8bcc5b13e10 Mon Sep 17 00:00:00 2001 From: Vladimir Dementyev Date: Wed, 26 Aug 2026 01:28:01 -0700 Subject: [PATCH 2/2] - bootsnap_spec --- spec/require-hooks/bootsnap_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/require-hooks/bootsnap_spec.rb b/spec/require-hooks/bootsnap_spec.rb index cdf3b93..5aa455e 100644 --- a/spec/require-hooks/bootsnap_spec.rb +++ b/spec/require-hooks/bootsnap_spec.rb @@ -53,6 +53,7 @@ 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") end end