bootsnap 1.4.8 → 1.16.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require("mkmf")
3
- $CFLAGS << ' -O3 '
4
- $CFLAGS << ' -std=c99'
5
4
 
6
- # ruby.h has some -Wpedantic fails in some cases
7
- # (e.g. https://github.com/Shopify/bootsnap/issues/15)
8
- unless ['0', '', nil].include?(ENV['BOOTSNAP_PEDANTIC'])
9
- $CFLAGS << ' -Wall'
10
- $CFLAGS << ' -Werror'
11
- $CFLAGS << ' -Wextra'
12
- $CFLAGS << ' -Wpedantic'
5
+ if RUBY_ENGINE == "ruby"
6
+ $CFLAGS << " -O3 "
7
+ $CFLAGS << " -std=c99"
13
8
 
14
- $CFLAGS << ' -Wno-unused-parameter' # VALUE self has to be there but we don't care what it is.
15
- $CFLAGS << ' -Wno-keyword-macro' # hiding return
16
- $CFLAGS << ' -Wno-gcc-compat' # ruby.h 2.6.0 on macos 10.14, dunno
17
- end
9
+ # ruby.h has some -Wpedantic fails in some cases
10
+ # (e.g. https://github.com/Shopify/bootsnap/issues/15)
11
+ unless ["0", "", nil].include?(ENV["BOOTSNAP_PEDANTIC"])
12
+ $CFLAGS << " -Wall"
13
+ $CFLAGS << " -Werror"
14
+ $CFLAGS << " -Wextra"
15
+ $CFLAGS << " -Wpedantic"
18
16
 
19
- create_makefile("bootsnap/bootsnap")
17
+ $CFLAGS << " -Wno-unused-parameter" # VALUE self has to be there but we don't care what it is.
18
+ $CFLAGS << " -Wno-keyword-macro" # hiding return
19
+ $CFLAGS << " -Wno-gcc-compat" # ruby.h 2.6.0 on macos 10.14, dunno
20
+ $CFLAGS << " -Wno-compound-token-split-by-macro"
21
+ end
22
+
23
+ create_makefile("bootsnap/bootsnap")
24
+ else
25
+ File.write("Makefile", dummy_makefile($srcdir).join)
26
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bootsnap
3
4
  extend(self)
4
5
 
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootsnap
4
+ class CLI
5
+ class WorkerPool
6
+ class << self
7
+ def create(size:, jobs:)
8
+ if size > 0 && Process.respond_to?(:fork)
9
+ new(size: size, jobs: jobs)
10
+ else
11
+ Inline.new(jobs: jobs)
12
+ end
13
+ end
14
+ end
15
+
16
+ class Inline
17
+ def initialize(jobs: {})
18
+ @jobs = jobs
19
+ end
20
+
21
+ def push(job, *args)
22
+ @jobs.fetch(job).call(*args)
23
+ nil
24
+ end
25
+
26
+ def spawn
27
+ # noop
28
+ end
29
+
30
+ def shutdown
31
+ # noop
32
+ end
33
+ end
34
+
35
+ class Worker
36
+ attr_reader :to_io, :pid
37
+
38
+ def initialize(jobs)
39
+ @jobs = jobs
40
+ @pipe_out, @to_io = IO.pipe(binmode: true)
41
+ # Set the writer encoding to binary since IO.pipe only sets it for the reader.
42
+ # https://github.com/rails/rails/issues/16514#issuecomment-52313290
43
+ @to_io.set_encoding(Encoding::BINARY)
44
+
45
+ @pid = nil
46
+ end
47
+
48
+ def write(message, block: true)
49
+ payload = Marshal.dump(message)
50
+ if block
51
+ to_io.write(payload)
52
+ true
53
+ else
54
+ to_io.write_nonblock(payload, exception: false) != :wait_writable
55
+ end
56
+ end
57
+
58
+ def close
59
+ to_io.close
60
+ end
61
+
62
+ def work_loop
63
+ loop do
64
+ job, *args = Marshal.load(@pipe_out)
65
+ return if job == :exit
66
+
67
+ @jobs.fetch(job).call(*args)
68
+ end
69
+ rescue IOError
70
+ nil
71
+ end
72
+
73
+ def spawn
74
+ @pid = Process.fork do
75
+ to_io.close
76
+ work_loop
77
+ exit!(0)
78
+ end
79
+ @pipe_out.close
80
+ true
81
+ end
82
+ end
83
+
84
+ def initialize(size:, jobs: {})
85
+ @size = size
86
+ @jobs = jobs
87
+ @queue = Queue.new
88
+ @pids = []
89
+ end
90
+
91
+ def spawn
92
+ @workers = @size.times.map { Worker.new(@jobs) }
93
+ @workers.each(&:spawn)
94
+ @dispatcher_thread = Thread.new { dispatch_loop }
95
+ @dispatcher_thread.abort_on_exception = true
96
+ true
97
+ end
98
+
99
+ def dispatch_loop
100
+ loop do
101
+ case job = @queue.pop
102
+ when nil
103
+ @workers.each do |worker|
104
+ worker.write([:exit])
105
+ worker.close
106
+ end
107
+ return true
108
+ else
109
+ unless @workers.sample.write(job, block: false)
110
+ free_worker.write(job)
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ def free_worker
117
+ IO.select(nil, @workers)[1].sample
118
+ end
119
+
120
+ def push(*args)
121
+ @queue.push(args)
122
+ nil
123
+ end
124
+
125
+ def shutdown
126
+ @queue.close
127
+ @dispatcher_thread.join
128
+ @workers.each do |worker|
129
+ _pid, status = Process.wait2(worker.pid)
130
+ return status.exitstatus unless status.success?
131
+ end
132
+ nil
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bootsnap"
4
+ require "bootsnap/cli/worker_pool"
5
+ require "optparse"
6
+ require "fileutils"
7
+ require "etc"
8
+
9
+ module Bootsnap
10
+ class CLI
11
+ unless Regexp.method_defined?(:match?)
12
+ module RegexpMatchBackport
13
+ refine Regexp do
14
+ def match?(string)
15
+ !!match(string)
16
+ end
17
+ end
18
+ end
19
+ using RegexpMatchBackport
20
+ end
21
+
22
+ attr_reader :cache_dir, :argv
23
+
24
+ attr_accessor :compile_gemfile, :exclude, :verbose, :iseq, :yaml, :json, :jobs
25
+
26
+ def initialize(argv)
27
+ @argv = argv
28
+ self.cache_dir = ENV.fetch("BOOTSNAP_CACHE_DIR", "tmp/cache")
29
+ self.compile_gemfile = false
30
+ self.exclude = nil
31
+ self.verbose = false
32
+ self.jobs = Etc.nprocessors
33
+ self.iseq = true
34
+ self.yaml = true
35
+ self.json = true
36
+ end
37
+
38
+ def precompile_command(*sources)
39
+ require "bootsnap/compile_cache/iseq"
40
+ require "bootsnap/compile_cache/yaml"
41
+ require "bootsnap/compile_cache/json"
42
+
43
+ fix_default_encoding do
44
+ Bootsnap::CompileCache::ISeq.cache_dir = cache_dir
45
+ Bootsnap::CompileCache::YAML.init!
46
+ Bootsnap::CompileCache::YAML.cache_dir = cache_dir
47
+ Bootsnap::CompileCache::JSON.init!
48
+ Bootsnap::CompileCache::JSON.cache_dir = cache_dir
49
+
50
+ @work_pool = WorkerPool.create(size: jobs, jobs: {
51
+ ruby: method(:precompile_ruby),
52
+ yaml: method(:precompile_yaml),
53
+ json: method(:precompile_json),
54
+ })
55
+ @work_pool.spawn
56
+
57
+ main_sources = sources.map { |d| File.expand_path(d) }
58
+ precompile_ruby_files(main_sources)
59
+ precompile_yaml_files(main_sources)
60
+ precompile_json_files(main_sources)
61
+
62
+ if compile_gemfile
63
+ # Some gems embed their tests, they're very unlikely to be loaded, so not worth precompiling.
64
+ gem_exclude = Regexp.union([exclude, "/spec/", "/test/"].compact)
65
+ precompile_ruby_files($LOAD_PATH.map { |d| File.expand_path(d) }, exclude: gem_exclude)
66
+
67
+ # Gems that include JSON or YAML files usually don't put them in `lib/`.
68
+ # So we look at the gem root.
69
+ gem_pattern = %r{^#{Regexp.escape(Bundler.bundle_path.to_s)}/?(?:bundler/)?gems/[^/]+}
70
+ gem_paths = $LOAD_PATH.map { |p| p[gem_pattern] }.compact.uniq
71
+ precompile_yaml_files(gem_paths, exclude: gem_exclude)
72
+ precompile_json_files(gem_paths, exclude: gem_exclude)
73
+ end
74
+
75
+ if (exitstatus = @work_pool.shutdown)
76
+ exit(exitstatus)
77
+ end
78
+ end
79
+ 0
80
+ end
81
+
82
+ dir_sort = begin
83
+ Dir[__FILE__, sort: false]
84
+ true
85
+ rescue ArgumentError, TypeError
86
+ false
87
+ end
88
+
89
+ if dir_sort
90
+ def list_files(path, pattern)
91
+ if File.directory?(path)
92
+ Dir[File.join(path, pattern), sort: false]
93
+ elsif File.exist?(path)
94
+ [path]
95
+ else
96
+ []
97
+ end
98
+ end
99
+ else
100
+ def list_files(path, pattern)
101
+ if File.directory?(path)
102
+ Dir[File.join(path, pattern)]
103
+ elsif File.exist?(path)
104
+ [path]
105
+ else
106
+ []
107
+ end
108
+ end
109
+ end
110
+
111
+ def run
112
+ parser.parse!(argv)
113
+ command = argv.shift
114
+ method = "#{command}_command"
115
+ if respond_to?(method)
116
+ public_send(method, *argv)
117
+ else
118
+ invalid_usage!("Unknown command: #{command}")
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def precompile_yaml_files(load_paths, exclude: self.exclude)
125
+ return unless yaml
126
+
127
+ load_paths.each do |path|
128
+ if !exclude || !exclude.match?(path)
129
+ list_files(path, "**/*.{yml,yaml}").each do |yaml_file|
130
+ # We ignore hidden files to not match the various .ci.yml files
131
+ if !File.basename(yaml_file).start_with?(".") && (!exclude || !exclude.match?(yaml_file))
132
+ @work_pool.push(:yaml, yaml_file)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ def precompile_yaml(*yaml_files)
140
+ Array(yaml_files).each do |yaml_file|
141
+ if CompileCache::YAML.precompile(yaml_file) && verbose
142
+ $stderr.puts(yaml_file)
143
+ end
144
+ end
145
+ end
146
+
147
+ def precompile_json_files(load_paths, exclude: self.exclude)
148
+ return unless json
149
+
150
+ load_paths.each do |path|
151
+ if !exclude || !exclude.match?(path)
152
+ list_files(path, "**/*.json").each do |json_file|
153
+ # We ignore hidden files to not match the various .config.json files
154
+ if !File.basename(json_file).start_with?(".") && (!exclude || !exclude.match?(json_file))
155
+ @work_pool.push(:json, json_file)
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ def precompile_json(*json_files)
163
+ Array(json_files).each do |json_file|
164
+ if CompileCache::JSON.precompile(json_file) && verbose
165
+ $stderr.puts(json_file)
166
+ end
167
+ end
168
+ end
169
+
170
+ def precompile_ruby_files(load_paths, exclude: self.exclude)
171
+ return unless iseq
172
+
173
+ load_paths.each do |path|
174
+ if !exclude || !exclude.match?(path)
175
+ list_files(path, "**/{*.rb,*.rake,Rakefile}").each do |ruby_file|
176
+ if !exclude || !exclude.match?(ruby_file)
177
+ @work_pool.push(:ruby, ruby_file)
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+
184
+ def precompile_ruby(*ruby_files)
185
+ Array(ruby_files).each do |ruby_file|
186
+ if CompileCache::ISeq.precompile(ruby_file) && verbose
187
+ $stderr.puts(ruby_file)
188
+ end
189
+ end
190
+ end
191
+
192
+ def fix_default_encoding
193
+ if Encoding.default_external == Encoding::US_ASCII
194
+ Encoding.default_external = Encoding::UTF_8
195
+ begin
196
+ yield
197
+ ensure
198
+ Encoding.default_external = Encoding::US_ASCII
199
+ end
200
+ else
201
+ yield
202
+ end
203
+ end
204
+
205
+ def invalid_usage!(message)
206
+ $stderr.puts message
207
+ $stderr.puts
208
+ $stderr.puts parser
209
+ 1
210
+ end
211
+
212
+ def cache_dir=(dir)
213
+ @cache_dir = File.expand_path(File.join(dir, "bootsnap/compile-cache"))
214
+ end
215
+
216
+ def exclude_pattern(pattern)
217
+ (@exclude_patterns ||= []) << Regexp.new(pattern)
218
+ self.exclude = Regexp.union(@exclude_patterns)
219
+ end
220
+
221
+ def parser
222
+ @parser ||= OptionParser.new do |opts|
223
+ opts.banner = "Usage: bootsnap COMMAND [ARGS]"
224
+ opts.separator ""
225
+ opts.separator "GLOBAL OPTIONS"
226
+ opts.separator ""
227
+
228
+ help = <<~HELP
229
+ Path to the bootsnap cache directory. Defaults to tmp/cache
230
+ HELP
231
+ opts.on("--cache-dir DIR", help.strip) do |dir|
232
+ self.cache_dir = dir
233
+ end
234
+
235
+ help = <<~HELP
236
+ Print precompiled paths.
237
+ HELP
238
+ opts.on("--verbose", "-v", help.strip) do
239
+ self.verbose = true
240
+ end
241
+
242
+ help = <<~HELP
243
+ Number of workers to use. Default to number of processors, set to 0 to disable multi-processing.
244
+ HELP
245
+ opts.on("--jobs JOBS", "-j", help.strip) do |jobs|
246
+ self.jobs = Integer(jobs)
247
+ end
248
+
249
+ opts.separator ""
250
+ opts.separator "COMMANDS"
251
+ opts.separator ""
252
+ opts.separator " precompile [DIRECTORIES...]: Precompile all .rb files in the passed directories"
253
+
254
+ help = <<~HELP
255
+ Precompile the gems in Gemfile
256
+ HELP
257
+ opts.on("--gemfile", help) { self.compile_gemfile = true }
258
+
259
+ help = <<~HELP
260
+ Path pattern to not precompile. e.g. --exclude 'aws-sdk|google-api'
261
+ HELP
262
+ opts.on("--exclude PATTERN", help) { |pattern| exclude_pattern(pattern) }
263
+
264
+ help = <<~HELP
265
+ Disable ISeq (.rb) precompilation.
266
+ HELP
267
+ opts.on("--no-iseq", help) { self.iseq = false }
268
+
269
+ help = <<~HELP
270
+ Disable YAML precompilation.
271
+ HELP
272
+ opts.on("--no-yaml", help) { self.yaml = false }
273
+
274
+ help = <<~HELP
275
+ Disable JSON precompilation.
276
+ HELP
277
+ opts.on("--no-json", help) { self.json = false }
278
+ end
279
+ end
280
+ end
281
+ end
@@ -1,32 +1,79 @@
1
1
  # frozen_string_literal: true
2
- require('bootsnap/bootsnap')
3
- require('zlib')
2
+
3
+ require("bootsnap/bootsnap")
4
+ require("zlib")
4
5
 
5
6
  module Bootsnap
6
7
  module CompileCache
7
8
  module ISeq
8
9
  class << self
9
- attr_accessor(:cache_dir)
10
+ attr_reader(:cache_dir)
11
+
12
+ def cache_dir=(cache_dir)
13
+ @cache_dir = cache_dir.end_with?("/") ? "#{cache_dir}iseq" : "#{cache_dir}-iseq"
14
+ end
10
15
  end
11
16
 
12
- def self.input_to_storage(_, path)
13
- RubyVM::InstructionSequence.compile_file(path).to_binary
14
- rescue SyntaxError
15
- raise(Uncompilable, 'syntax error')
17
+ has_ruby_bug_18250 = begin # https://bugs.ruby-lang.org/issues/18250
18
+ if defined? RubyVM::InstructionSequence
19
+ RubyVM::InstructionSequence.compile("def foo(*); ->{ super }; end; def foo(**); ->{ super }; end").to_binary
20
+ end
21
+ false
22
+ rescue TypeError
23
+ true
16
24
  end
17
25
 
18
- def self.storage_to_output(binary)
26
+ if has_ruby_bug_18250
27
+ def self.input_to_storage(_, path)
28
+ iseq = begin
29
+ RubyVM::InstructionSequence.compile_file(path)
30
+ rescue SyntaxError
31
+ return UNCOMPILABLE # syntax error
32
+ end
33
+
34
+ begin
35
+ iseq.to_binary
36
+ rescue TypeError
37
+ UNCOMPILABLE # ruby bug #18250
38
+ end
39
+ end
40
+ else
41
+ def self.input_to_storage(_, path)
42
+ RubyVM::InstructionSequence.compile_file(path).to_binary
43
+ rescue SyntaxError
44
+ UNCOMPILABLE # syntax error
45
+ end
46
+ end
47
+
48
+ def self.storage_to_output(binary, _args)
19
49
  RubyVM::InstructionSequence.load_from_binary(binary)
20
- rescue RuntimeError => e
21
- if e.message == 'broken binary format'
22
- STDERR.puts("[Bootsnap::CompileCache] warning: rejecting broken binary")
50
+ rescue RuntimeError => error
51
+ if error.message == "broken binary format"
52
+ $stderr.puts("[Bootsnap::CompileCache] warning: rejecting broken binary")
23
53
  nil
24
54
  else
25
55
  raise
26
56
  end
27
57
  end
28
58
 
29
- def self.input_to_output(_)
59
+ def self.fetch(path, cache_dir: ISeq.cache_dir)
60
+ Bootsnap::CompileCache::Native.fetch(
61
+ cache_dir,
62
+ path.to_s,
63
+ Bootsnap::CompileCache::ISeq,
64
+ nil,
65
+ )
66
+ end
67
+
68
+ def self.precompile(path)
69
+ Bootsnap::CompileCache::Native.precompile(
70
+ cache_dir,
71
+ path.to_s,
72
+ Bootsnap::CompileCache::ISeq,
73
+ )
74
+ end
75
+
76
+ def self.input_to_output(_data, _kwargs)
30
77
  nil # ruby handles this
31
78
  end
32
79
 
@@ -35,15 +82,11 @@ module Bootsnap
35
82
  # Having coverage enabled prevents iseq dumping/loading.
36
83
  return nil if defined?(Coverage) && Bootsnap::CompileCache::Native.coverage_running?
37
84
 
38
- Bootsnap::CompileCache::Native.fetch(
39
- Bootsnap::CompileCache::ISeq.cache_dir,
40
- path.to_s,
41
- Bootsnap::CompileCache::ISeq
42
- )
85
+ Bootsnap::CompileCache::ISeq.fetch(path.to_s)
43
86
  rescue Errno::EACCES
44
87
  Bootsnap::CompileCache.permission_error(path)
45
- rescue RuntimeError => e
46
- if e.message =~ /unmatched platform/
88
+ rescue RuntimeError => error
89
+ if error.message =~ /unmatched platform/
47
90
  puts("unmatched platform for file #{path}")
48
91
  end
49
92
  raise
@@ -60,6 +103,7 @@ module Bootsnap
60
103
  crc = Zlib.crc32(option.inspect)
61
104
  Bootsnap::CompileCache::Native.compile_option_crc32 = crc
62
105
  end
106
+ compile_option_updated
63
107
 
64
108
  def self.install!(cache_dir)
65
109
  Bootsnap::CompileCache::ISeq.cache_dir = cache_dir
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("bootsnap/bootsnap")
4
+
5
+ module Bootsnap
6
+ module CompileCache
7
+ module JSON
8
+ class << self
9
+ attr_accessor(:msgpack_factory, :supported_options)
10
+ attr_reader(:cache_dir)
11
+
12
+ def cache_dir=(cache_dir)
13
+ @cache_dir = cache_dir.end_with?("/") ? "#{cache_dir}json" : "#{cache_dir}-json"
14
+ end
15
+
16
+ def input_to_storage(payload, _)
17
+ obj = ::JSON.parse(payload)
18
+ msgpack_factory.dump(obj)
19
+ end
20
+
21
+ def storage_to_output(data, kwargs)
22
+ if kwargs&.key?(:symbolize_names)
23
+ kwargs[:symbolize_keys] = kwargs.delete(:symbolize_names)
24
+ end
25
+ msgpack_factory.load(data, kwargs)
26
+ end
27
+
28
+ def input_to_output(data, kwargs)
29
+ ::JSON.parse(data, **(kwargs || {}))
30
+ end
31
+
32
+ def precompile(path)
33
+ Bootsnap::CompileCache::Native.precompile(
34
+ cache_dir,
35
+ path.to_s,
36
+ self,
37
+ )
38
+ end
39
+
40
+ def install!(cache_dir)
41
+ self.cache_dir = cache_dir
42
+ init!
43
+ if ::JSON.respond_to?(:load_file)
44
+ ::JSON.singleton_class.prepend(Patch)
45
+ end
46
+ end
47
+
48
+ def init!
49
+ require("json")
50
+ require("msgpack")
51
+
52
+ self.msgpack_factory = MessagePack::Factory.new
53
+ self.supported_options = [:symbolize_names]
54
+ if supports_freeze?
55
+ self.supported_options = [:freeze]
56
+ end
57
+ supported_options.freeze
58
+ end
59
+
60
+ private
61
+
62
+ def supports_freeze?
63
+ ::JSON.parse('["foo"]', freeze: true).first.frozen? &&
64
+ MessagePack.load(MessagePack.dump("foo"), freeze: true).frozen?
65
+ end
66
+ end
67
+
68
+ module Patch
69
+ def load_file(path, *args)
70
+ return super if args.size > 1
71
+
72
+ if (kwargs = args.first)
73
+ return super unless kwargs.is_a?(Hash)
74
+ return super unless (kwargs.keys - ::Bootsnap::CompileCache::JSON.supported_options).empty?
75
+ end
76
+
77
+ begin
78
+ ::Bootsnap::CompileCache::Native.fetch(
79
+ Bootsnap::CompileCache::JSON.cache_dir,
80
+ File.realpath(path),
81
+ ::Bootsnap::CompileCache::JSON,
82
+ kwargs,
83
+ )
84
+ rescue Errno::EACCES
85
+ ::Bootsnap::CompileCache.permission_error(path)
86
+ end
87
+ end
88
+
89
+ ruby2_keywords :load_file if respond_to?(:ruby2_keywords, true)
90
+ end
91
+ end
92
+ end
93
+ end