What did you do?
Ran require-hooks in combination with bootsnap
What did you expect to happen?
ISeq.cache_dir should remain consistent throughout program run.
What actually happened?
ISeq.cache_dir becomes corrupted with extra append.
Additional context
Reproduction
# Bootsnap::CompileCache::ISeq's accessor is asymmetric (compile_cache/iseq.rb:10-15):
# * reader returns the path WITH "-iseq" already appended
# * writer appends "-iseq" to whatever you assign it
# so `ISeq.cache_dir = ISeq.cache_dir` appends the suffix a second time.
#
# require-hooks memoises the READER's value (mode/bootsnap.rb:68 in 0.4.1) and
# restores it through the WRITER (:75 and :88), so after the first hooked load the
# process is permanently pointed at "<base>-iseq-iseq" instead of "<base>-iseq".
#
# Verified with this script, bootsnap 1.25.0 throughout unless noted:
#
# require-hooks result
# 0.2.2 PASS
# 0.2.3 PASS
# 0.3.0 PASS
# 0.4.0 FAIL <- first failing release
# 0.4.1 FAIL
#
# Also PASS on require-hooks 0.2.2 + bootsnap 1.23.0, so bootsnap's version is not
# the variable. Before 0.4.0, LoadIseqExt#load_iseq never assigned ISeq.cache_dir --
# the orig_cache_dir memo and version-hashed subdirectory arrive in
# ruby-next/require-hooks#2 ("Bootsnap mode: improve cache invalidation",
# merged 2026-04-29). The invalidation approach is sound; the round trip through
# the asymmetric accessor is what breaks.
#
# Usage:
# ruby require-hooks-drift-repro.rb # defaults below
# REQUIRE_HOOKS_VERSION=0.2.2 ruby require-hooks-drift-repro.rb
# BOOTSNAP_VERSION=1.23.0 REQUIRE_HOOKS_VERSION=0.3.0 ruby require-hooks-drift-repro.rb
#
# Exits 0 on PASS, 1 on FAIL, so it works directly with `git bisect run` or a CI
# check guarding a require-hooks bump. Gems are installed on demand from
# rubygems.org, so nothing needs to be preinstalled.
require "bundler/inline"
BOOTSNAP_VERSION = ENV.fetch("BOOTSNAP_VERSION", "1.25.0")
REQUIRE_HOOKS_VERSION = ENV.fetch("REQUIRE_HOOKS_VERSION", "0.4.1")
gemfile(true, quiet: true) do
source "https://rubygems.org"
# require: false so this script controls load order -- Bootsnap must be set up
# before require-hooks/setup, or require-hooks picks a non-Bootsnap strategy.
gem "bootsnap", BOOTSNAP_VERSION, require: false
gem "require-hooks", REQUIRE_HOOKS_VERSION, require: false
end
require "bootsnap"
require "tmpdir"
require "fileutils"
puts "bootsnap #{Bootsnap::VERSION}, require-hooks #{REQUIRE_HOOKS_VERSION}"
puts
Dir.mktmpdir("require-hooks-drift") do |root|
rel = ->(path) { path.to_s.sub(root, "<tmp>") }
# A hooked file. It needs at least one directory level below the pattern root:
# require-hooks matches with File.fnmatch? and no FNM_PATHNAME, so "<root>/**/*.rb"
# will not match "<root>/hooked.rb". Getting this wrong makes ctx.empty? true and
# the bug silently fails to reproduce.
FileUtils.mkdir_p("#{root}/lib/sub")
File.write("#{root}/lib/sub/hooked.rb", "class Hooked; end\n")
File.write("#{root}/lib/sub/other.rb", "class Other; end\n")
Bootsnap.setup(
cache_dir: "#{root}/cache", development_mode: false,
load_path_cache: false, compile_cache_iseq: true, compile_cache_yaml: false,
)
baseline = Bootsnap::CompileCache::ISeq.cache_dir
puts "1. after Bootsnap.setup #{rel[baseline]}" # => <tmp>/cache/bootsnap/compile-cache-iseq
ENV["REQUIRE_HOOKS_MODE"] = "bootsnap"
require "require-hooks/setup"
# Any hook of any type is enough: Context#empty? (api.rb:30-33) does not
# distinguish source_transform / hijack_load / around_load.
RequireHooks.source_transform(patterns: ["#{root}/**/*.rb"]) do |path, src|
src ||= File.read(path)
src # no-op transform
end
puts "2. after require-hooks #{rel[Bootsnap::CompileCache::ISeq.cache_dir]}" # => <tmp>/cache/bootsnap/compile-cache-iseq
require "#{root}/lib/sub/hooked.rb"
after = Bootsnap::CompileCache::ISeq.cache_dir
puts "3. after ONE hooked load #{rel[after]}" # => <tmp>/cache/bootsnap/compile-cache-iseq-iseq
# Consequence: ISeq.precompile reads cache_dir directly, so it now writes into
# the drifted directory -- which load_iseq never reads back for hooked files.
Bootsnap::CompileCache::ISeq.precompile("#{root}/lib/sub/other.rb")
puts
puts "expected after step 3: #{rel[baseline]}" # => <tmp>/cache/bootsnap/compile-cache-iseq
puts "actual after step 3: #{rel[after]}" # => <tmp>/cache/bootsnap/compile-cache-iseq-iseq
puts
puts "directories created under <tmp>/cache/bootsnap:"
Dir.glob("#{root}/cache/bootsnap/*/").sort.each { |d| puts " #{rel[d]}" } # prints:
# <tmp>/cache/bootsnap/compile-cache-iseq-iseq/
# <tmp>/cache/bootsnap/compile-cache-iseq/
puts
if after == baseline
puts "PASS"
else
puts "FAIL - cache_dir gained an extra '-iseq'"
at_exit { exit 1 }
end
end
Cause
|
::Bootsnap::CompileCache::ISeq.cache_dir = File.join(LoadIseqExt.orig_cache_dir, RequireHooks::Bootsnap.version_hash) |
|
::Bootsnap::CompileCache::ISeq.cache_dir = LoadIseqExt.orig_cache_dir |
|
::Bootsnap::CompileCache::ISeq.cache_dir = LoadIseqExt.orig_cache_dir |
Environment
Ruby Version:
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin]
Framework Version (Rails, whatever):
Require Hooks Version:
0.4.1
What did you do?
Ran
require-hooksin combination withbootsnapWhat did you expect to happen?
ISeq.cache_dirshould remain consistent throughout program run.What actually happened?
ISeq.cache_dirbecomes corrupted with extra append.Additional context
Reproduction
Cause
require-hooks/lib/require-hooks/mode/bootsnap.rb
Line 69 in 243e839
require-hooks/lib/require-hooks/mode/bootsnap.rb
Line 75 in 243e839
require-hooks/lib/require-hooks/mode/bootsnap.rb
Line 88 in 243e839
Environment
Ruby Version:
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin]Framework Version (Rails, whatever):
Require Hooks Version:
0.4.1