Skip to content

Bootsnap ISeq.cache_dir corrupted after the first hooked load (regression in 0.4.0) #3

Description

@jesse-shopify

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):

  • bootsnap 1.25.0

Require Hooks Version:

0.4.1

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions