gemfile-audit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,637 @@
1
+ # Measures one Ruby process of the test run: line coverage of the project's
2
+ # own files, ActiveSupport deprecations, RSpec or Minitest counts and the
3
+ # database server version. At exit it writes one JSON file into the audit
4
+ # folder. Loaded by loader.rb only when the audit folder and root are set.
5
+ #
6
+ # Rules for this file, because it runs inside other people's processes:
7
+ # standard library only, Ruby 2.3 syntax and APIs, never print, never raise
8
+ # into the app and never change the app's behaviour, warnings or exit status.
9
+ module GemfileAudit
10
+ module Measurement
11
+ COVERAGE_EXCLUDED = %w(vendor/ node_modules/ tmp/ spec/ test/ features/ db/).freeze
12
+ LOCATION_EXCLUDED = %w(vendor/ node_modules/ tmp/).freeze
13
+ MAX_DEPRECATIONS = 5000
14
+ MAX_FRAMES = 200
15
+ MODULE_NAME = Module.instance_method(:name)
16
+ JSON_ESCAPES = {
17
+ "\"" => "\\\"", "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f",
18
+ "\n" => "\\n", "\r" => "\\r", "\t" => "\\t"
19
+ }.freeze
20
+
21
+ # Prepended to the Coverage singleton. Keeps a copy of every result the
22
+ # app takes, so coverage survives tools such as SimpleCov stopping it,
23
+ # and hands coverage over when the app starts its own measurement.
24
+ module CoverageHooks
25
+ def start(*args)
26
+ GemfileAudit::Measurement.release_coverage
27
+ super
28
+ end
29
+
30
+ def result(*args)
31
+ value = super
32
+ GemfileAudit::Measurement.keep_coverage(value, args)
33
+ value
34
+ end
35
+
36
+ ruby2_keywords(:start, :result) if respond_to?(:ruby2_keywords, true)
37
+ end
38
+
39
+ # Coverage.setup exists from Ruby 3.1 only.
40
+ module CoverageSetupHook
41
+ def setup(*args)
42
+ GemfileAudit::Measurement.release_coverage
43
+ super
44
+ end
45
+
46
+ ruby2_keywords(:setup) if respond_to?(:ruby2_keywords, true)
47
+ end
48
+
49
+ # Prepended to the SimpleCov singleton. SimpleCov 0.22 starts coverage
50
+ # only when it is not running, so hand our measurement over first and
51
+ # SimpleCov starts its own with the options it wants, such as branches.
52
+ # Coverage.running? itself always tells the truth: bootsnap relies on it.
53
+ module SimpleCovHooks
54
+ def start_coverage_measurement(*args)
55
+ GemfileAudit::Measurement.release_coverage
56
+ super
57
+ end
58
+ private :start_coverage_measurement
59
+ end
60
+
61
+ # Prepended to ActiveSupport::Deprecation. Records the warning, then
62
+ # always calls the original with the call stack Rails itself would have
63
+ # used, so messages and the :raise behaviour stay exactly the same.
64
+ module DeprecationHooks
65
+ def warn(message = nil, callstack = nil, *rest)
66
+ begin
67
+ if callstack.nil?
68
+ callstack = GemfileAudit::Measurement.string_callstack? ? caller(2) : caller_locations(2)
69
+ end
70
+ GemfileAudit::Measurement.record_deprecation(message, caller_locations(1))
71
+ rescue StandardError
72
+ nil
73
+ end
74
+ super(message, callstack, *rest)
75
+ end
76
+ end
77
+
78
+ # Prepended to the Minitest singleton. Adds a counting reporter once
79
+ # Minitest has set up its own reporters for a run.
80
+ module MinitestHooks
81
+ def init_plugins(*args)
82
+ value = super
83
+ GemfileAudit::Measurement.add_minitest_counter(self)
84
+ value
85
+ end
86
+ end
87
+
88
+ # A Minitest reporter that only counts results. It never fails a run.
89
+ class MinitestCounter
90
+ def start
91
+ GemfileAudit::Measurement.minitest_started
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def prerecord(_klass, _name)
97
+ nil
98
+ end
99
+
100
+ def record(result)
101
+ GemfileAudit::Measurement.minitest_record(result)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ def report
107
+ nil
108
+ end
109
+
110
+ def passed?
111
+ true
112
+ end
113
+ end
114
+
115
+ @active = false
116
+
117
+ class << self
118
+ def start(dir, root)
119
+ return false if @active
120
+ @active = true
121
+ @dir = File.expand_path(dir)
122
+ @pid = Process.pid
123
+ use_root(root, Dir.pwd)
124
+ @deprecations = []
125
+ @deprecations_pid = @pid
126
+ @minitest_counts = nil
127
+ @minitest_mutex = Mutex.new
128
+ @coverage_kept_pid = @pid
129
+ @lookup_running = false
130
+ @simplecov_hooked = false
131
+ @coverage_kept = {}
132
+ @own_coverage = false
133
+ @deprecation_hooked = false
134
+ @minitest_hooked = false
135
+ @trace = nil
136
+ start_coverage
137
+ watch_for_frameworks
138
+ at_exit { finish }
139
+ true
140
+ rescue StandardError, ScriptError
141
+ false
142
+ end
143
+
144
+ def use_root(root, cwd)
145
+ @cwd = cwd
146
+ @roots = path_forms(root)
147
+ @gem_dirs = nil
148
+ @gem_dirs_with_bundler = nil
149
+ end
150
+
151
+ # Coverage
152
+
153
+ def start_coverage
154
+ require "coverage"
155
+ begin
156
+ if RUBY_VERSION >= "2.5"
157
+ Coverage.start(lines: true)
158
+ else
159
+ Coverage.start
160
+ end
161
+ @own_coverage = true
162
+ rescue StandardError
163
+ @own_coverage = false
164
+ end
165
+ hooks = Coverage.singleton_class
166
+ hooks.send(:prepend, CoverageHooks)
167
+ hooks.send(:prepend, CoverageSetupHook) if Coverage.respond_to?(:setup)
168
+ end
169
+
170
+ # The app is starting coverage itself while ours runs: take our counts
171
+ # and stop, so the app gets the fresh measurement it asked for.
172
+ def release_coverage
173
+ return unless @own_coverage
174
+ @own_coverage = false
175
+ begin
176
+ Coverage.result
177
+ rescue StandardError
178
+ nil
179
+ end
180
+ end
181
+
182
+ def keep_coverage(value, args)
183
+ options = args.last.is_a?(Hash) ? args.last : {}
184
+ stop = options.key?(:stop) ? options[:stop] : true
185
+ clear = options.key?(:clear) ? options[:clear] : true
186
+ return unless stop || clear
187
+ @own_coverage = false if stop
188
+ unless @coverage_kept_pid == Process.pid
189
+ @coverage_kept = {}
190
+ @coverage_kept_pid = Process.pid
191
+ end
192
+ add_coverage(@coverage_kept, project_coverage(value))
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
197
+ def collected_coverage
198
+ total = {}
199
+ add_coverage(total, @coverage_kept) if @coverage_kept_pid == Process.pid
200
+ current = begin
201
+ Coverage.peek_result
202
+ rescue StandardError
203
+ nil
204
+ end
205
+ add_coverage(total, project_coverage(current)) if current
206
+ # A forked child starts with cleared counters: files it never ran
207
+ # carry no information of its own.
208
+ total.reject { |_path, lines| lines.none? { |count| count.is_a?(Integer) && count > 0 } }
209
+ end
210
+
211
+ # Turns either coverage format into { path => [count or nil per line] }:
212
+ # Ruby 2.3 and 2.4 give an array per file, later versions a hash.
213
+ def normalise_coverage(raw)
214
+ result = {}
215
+ raw.each do |path, data|
216
+ lines = line_counts(data)
217
+ result[path] = lines if lines
218
+ end
219
+ result
220
+ end
221
+
222
+ def line_counts(data)
223
+ if data.is_a?(Array)
224
+ data.dup
225
+ elsif data.is_a?(Hash) && data[:lines].is_a?(Array)
226
+ data[:lines].dup
227
+ elsif data.is_a?(Hash) && data[:oneshot_lines].is_a?(Array)
228
+ lines = []
229
+ data[:oneshot_lines].each { |number| lines[number - 1] = 1 if number.is_a?(Integer) && number > 0 }
230
+ lines
231
+ end
232
+ end
233
+
234
+ def project_coverage(raw)
235
+ result = {}
236
+ return result unless raw.is_a?(Hash)
237
+ normalise_coverage(raw).each do |path, lines|
238
+ next unless path.is_a?(String) && path.end_with?(".rb")
239
+ relative = project_path(path, COVERAGE_EXCLUDED)
240
+ add_lines(result, relative, lines) if relative
241
+ end
242
+ result
243
+ end
244
+
245
+ def add_coverage(total, coverage)
246
+ coverage.each { |path, lines| add_lines(total, path, lines) }
247
+ total
248
+ end
249
+
250
+ def add_lines(total, path, lines)
251
+ existing = total[path]
252
+ unless existing
253
+ total[path] = lines.dup
254
+ return
255
+ end
256
+ size = existing.size > lines.size ? existing.size : lines.size
257
+ merged = Array.new(size) do |index|
258
+ left = existing[index]
259
+ right = lines[index]
260
+ if left.nil?
261
+ right
262
+ elsif right.nil?
263
+ left
264
+ else
265
+ left + right
266
+ end
267
+ end
268
+ total[path] = merged
269
+ end
270
+
271
+ # Paths
272
+
273
+ def path_forms(path)
274
+ forms = [File.expand_path(path)]
275
+ begin
276
+ forms << File.realpath(path)
277
+ rescue StandardError
278
+ nil
279
+ end
280
+ forms.uniq.map { |form| form.end_with?("/") ? form : "#{form}/" }
281
+ end
282
+
283
+ # The path relative to the project root, or nil when the file is outside
284
+ # it, inside an installed gem or under one of the excluded folders.
285
+ def project_path(path, excluded)
286
+ absolute = File.expand_path(path.to_s, @cwd)
287
+ return nil if in_gem_dirs?(absolute)
288
+ relative = relative_to_root(absolute)
289
+ if relative.nil?
290
+ real = begin
291
+ File.realpath(absolute)
292
+ rescue StandardError
293
+ nil
294
+ end
295
+ return nil if real.nil? || in_gem_dirs?(real)
296
+ relative = relative_to_root(real)
297
+ return nil if relative.nil?
298
+ end
299
+ return nil if excluded.any? { |prefix| relative.start_with?(prefix) }
300
+ relative
301
+ end
302
+
303
+ def relative_to_root(absolute)
304
+ @roots.each do |root|
305
+ return absolute[root.size..-1] if absolute.start_with?(root)
306
+ end
307
+ nil
308
+ end
309
+
310
+ def in_gem_dirs?(absolute)
311
+ gem_dirs.any? { |dir| absolute.start_with?(dir) }
312
+ end
313
+
314
+ def gem_dirs
315
+ with_bundler = defined?(::Bundler) ? true : false
316
+ return @gem_dirs if @gem_dirs && @gem_dirs_with_bundler == with_bundler
317
+ dirs = []
318
+ if defined?(::Gem)
319
+ dirs << safely(nil) { ::Gem.dir }
320
+ dirs.concat(safely([]) { ::Gem.path })
321
+ end
322
+ if with_bundler && ::Bundler.respond_to?(:bundle_path)
323
+ dirs << safely(nil) { ::Bundler.bundle_path.to_s }
324
+ end
325
+ dirs.concat(ruby_library_dirs)
326
+ forms = dirs.compact.map(&:to_s).reject(&:empty?).uniq.map { |dir| path_forms(dir) }.flatten.uniq
327
+ # A gem folder that holds the whole project must not hide the project.
328
+ forms = forms.reject { |dir| @roots.any? { |root| root.start_with?(dir) } }
329
+ @gem_dirs_with_bundler = with_bundler
330
+ @gem_dirs = forms
331
+ end
332
+
333
+ # Ruby's own library, which is never the customer's code.
334
+ def ruby_library_dirs
335
+ %w(rubylibdir rubyarchdir sitelibdir vendorlibdir).map do |key|
336
+ safely(nil) { ::RbConfig::CONFIG[key] }
337
+ end.compact.reject { |dir| dir.to_s.empty? }
338
+ end
339
+
340
+ # Deprecations and Minitest, hooked as soon as their classes exist
341
+
342
+ def watch_for_frameworks
343
+ hook_deprecation(::ActiveSupport::Deprecation) if defined?(::ActiveSupport::Deprecation)
344
+ hook_minitest(::Minitest) if defined?(::Minitest)
345
+ hook_simplecov(::SimpleCov) if defined?(::SimpleCov)
346
+ return if all_hooked?
347
+ @trace = TracePoint.new(:end) { |point| module_closed(point.self) }
348
+ @trace.enable
349
+ end
350
+
351
+ def module_closed(mod)
352
+ name = MODULE_NAME.bind(mod).call
353
+ if name == "ActiveSupport::Deprecation"
354
+ hook_deprecation(mod)
355
+ elsif name == "Minitest"
356
+ hook_minitest(mod)
357
+ elsif name == "SimpleCov"
358
+ hook_simplecov(mod)
359
+ end
360
+ @trace.disable if @trace && all_hooked?
361
+ rescue StandardError
362
+ nil
363
+ end
364
+
365
+ def hook_deprecation(klass)
366
+ return if @deprecation_hooked || !klass.is_a?(Module)
367
+ klass.send(:prepend, DeprecationHooks)
368
+ @deprecation_hooked = true
369
+ end
370
+
371
+ def hook_minitest(mod)
372
+ return if @minitest_hooked || !mod.is_a?(Module)
373
+ mod.singleton_class.send(:prepend, MinitestHooks)
374
+ @minitest_hooked = true
375
+ end
376
+
377
+ def hook_simplecov(mod)
378
+ return if @simplecov_hooked || !mod.is_a?(Module)
379
+ mod.singleton_class.send(:prepend, SimpleCovHooks)
380
+ @simplecov_hooked = true
381
+ end
382
+
383
+ def all_hooked?
384
+ @deprecation_hooked && @minitest_hooked && @simplecov_hooked ? true : false
385
+ end
386
+
387
+ # Rails 4 builds its call stack with caller, Rails 5 and later with
388
+ # caller_locations.
389
+ def string_callstack?
390
+ defined?(::ActiveSupport::VERSION::MAJOR) && ::ActiveSupport::VERSION::MAJOR.to_i < 5 ? true : false
391
+ rescue StandardError
392
+ false
393
+ end
394
+
395
+ def record_deprecation(message, callstack)
396
+ return if @lookup_running
397
+ unless @deprecations_pid == Process.pid
398
+ @deprecations = []
399
+ @deprecations_pid = Process.pid
400
+ end
401
+ return if @deprecations.size >= MAX_DEPRECATIONS
402
+ file, line = project_location(callstack)
403
+ @deprecations << { "message" => message.to_s, "file" => file, "line" => line }
404
+ end
405
+
406
+ def project_location(callstack)
407
+ Array(callstack).first(MAX_FRAMES).each do |frame|
408
+ if frame.is_a?(String)
409
+ match = /\A(.+?):(\d+)/.match(frame)
410
+ next unless match
411
+ path = match[1]
412
+ line = match[2].to_i
413
+ else
414
+ path = frame.absolute_path || frame.path
415
+ line = frame.lineno
416
+ end
417
+ next unless path
418
+ relative = project_path(path, LOCATION_EXCLUDED)
419
+ return [relative, line] if relative
420
+ end
421
+ [nil, nil]
422
+ end
423
+
424
+ def add_minitest_counter(minitest)
425
+ reporter = minitest.reporter if minitest.respond_to?(:reporter)
426
+ reporter << MinitestCounter.new if reporter.respond_to?(:<<)
427
+ rescue StandardError
428
+ nil
429
+ end
430
+
431
+ def minitest_started
432
+ @minitest_mutex.synchronize do
433
+ return if @minitest_counts && @minitest_counts["pid"] == Process.pid
434
+ @minitest_counts = { "pid" => Process.pid, "run" => 0, "passed" => 0, "failed" => 0, "skipped" => 0 }
435
+ end
436
+ end
437
+
438
+ def minitest_record(result)
439
+ outcome = if result.respond_to?(:skipped?) && result.skipped?
440
+ "skipped"
441
+ elsif result.respond_to?(:passed?) && result.passed?
442
+ "passed"
443
+ else
444
+ "failed"
445
+ end
446
+ @minitest_mutex.synchronize do
447
+ counts = @minitest_counts
448
+ return unless counts && counts["pid"] == Process.pid
449
+ counts["run"] += 1
450
+ counts[outcome] += 1
451
+ end
452
+ end
453
+
454
+ # At exit
455
+
456
+ def finish
457
+ report = build_report
458
+ write_report(@dir, report) if report
459
+ rescue Exception
460
+ nil
461
+ end
462
+
463
+ def build_report
464
+ coverage = safely({}) { collected_coverage }
465
+ deprecations = safely([]) { @deprecations_pid == Process.pid ? @deprecations : [] }
466
+ tests = safely(nil) { test_counts }
467
+ return nil if coverage.empty? && deprecations.empty? && tests.nil?
468
+ {
469
+ "ruby" => RUBY_VERSION,
470
+ "bundler" => safely(nil) { defined?(::Bundler::VERSION) ? ::Bundler::VERSION.to_s : nil },
471
+ "database" => safely(nil) { database },
472
+ "coverage" => coverage,
473
+ "deprecations" => deprecations,
474
+ "tests" => tests
475
+ }
476
+ end
477
+
478
+ def test_counts
479
+ counts = @minitest_mutex.synchronize { @minitest_counts && @minitest_counts.dup }
480
+ if counts && counts["pid"] == Process.pid
481
+ return {
482
+ "runner" => "minitest", "run" => counts["run"], "passed" => counts["passed"],
483
+ "failed" => counts["failed"], "skipped" => counts["skipped"]
484
+ }
485
+ end
486
+ rspec_counts
487
+ end
488
+
489
+ # Reads the reporter RSpec already built. Never creates a configuration
490
+ # or a reporter, and ignores counts a forked child inherited.
491
+ def rspec_counts
492
+ return nil unless Process.pid == @pid && defined?(::RSpec)
493
+ return nil unless ::RSpec.instance_variable_defined?(:@configuration)
494
+ configuration = ::RSpec.instance_variable_get(:@configuration)
495
+ return nil unless configuration && configuration.instance_variable_defined?(:@reporter)
496
+ reporter = configuration.instance_variable_get(:@reporter)
497
+ return nil unless reporter.respond_to?(:examples) && reporter.respond_to?(:failed_examples) &&
498
+ reporter.respond_to?(:pending_examples)
499
+ run = reporter.examples.size
500
+ failed = reporter.failed_examples.size
501
+ skipped = reporter.pending_examples.size
502
+ { "runner" => "rspec", "run" => run, "passed" => run - failed - skipped, "failed" => failed, "skipped" => skipped }
503
+ end
504
+
505
+ # Only in the process that started the run: a forked child shares the
506
+ # parent's database connection and must not use it.
507
+ # Deprecations raised by the lookup itself are not the app's and are
508
+ # not recorded.
509
+ def database
510
+ return nil unless Process.pid == @pid && defined?(::ActiveRecord::Base)
511
+ base = ::ActiveRecord::Base
512
+ return nil unless base.respond_to?(:connected?) && base.connected?
513
+ @lookup_running = true
514
+ begin
515
+ if base.respond_to?(:with_connection)
516
+ base.with_connection { |connection| database_version(base, connection) }
517
+ else
518
+ database_version(base, base.connection)
519
+ end
520
+ ensure
521
+ @lookup_running = false
522
+ end
523
+ end
524
+
525
+ # Rails 7.2 deprecates ActiveRecord::Base.connection, so with_connection
526
+ # is used where it exists.
527
+ def database_version(base, connection)
528
+ name = connection.adapter_name.to_s.downcase
529
+ adapter, query = database_query(name)
530
+ version = query ? quiet_select(base, connection, query) : nil
531
+ adapter = "mariadb" if adapter == "mysql" && version.to_s.downcase.include?("mariadb")
532
+ number = version.to_s[/\d+(\.\d+)*/]
533
+ { "adapter" => adapter, "version" => number }
534
+ end
535
+
536
+ def database_query(name)
537
+ if name.include?("postg")
538
+ ["postgresql", "SHOW server_version"]
539
+ elsif name.include?("mysql") || name.include?("trilogy")
540
+ ["mysql", "SELECT VERSION()"]
541
+ elsif name.include?("sqlite")
542
+ ["sqlite", "SELECT sqlite_version()"]
543
+ else
544
+ [name, nil]
545
+ end
546
+ end
547
+
548
+ # Runs the version query with the app's logger switched off, so the
549
+ # query never shows up in output the app sends to the terminal.
550
+ def quiet_select(base, connection, query)
551
+ logger = base.respond_to?(:logger) ? base.logger : nil
552
+ swap = logger && base.respond_to?(:logger=)
553
+ base.logger = nil if swap
554
+ begin
555
+ connection.select_value(query)
556
+ ensure
557
+ base.logger = logger if swap
558
+ end
559
+ end
560
+
561
+ # Writing
562
+
563
+ # Writes to a hidden temporary name in the same folder, then renames,
564
+ # so the collector never sees half a file.
565
+ def write_report(dir, report)
566
+ json = generate(report)
567
+ name = "process-#{Process.pid}-#{random_token}"
568
+ temporary = File.join(dir, ".#{name}.json.tmp")
569
+ final = File.join(dir, "#{name}.json")
570
+ begin
571
+ File.open(temporary, "wb") { |file| file.write(json) }
572
+ File.rename(temporary, final)
573
+ rescue Exception
574
+ begin
575
+ File.unlink(temporary) if File.exist?(temporary)
576
+ rescue StandardError
577
+ nil
578
+ end
579
+ raise
580
+ end
581
+ final
582
+ end
583
+
584
+ def random_token
585
+ "#{rand(0x100000000).to_s(36)}#{(Time.now.to_f * 1_000_000).to_i.to_s(36)}"
586
+ end
587
+
588
+ # A small JSON writer, so the loader never loads the json gem into the
589
+ # app's process.
590
+ def generate(value)
591
+ case value
592
+ when Hash
593
+ "{" + value.map { |key, item| "#{generate_string(key.to_s)}:#{generate(item)}" }.join(",") + "}"
594
+ when Array
595
+ "[" + value.map { |item| generate(item) }.join(",") + "]"
596
+ when String
597
+ generate_string(value)
598
+ when Integer
599
+ value.to_s
600
+ when Float
601
+ value.finite? ? value.to_s : "null"
602
+ when true
603
+ "true"
604
+ when false
605
+ "false"
606
+ when nil
607
+ "null"
608
+ else
609
+ generate_string(value.to_s)
610
+ end
611
+ end
612
+
613
+ def generate_string(string)
614
+ text = string.dup
615
+ if text.encoding == Encoding::UTF_8 || text.encoding == Encoding::BINARY || text.encoding == Encoding::US_ASCII
616
+ text.force_encoding(Encoding::UTF_8)
617
+ else
618
+ begin
619
+ text = text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
620
+ rescue EncodingError
621
+ # Dummy encodings such as UTF-7 have no converter.
622
+ text = text.force_encoding(Encoding::UTF_8)
623
+ end
624
+ end
625
+ text = text.scrub("?") unless text.valid_encoding?
626
+ escaped = text.gsub(/["\\\x00-\x1f]/) { |char| JSON_ESCAPES[char] || format("\\u%04x", char.ord) }
627
+ "\"#{escaped}\""
628
+ end
629
+
630
+ def safely(default)
631
+ yield
632
+ rescue StandardError, ScriptError, SystemStackError
633
+ default
634
+ end
635
+ end
636
+ end
637
+ end
@@ -0,0 +1,70 @@
1
+ module GemfileAudit
2
+ # Runs a short command and returns what it printed, giving up after a
3
+ # deadline so a stuck tool can never hold the build.
4
+ module Shell
5
+ TIMEOUT = 5
6
+
7
+ # The standard output of the command, or nil when it failed, was not
8
+ # found or did not finish in time.
9
+ def self.capture(*command)
10
+ options = { :err => File::NULL }
11
+ options[:chdir] = command.pop[:chdir] if command.last.is_a?(Hash)
12
+ reader, writer = IO.pipe
13
+ options[:out] = writer
14
+ pid = nil
15
+ begin
16
+ pid = Process.spawn(*command, options)
17
+ writer.close
18
+ read_until(reader, pid, Time.now + TIMEOUT)
19
+ rescue StandardError
20
+ stop(pid)
21
+ nil
22
+ ensure
23
+ writer.close unless writer.closed?
24
+ reader.close unless reader.closed?
25
+ end
26
+ end
27
+
28
+ def self.read_until(reader, pid, deadline)
29
+ output = String.new
30
+ loop do
31
+ remaining = deadline - Time.now
32
+ return timed_out(pid) if remaining <= 0
33
+ return timed_out(pid) unless IO.select([reader], nil, nil, remaining)
34
+ begin
35
+ output << reader.read_nonblock(4096)
36
+ rescue IO::WaitReadable
37
+ next
38
+ rescue EOFError
39
+ break
40
+ end
41
+ end
42
+ until (done = Process.wait2(pid, Process::WNOHANG))
43
+ return timed_out(pid) if Time.now >= deadline
44
+ sleep 0.01
45
+ end
46
+ done.last.success? ? output : nil
47
+ end
48
+
49
+ def self.timed_out(pid)
50
+ stop(pid)
51
+ nil
52
+ end
53
+
54
+ def self.stop(pid)
55
+ return unless pid
56
+ begin
57
+ Process.kill("KILL", pid)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+ begin
62
+ Process.wait(pid)
63
+ rescue StandardError
64
+ nil
65
+ end
66
+ end
67
+
68
+ private_class_method :read_until, :timed_out, :stop
69
+ end
70
+ end