bootsnap 1.1.8 → 1.7.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +121 -0
  3. data/README.md +72 -14
  4. data/exe/bootsnap +5 -0
  5. data/ext/bootsnap/bootsnap.c +283 -105
  6. data/ext/bootsnap/extconf.rb +21 -14
  7. data/lib/bootsnap.rb +95 -14
  8. data/lib/bootsnap/bundler.rb +6 -3
  9. data/lib/bootsnap/cli.rb +246 -0
  10. data/lib/bootsnap/cli/worker_pool.rb +131 -0
  11. data/lib/bootsnap/compile_cache.rb +32 -4
  12. data/lib/bootsnap/compile_cache/iseq.rb +32 -15
  13. data/lib/bootsnap/compile_cache/yaml.rb +97 -40
  14. data/lib/bootsnap/explicit_require.rb +2 -1
  15. data/lib/bootsnap/load_path_cache.rb +33 -20
  16. data/lib/bootsnap/load_path_cache/cache.rb +65 -29
  17. data/lib/bootsnap/load_path_cache/change_observer.rb +36 -29
  18. data/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb +70 -53
  19. data/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb +18 -0
  20. data/lib/bootsnap/load_path_cache/loaded_features_index.rb +148 -0
  21. data/lib/bootsnap/load_path_cache/path.rb +8 -7
  22. data/lib/bootsnap/load_path_cache/path_scanner.rb +61 -39
  23. data/lib/bootsnap/load_path_cache/realpath_cache.rb +32 -0
  24. data/lib/bootsnap/load_path_cache/store.rb +27 -14
  25. data/lib/bootsnap/setup.rb +3 -40
  26. data/lib/bootsnap/version.rb +2 -1
  27. metadata +26 -29
  28. data/.gitignore +0 -17
  29. data/.rubocop.yml +0 -20
  30. data/.travis.yml +0 -4
  31. data/CODE_OF_CONDUCT.md +0 -74
  32. data/CONTRIBUTING.md +0 -21
  33. data/Gemfile +0 -8
  34. data/Rakefile +0 -11
  35. data/bin/console +0 -14
  36. data/bin/setup +0 -8
  37. data/bin/testunit +0 -8
  38. data/bootsnap.gemspec +0 -39
  39. data/dev.yml +0 -10
  40. data/lib/bootsnap/load_path_cache/core_ext/active_support.rb +0 -75
@@ -1,37 +1,37 @@
1
+ # frozen_string_literal: true
1
2
  module Bootsnap
2
3
  module LoadPathCache
3
4
  module ChangeObserver
4
- def self.register(observer, arr)
5
- # Re-overriding these methods on an array that already has them would
6
- # cause StackOverflowErrors
7
- return if arr.respond_to?(:push_without_lpc)
8
-
5
+ module ArrayMixin
9
6
  # For each method that adds items to one end or another of the array
10
7
  # (<<, push, unshift, concat), override that method to also notify the
11
8
  # observer of the change.
12
- sc = arr.singleton_class
13
- sc.send(:alias_method, :shovel_without_lpc, :<<)
14
- arr.define_singleton_method(:<<) do |entry|
15
- observer.push_paths(self, entry.to_s)
16
- shovel_without_lpc(entry)
9
+ def <<(entry)
10
+ @lpc_observer.push_paths(self, entry.to_s)
11
+ super
12
+ end
13
+
14
+ def push(*entries)
15
+ @lpc_observer.push_paths(self, *entries.map(&:to_s))
16
+ super
17
17
  end
18
18
 
19
- sc.send(:alias_method, :push_without_lpc, :push)
20
- arr.define_singleton_method(:push) do |*entries|
21
- observer.push_paths(self, *entries.map(&:to_s))
22
- push_without_lpc(*entries)
19
+ def unshift(*entries)
20
+ @lpc_observer.unshift_paths(self, *entries.map(&:to_s))
21
+ super
23
22
  end
24
23
 
25
- sc.send(:alias_method, :unshift_without_lpc, :unshift)
26
- arr.define_singleton_method(:unshift) do |*entries|
27
- observer.unshift_paths(self, *entries.map(&:to_s))
28
- unshift_without_lpc(*entries)
24
+ def concat(entries)
25
+ @lpc_observer.push_paths(self, *entries.map(&:to_s))
26
+ super
29
27
  end
30
28
 
31
- sc.send(:alias_method, :concat_without_lpc, :concat)
32
- arr.define_singleton_method(:concat) do |entries|
33
- observer.push_paths(self, *entries.map(&:to_s))
34
- concat_without_lpc(entries)
29
+ # uniq! keeps the first occurrence of each path, otherwise preserving
30
+ # order, preserving the effective load path
31
+ def uniq!(*args)
32
+ ret = super
33
+ @lpc_observer.reinitialize if block_given? || !args.empty?
34
+ ret
35
35
  end
36
36
 
37
37
  # For each method that modifies the array more aggressively, override
@@ -41,16 +41,23 @@ module Bootsnap
41
41
  # accounting cost would be greater than the hit from these, since we
42
42
  # actively discourage calling them.
43
43
  %i(
44
- collect! compact! delete delete_at delete_if fill flatten! insert map!
45
- reject! reverse! select! shuffle! shift slice! sort! sort_by!
46
- ).each do |meth|
47
- sc.send(:alias_method, :"#{meth}_without_lpc", meth)
48
- arr.define_singleton_method(meth) do |*a|
49
- send(:"#{meth}_without_lpc", *a)
50
- observer.reinitialize
44
+ []= clear collect! compact! delete delete_at delete_if fill flatten!
45
+ insert keep_if map! pop reject! replace reverse! rotate! select!
46
+ shift shuffle! slice! sort! sort_by!
47
+ ).each do |method_name|
48
+ define_method(method_name) do |*args, &block|
49
+ ret = super(*args, &block)
50
+ @lpc_observer.reinitialize
51
+ ret
51
52
  end
52
53
  end
53
54
  end
55
+
56
+ def self.register(observer, arr)
57
+ return if arr.frozen? # can't register observer, but no need to.
58
+ arr.instance_variable_set(:@lpc_observer, observer)
59
+ arr.extend(ArrayMixin)
60
+ end
54
61
  end
55
62
  end
56
63
  end
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
1
2
  module Bootsnap
2
3
  module LoadPathCache
3
4
  module CoreExt
4
5
  def self.make_load_error(path)
5
- err = LoadError.new("cannot load such file -- #{path}")
6
+ err = LoadError.new(+"cannot load such file -- #{path}")
7
+ err.instance_variable_set(Bootsnap::LoadPathCache::ERROR_TAG_IVAR, true)
6
8
  err.define_singleton_method(:path) { path }
7
9
  err
8
10
  end
@@ -11,78 +13,93 @@ module Bootsnap
11
13
  end
12
14
 
13
15
  module Kernel
14
- alias_method :require_without_cache, :require
15
- def require(path)
16
- if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
17
- require_without_cache(resolved)
18
- else
19
- raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
20
- end
21
- rescue Bootsnap::LoadPathCache::ReturnFalse
22
- return false
23
- rescue Bootsnap::LoadPathCache::FallbackScan
24
- require_without_cache(path)
25
- end
16
+ module_function # rubocop:disable Style/ModuleFunction
26
17
 
27
- alias_method :load_without_cache, :load
28
- def load(path, wrap = false)
29
- if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
30
- load_without_cache(resolved, wrap)
31
- else
32
- # load also allows relative paths from pwd even when not in $:
33
- relative = File.expand_path(path)
34
- if File.exist?(File.expand_path(path))
35
- return load_without_cache(relative, wrap)
36
- end
37
- raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
18
+ alias_method(:require_without_bootsnap, :require)
19
+
20
+ # Note that require registers to $LOADED_FEATURES while load does not.
21
+ def require_with_bootsnap_lfi(path, resolved = nil)
22
+ Bootsnap::LoadPathCache.loaded_features_index.register(path, resolved) do
23
+ require_without_bootsnap(resolved || path)
38
24
  end
39
- rescue Bootsnap::LoadPathCache::ReturnFalse
40
- return false
41
- rescue Bootsnap::LoadPathCache::FallbackScan
42
- load_without_cache(path, wrap)
43
25
  end
44
- end
45
26
 
46
- class << Kernel
47
- alias_method :require_without_cache, :require
48
27
  def require(path)
49
- if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
50
- require_without_cache(resolved)
51
- else
52
- raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
28
+ return false if Bootsnap::LoadPathCache.loaded_features_index.key?(path)
29
+
30
+ if (resolved = Bootsnap::LoadPathCache.load_path_cache.find(path))
31
+ return require_with_bootsnap_lfi(path, resolved)
53
32
  end
33
+
34
+ raise(Bootsnap::LoadPathCache::CoreExt.make_load_error(path))
35
+ rescue LoadError => e
36
+ e.instance_variable_set(Bootsnap::LoadPathCache::ERROR_TAG_IVAR, true)
37
+ raise(e)
54
38
  rescue Bootsnap::LoadPathCache::ReturnFalse
55
- return false
39
+ false
56
40
  rescue Bootsnap::LoadPathCache::FallbackScan
57
- require_without_cache(path)
41
+ fallback = true
42
+ ensure
43
+ if fallback
44
+ require_with_bootsnap_lfi(path)
45
+ end
58
46
  end
59
47
 
60
- alias_method :load_without_cache, :load
48
+ alias_method(:require_relative_without_bootsnap, :require_relative)
49
+ def require_relative(path)
50
+ realpath = Bootsnap::LoadPathCache.realpath_cache.call(
51
+ caller_locations(1..1).first.absolute_path, path
52
+ )
53
+ require(realpath)
54
+ end
55
+
56
+ alias_method(:load_without_bootsnap, :load)
61
57
  def load(path, wrap = false)
62
- if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
63
- load_without_cache(resolved, wrap)
64
- else
65
- # load also allows relative paths from pwd even when not in $:
66
- relative = File.expand_path(path)
67
- if File.exist?(relative)
68
- return load_without_cache(relative, wrap)
69
- end
70
- raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
58
+ if (resolved = Bootsnap::LoadPathCache.load_path_cache.find(path))
59
+ return load_without_bootsnap(resolved, wrap)
71
60
  end
61
+
62
+ # load also allows relative paths from pwd even when not in $:
63
+ if File.exist?(relative = File.expand_path(path).freeze)
64
+ return load_without_bootsnap(relative, wrap)
65
+ end
66
+
67
+ raise(Bootsnap::LoadPathCache::CoreExt.make_load_error(path))
68
+ rescue LoadError => e
69
+ e.instance_variable_set(Bootsnap::LoadPathCache::ERROR_TAG_IVAR, true)
70
+ raise(e)
72
71
  rescue Bootsnap::LoadPathCache::ReturnFalse
73
- return false
72
+ false
74
73
  rescue Bootsnap::LoadPathCache::FallbackScan
75
- load_without_cache(path, wrap)
74
+ fallback = true
75
+ ensure
76
+ if fallback
77
+ load_without_bootsnap(path, wrap)
78
+ end
76
79
  end
77
80
  end
78
81
 
79
82
  class Module
80
- alias_method :autoload_without_cache, :autoload
83
+ alias_method(:autoload_without_bootsnap, :autoload)
81
84
  def autoload(const, path)
82
- autoload_without_cache(const, Bootsnap::LoadPathCache.load_path_cache.find(path) || path)
85
+ # NOTE: This may defeat LoadedFeaturesIndex, but it's not immediately
86
+ # obvious how to make it work. This feels like a pretty niche case, unclear
87
+ # if it will ever burn anyone.
88
+ #
89
+ # The challenge is that we don't control the point at which the entry gets
90
+ # added to $LOADED_FEATURES and won't be able to hook that modification
91
+ # since it's done in C-land.
92
+ autoload_without_bootsnap(const, Bootsnap::LoadPathCache.load_path_cache.find(path) || path)
93
+ rescue LoadError => e
94
+ e.instance_variable_set(Bootsnap::LoadPathCache::ERROR_TAG_IVAR, true)
95
+ raise(e)
83
96
  rescue Bootsnap::LoadPathCache::ReturnFalse
84
- return false
97
+ false
85
98
  rescue Bootsnap::LoadPathCache::FallbackScan
86
- autoload_without_cache(const, path)
99
+ fallback = true
100
+ ensure
101
+ if fallback
102
+ autoload_without_bootsnap(const, path)
103
+ end
87
104
  end
88
105
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ class << $LOADED_FEATURES
3
+ alias_method(:delete_without_bootsnap, :delete)
4
+ def delete(key)
5
+ Bootsnap::LoadPathCache.loaded_features_index.purge(key)
6
+ delete_without_bootsnap(key)
7
+ end
8
+
9
+ alias_method(:reject_without_bootsnap!, :reject!)
10
+ def reject!(&block)
11
+ backup = dup
12
+
13
+ # FIXME: if no block is passed we'd need to return a decorated iterator
14
+ reject_without_bootsnap!(&block)
15
+
16
+ Bootsnap::LoadPathCache.loaded_features_index.purge_multi(backup - self)
17
+ end
18
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootsnap
4
+ module LoadPathCache
5
+ # LoadedFeaturesIndex partially mirrors an internal structure in ruby that
6
+ # we can't easily obtain an interface to.
7
+ #
8
+ # This works around an issue where, without bootsnap, *ruby* knows that it
9
+ # has already required a file by its short name (e.g. require 'bundler') if
10
+ # a new instance of bundler is added to the $LOAD_PATH which resolves to a
11
+ # different absolute path. This class makes bootsnap smart enough to
12
+ # realize that it has already loaded 'bundler', and not just
13
+ # '/path/to/bundler'.
14
+ #
15
+ # If you disable LoadedFeaturesIndex, you can see the problem this solves by:
16
+ #
17
+ # 1. `require 'a'`
18
+ # 2. Prepend a new $LOAD_PATH element containing an `a.rb`
19
+ # 3. `require 'a'`
20
+ #
21
+ # Ruby returns false from step 3.
22
+ # With bootsnap but with no LoadedFeaturesIndex, this loads two different
23
+ # `a.rb`s.
24
+ # With bootsnap and with LoadedFeaturesIndex, this skips the second load,
25
+ # returning false like ruby.
26
+ class LoadedFeaturesIndex
27
+ def initialize
28
+ @lfi = {}
29
+ @mutex = Mutex.new
30
+
31
+ # In theory the user could mutate $LOADED_FEATURES and invalidate our
32
+ # cache. If this ever comes up in practice — or if you, the
33
+ # enterprising reader, feels inclined to solve this problem — we could
34
+ # parallel the work done with ChangeObserver on $LOAD_PATH to mirror
35
+ # updates to our @lfi.
36
+ $LOADED_FEATURES.each do |feat|
37
+ hash = feat.hash
38
+ $LOAD_PATH.each do |lpe|
39
+ next unless feat.start_with?(lpe)
40
+ # /a/b/lib/my/foo.rb
41
+ # ^^^^^^^^^
42
+ short = feat[(lpe.length + 1)..-1]
43
+ stripped = strip_extension_if_elidable(short)
44
+ @lfi[short] = hash
45
+ @lfi[stripped] = hash
46
+ end
47
+ end
48
+ end
49
+
50
+ # We've optimized for initialize and register to be fast, and purge to be tolerable.
51
+ # If access patterns make this not-okay, we can lazy-invert the LFI on
52
+ # first purge and work from there.
53
+ def purge(feature)
54
+ @mutex.synchronize do
55
+ feat_hash = feature.hash
56
+ @lfi.reject! { |_, hash| hash == feat_hash }
57
+ end
58
+ end
59
+
60
+ def purge_multi(features)
61
+ rejected_hashes = features.map(&:hash).to_set
62
+ @mutex.synchronize do
63
+ @lfi.reject! { |_, hash| rejected_hashes.include?(hash) }
64
+ end
65
+ end
66
+
67
+ def key?(feature)
68
+ @mutex.synchronize { @lfi.key?(feature) }
69
+ end
70
+
71
+ # There is a relatively uncommon case where we could miss adding an
72
+ # entry:
73
+ #
74
+ # If the user asked for e.g. `require 'bundler'`, and we went through the
75
+ # `FallbackScan` pathway in `kernel_require.rb` and therefore did not
76
+ # pass `long` (the full expanded absolute path), then we did are not able
77
+ # to confidently add the `bundler.rb` form to @lfi.
78
+ #
79
+ # We could either:
80
+ #
81
+ # 1. Just add `bundler.rb`, `bundler.so`, and so on, which is close but
82
+ # not quite right; or
83
+ # 2. Inspect $LOADED_FEATURES upon return from yield to find the matching
84
+ # entry.
85
+ def register(short, long = nil)
86
+ if long.nil?
87
+ pat = %r{/#{Regexp.escape(short)}(\.[^/]+)?$}
88
+ len = $LOADED_FEATURES.size
89
+ ret = yield
90
+ long = $LOADED_FEATURES[len..-1].detect { |feat| feat =~ pat }
91
+ else
92
+ ret = yield
93
+ end
94
+
95
+ hash = long.hash
96
+
97
+ # Do we have a filename with an elidable extension, e.g.,
98
+ # 'bundler.rb', or 'libgit2.so'?
99
+ altname = if extension_elidable?(short)
100
+ # Strip the extension off, e.g. 'bundler.rb' -> 'bundler'.
101
+ strip_extension_if_elidable(short)
102
+ elsif long && (ext = File.extname(long.freeze))
103
+ # We already know the extension of the actual file this
104
+ # resolves to, so put that back on.
105
+ short + ext
106
+ end
107
+
108
+ @mutex.synchronize do
109
+ @lfi[short] = hash
110
+ (@lfi[altname] = hash) if altname
111
+ end
112
+
113
+ ret
114
+ end
115
+
116
+ private
117
+
118
+ STRIP_EXTENSION = /\.[^.]*?$/
119
+ private_constant(:STRIP_EXTENSION)
120
+
121
+ # Might Ruby automatically search for this extension if
122
+ # someone tries to 'require' the file without it? E.g. Ruby
123
+ # will implicitly try 'x.rb' if you ask for 'x'.
124
+ #
125
+ # This is complex and platform-dependent, and the Ruby docs are a little
126
+ # handwavy about what will be tried when and in what order.
127
+ # So optimistically pretend that all known elidable extensions
128
+ # will be tried on all platforms, and that people are unlikely
129
+ # to name files in a way that assumes otherwise.
130
+ # (E.g. It's unlikely that someone will know that their code
131
+ # will _never_ run on MacOS, and therefore think they can get away
132
+ # with calling a Ruby file 'x.dylib.rb' and then requiring it as 'x.dylib'.)
133
+ #
134
+ # See <https://ruby-doc.org/core-2.6.4/Kernel.html#method-i-require>.
135
+ def extension_elidable?(f)
136
+ f.to_s.end_with?('.rb', '.so', '.o', '.dll', '.dylib')
137
+ end
138
+
139
+ def strip_extension_if_elidable(f)
140
+ if extension_elidable?(f)
141
+ f.sub(STRIP_EXTENSION, '')
142
+ else
143
+ f
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -1,4 +1,5 @@
1
- require_relative 'path_scanner'
1
+ # frozen_string_literal: true
2
+ require_relative('path_scanner')
2
3
 
3
4
  module Bootsnap
4
5
  module LoadPathCache
@@ -17,16 +18,16 @@ module Bootsnap
17
18
  stability == VOLATILE
18
19
  end
19
20
 
20
- attr_reader :path
21
+ attr_reader(:path)
21
22
 
22
23
  def initialize(path)
23
- @path = path.to_s
24
+ @path = path.to_s.freeze
24
25
  end
25
26
 
26
27
  # True if the path exists, but represents a non-directory object
27
28
  def non_directory?
28
29
  !File.stat(path).directory?
29
- rescue Errno::ENOENT
30
+ rescue Errno::ENOENT, Errno::ENOTDIR
30
31
  false
31
32
  end
32
33
 
@@ -59,7 +60,7 @@ module Bootsnap
59
60
  end
60
61
 
61
62
  def expanded_path
62
- File.expand_path(path)
63
+ File.expand_path(path).freeze
63
64
  end
64
65
 
65
66
  private
@@ -76,8 +77,8 @@ module Bootsnap
76
77
  ["", *dirs].each do |dir|
77
78
  curr = begin
78
79
  File.mtime("#{path}/#{dir}").to_i
79
- rescue Errno::ENOENT
80
- -1
80
+ rescue Errno::ENOENT, Errno::ENOTDIR
81
+ -1
81
82
  end
82
83
  max = curr if curr > max
83
84
  end