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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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