Skip to content

Commit

Permalink
Fix LOADED_FEATURES issue:
Browse files Browse the repository at this point in the history
This fixes an issue where we were defeating ruby's ability to detect
that a feature's name-as-required has already been loaded in situations
where the $LOAD_PATH changed in such a way as to make the feature expand
to a new absolute path.
  • Loading branch information
burke committed Mar 6, 2018
1 parent 09c9235 commit 05d4fc9
Show file tree
Hide file tree
Showing 5 changed files with 221 additions and 35 deletions.
5 changes: 4 additions & 1 deletion lib/bootsnap/load_path_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ module LoadPathCache
CACHED_EXTENSIONS = DLEXT2 ? [DOT_RB, DLEXT, DLEXT2] : [DOT_RB, DLEXT]

class << self
attr_reader :load_path_cache, :autoload_paths_cache
attr_reader :load_path_cache, :autoload_paths_cache, :loaded_features_index

def setup(cache_path:, development_mode:, active_support: true)
store = Store.new(cache_path)

@loaded_features_index = LoadedFeaturesIndex.new

@load_path_cache = Cache.new(store, $LOAD_PATH, development_mode: development_mode)
require_relative 'load_path_cache/core_ext/kernel_require'

Expand All @@ -50,3 +52,4 @@ def setup(cache_path:, development_mode:, active_support: true)
require_relative 'load_path_cache/cache'
require_relative 'load_path_cache/store'
require_relative 'load_path_cache/change_observer'
require_relative 'load_path_cache/loaded_features_index'
92 changes: 59 additions & 33 deletions lib/bootsnap/load_path_cache/core_ext/kernel_require.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,78 +11,104 @@ def self.make_load_error(path)
end

module Kernel
alias_method :require_without_cache, :require
alias_method :require_without_bootsnap, :require

# Note that require registers to $LOADED_FEATURES while load does not.
def require_with_bootsnap_lfi(path, resolved = nil)
Bootsnap::LoadPathCache.loaded_features_index.register(path, resolved) do
require_without_bootsnap(resolved || path)
end
end

def require(path)
return false if Bootsnap::LoadPathCache.loaded_features_index.key?(path)

if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
require_without_cache(resolved)
else
raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
return require_with_bootsnap_lfi(path, resolved)
end

raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
rescue Bootsnap::LoadPathCache::ReturnFalse
return false
rescue Bootsnap::LoadPathCache::FallbackScan
require_without_cache(path)
require_with_bootsnap_lfi(path)
end

alias_method :load_without_cache, :load
alias_method :load_without_bootsnap, :load
def load(path, wrap = false)
if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
load_without_cache(resolved, wrap)
else
# load also allows relative paths from pwd even when not in $:
relative = File.expand_path(path)
if File.exist?(File.expand_path(path))
return load_without_cache(relative, wrap)
end
raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
return load_without_bootsnap(resolved, wrap)
end

# load also allows relative paths from pwd even when not in $:
if File.exist?(relative = File.expand_path(path))
return load_without_bootsnap(relative, wrap)
end

raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
rescue Bootsnap::LoadPathCache::ReturnFalse
return false
rescue Bootsnap::LoadPathCache::FallbackScan
load_without_cache(path, wrap)
load_without_bootsnap(path, wrap)
end
end

class << Kernel
alias_method :require_without_cache, :require
alias_method :require_without_bootsnap, :require

def require_with_bootsnap_lfi(path, resolved = nil)
Bootsnap::LoadPathCache.loaded_features_index.register(path, resolved) do
require_without_bootsnap(resolved || path)
end
end

def require(path)
return false if Bootsnap::LoadPathCache.loaded_features_index.key?(path)

if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
require_without_cache(resolved)
else
raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
return require_with_bootsnap_lfi(path, resolved)
end

raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
rescue Bootsnap::LoadPathCache::ReturnFalse
return false
rescue Bootsnap::LoadPathCache::FallbackScan
require_without_cache(path)
require_with_bootsnap_lfi(path)
end

alias_method :load_without_cache, :load
alias_method :load_without_bootsnap, :load
def load(path, wrap = false)
if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
load_without_cache(resolved, wrap)
else
# load also allows relative paths from pwd even when not in $:
relative = File.expand_path(path)
if File.exist?(relative)
return load_without_cache(relative, wrap)
end
raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
return load_without_bootsnap(resolved, wrap)
end

# load also allows relative paths from pwd even when not in $:
if File.exist?(relative = File.expand_path(path))
return load_without_bootsnap(relative, wrap)
end

raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
rescue Bootsnap::LoadPathCache::ReturnFalse
return false
rescue Bootsnap::LoadPathCache::FallbackScan
load_without_cache(path, wrap)
load_without_bootsnap(path, wrap)
end
end

class Module
alias_method :autoload_without_cache, :autoload
alias_method :autoload_without_bootsnap, :autoload
def autoload(const, path)
autoload_without_cache(const, Bootsnap::LoadPathCache.load_path_cache.find(path) || path)
# NOTE: This may defeat LoadedFeaturesIndex, but it's not immediately
# obvious how to make it work. This feels like a pretty niche case, unclear
# if it will ever burn anyone.
#
# The challenge is that we don't control the point at which the entry gets
# added to $LOADED_FEATURES and won't be able to hook that modification
# since it's done in C-land.
autoload_without_bootsnap(const, Bootsnap::LoadPathCache.load_path_cache.find(path) || path)
rescue Bootsnap::LoadPathCache::ReturnFalse
return false
rescue Bootsnap::LoadPathCache::FallbackScan
autoload_without_cache(const, path)
autoload_without_bootsnap(const, path)
end
end
95 changes: 95 additions & 0 deletions lib/bootsnap/load_path_cache/loaded_features_index.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
module Bootsnap
module LoadPathCache
# LoadedFeaturesIndex partially mirrors an internal structure in ruby that
# we can't easily obtain an interface to.
#
# This works around an issue where, without bootsnap, *ruby* knows that it
# has already required a file by its short name (e.g. require 'bundler') if
# a new instance of bundler is added to the $LOAD_PATH which resolves to a
# different absolute path. This class makes bootsnap smart enough to
# realize that it has already loaded 'bundler', and not just
# '/path/to/bundler'.
#
# If you disable LoadedFeaturesIndex, you can see the problem this solves by:
#
# 1. `require 'a'`
# 2. Prepend a new $LOAD_PATH element containing an `a.rb`
# 3. `require 'a'`
#
# Ruby returns false from step 3.
# With bootsnap but with no LoadedFeaturesIndex, this loads two different
# `a.rb`s.
# With bootsnap and with LoadedFeaturesIndex, this skips the second load,
# returning false like ruby.
class LoadedFeaturesIndex
def initialize
@lfi = {}
@mutex = defined?(::Mutex) ? ::Mutex.new : ::Thread::Mutex.new # TODO: Remove once Ruby 2.2 support is dropped.

# In theory the user could mutate $LOADED_FEATURES and invalidate our
# cache. If this ever comes up in practice — or if you, the
# enterprising reader, feels inclined to solve this problem — we could
# parallel the work done with ChangeObserver on $LOAD_PATH to mirror
# updates to our @lfi.
$LOADED_FEATURES.each do |feat|
$LOAD_PATH.each do |lpe|
next unless feat.start_with?(lpe)
# /a/b/lib/my/foo.rb
# ^^^^^^^^^
short = feat[(lpe.length + 1)..-1]
@lfi[short] = true
@lfi[strip_extension(short)] = true
end
end
end

def key?(feature)
@mutex.synchronize { @lfi.key?(feature) }
end

# There is a relatively uncommon case where we could miss adding an
# entry:
#
# If the user asked for e.g. `require 'bundler'`, and we went through the
# `FallbackScan` pathway in `kernel_require.rb` and therefore did not
# pass `long` (the full expanded absolute path), then we did are not able
# to confidently add the `bundler.rb` form to @lfi.
#
# We could either:
#
# 1. Just add `bundler.rb`, `bundler.so`, and so on, which is close but
# not quite right; or
# 2. Inspect $LOADED_FEATURES upon return from yield to find the matching
# entry.
def register(short, long = nil)
ret = yield

# do we have 'bundler' or 'bundler.rb'?
altname = if File.extname(short) != ''
# strip the path from 'bundler.rb' -> 'bundler'
strip_extension(short)
elsif long && ext = File.extname(long)
# get the extension from the expanded path if given
# 'bundler' + '.rb'
short + ext
end

@mutex.synchronize do
@lfi[short] = true
(@lfi[altname] = true) if altname
end

ret
end

private

STRIP_EXTENSION = /\..*?$/
private_constant :STRIP_EXTENSION

def strip_extension(f)
f.sub(STRIP_EXTENSION, '')
end
end
end
end
2 changes: 1 addition & 1 deletion lib/bootsnap/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Bootsnap
VERSION = "1.1.8"
VERSION = "1.2.0.pre"
end
62 changes: 62 additions & 0 deletions test/load_path_cache/loaded_features_index_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
require 'test_helper'

module Bootsnap
module LoadPathCache
class LoadedFeaturesIndexTest < MiniTest::Test
def setup
@index = LoadedFeaturesIndex.new
# not really necessary but let's just make it a clean slate
@index.instance_variable_set(:@lfi, {})
end

def test_successful_addition
refute @index.key?('bundler')
refute @index.key?('bundler.rb')
refute @index.key?('foo')
@index.register('bundler', '/a/b/bundler.rb') {}
assert @index.key?('bundler')
assert @index.key?('bundler.rb')
refute @index.key?('foo')
end

def test_no_add_on_raise
refute @index.key?('bundler')
refute @index.key?('bundler.rb')
refute @index.key?('foo')
assert_raises(RuntimeError) do
@index.register('bundler', '/a/b/bundler.rb') { raise }
end
refute @index.key?('bundler')
refute @index.key?('bundler.rb')
refute @index.key?('foo')
end

def test_infer_base_from_ext
refute @index.key?('bundler')
refute @index.key?('bundler.rb')
refute @index.key?('foo')
@index.register('bundler.rb') {}
assert @index.key?('bundler')
assert @index.key?('bundler.rb')
refute @index.key?('foo')
end

def test_cannot_infer_ext_from_base # Current limitation
refute @index.key?('bundler')
refute @index.key?('bundler.rb')
refute @index.key?('foo')
@index.register('bundler') {}
assert @index.key?('bundler')
refute @index.key?('bundler.rb')
refute @index.key?('foo')
end

def test_derives_initial_state_from_loaded_features
index = LoadedFeaturesIndex.new
assert index.key?('minitest/autorun')
assert index.key?('minitest/autorun.rb')
refute index.key?('minitest/autorun.so')
end
end
end
end

0 comments on commit 05d4fc9

Please sign in to comment.