audition 0.2.4 → 0.4.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.
@@ -12,9 +12,26 @@ Warning[:experimental] = false
12
12
  Thread.report_on_exception = false
13
13
 
14
14
  require "json"
15
+ require "rbconfig"
15
16
 
16
17
  module AuditionHarness
17
- MAX_CONSTS = 5000
18
+ # High enough for the largest targets: the sweep is the backstop
19
+ # for everything static analysis cannot prove, so it must reach
20
+ # every constant the boot defined. A hit is reported as
21
+ # truncated, never silent.
22
+ MAX_CONSTS = 200_000
23
+
24
+ # Directories under the target root that are not the target's
25
+ # own surface. Bundler's deployment mode (and bundler-cache in
26
+ # GitHub Actions) vendors every gem into <root>/vendor/bundle,
27
+ # and attributing those constants to the target would flip its
28
+ # verdict from blocked to not_ready. Must mirror
29
+ # Audition::Target::EXCLUDED_DIRS (this file is a standalone
30
+ # subprocess script and cannot require the gem); a spec keeps
31
+ # the two lists in sync.
32
+ EXCLUDED_DIRS = %w[
33
+ vendor node_modules tmp log coverage pkg .git .bundle
34
+ ].freeze
18
35
 
19
36
  # Fixtures for capability probes.
20
37
  CAP_CONST = [1, 2] # audition:disable mutable-constants
@@ -69,7 +86,18 @@ module AuditionHarness
69
86
  def describe_error(error)
70
87
  root = unwrap(error)
71
88
  {"class" => scrub(root.class.name.to_s),
72
- "message" => scrub(root.message.to_s)[0, 500]}
89
+ "message" => scrub(root.message.to_s)[0, 500],
90
+ "backtrace" => backtrace_for(root)}
91
+ end
92
+
93
+ # Enough frames to reach the target's code under framework
94
+ # wrappers; frames carry paths, which can carry arbitrary bytes.
95
+ def backtrace_for(error)
96
+ Array(error.backtrace).first(30).map do |frame|
97
+ scrub(frame.to_s)[0, 300]
98
+ end
99
+ rescue Exception
100
+ []
73
101
  end
74
102
 
75
103
  def scrub(text)
@@ -124,19 +152,53 @@ module AuditionHarness
124
152
  Array(payload["load_paths"]).each do |lp|
125
153
  $LOAD_PATH.unshift(lp) # audition:disable global-variables
126
154
  end
155
+ baseline = module_state_snapshot
127
156
  before = Object.constants
128
- feature = payload.fetch("feature")
129
- begin
130
- require feature # audition:disable runtime-require
131
- rescue LoadError
132
- # Dashed gem names conventionally ship slashed entry files
133
- # (rspec-mocks provides rspec/mocks).
134
- slashed = feature.tr("-", "/")
135
- raise if slashed == feature
157
+ features = $LOADED_FEATURES.dup # audition:disable global-variables
158
+ require_target(payload.fetch("feature"),
159
+ Array(payload["load_paths"]))
160
+ merge_reopened(
161
+ scan(Object.constants - before, root: payload["root"],
162
+ limit: payload["max_constants"]), baseline
163
+ ).merge(
164
+ "native_extensions" => native_extensions(
165
+ features, payload["root"], payload["known_compiled"]
166
+ )
167
+ )
168
+ end
169
+
170
+ # Gem names and entry files diverge in two conventional ways:
171
+ # dashed names ship slashed files (rspec-mocks provides
172
+ # rspec/mocks), and squashed names ship snake_case files
173
+ # (activesupport provides active_support). The second has no
174
+ # rule to invert, so when the target ships exactly one top-level
175
+ # file on its load paths, that file is the entry, required by
176
+ # absolute path. An absolute require keeps the path as given, and scan
177
+ # compares constant origins against the realpathed root, so the
178
+ # candidate is built from the realpath too (a symlinked tmpdir,
179
+ # macOS /var, otherwise turns own findings into dependency ones).
180
+ # The error reported is the last one seen: a candidate that loads
181
+ # but fails inside says more than "cannot load such file".
182
+ def require_target(feature, load_paths)
183
+ require feature # audition:disable runtime-require
184
+ rescue LoadError => error
185
+ entry_candidates(feature, load_paths).each do |candidate|
186
+ return require candidate # audition:disable runtime-require
187
+ rescue LoadError => e
188
+ error = e
189
+ end
190
+ raise error
191
+ end
136
192
 
137
- require slashed # audition:disable runtime-require
193
+ def entry_candidates(feature, load_paths)
194
+ candidates = []
195
+ slashed = feature.tr("-", "/")
196
+ candidates << slashed if slashed != feature
197
+ files = load_paths.flat_map do |path|
198
+ Dir[File.join(realpath(path), "*.rb")]
138
199
  end
139
- scan(Object.constants - before, root: payload["root"])
200
+ candidates << files.first.delete_suffix(".rb") if files.size == 1
201
+ candidates
140
202
  end
141
203
 
142
204
  # Breadth-first walk of every constant the require introduced:
@@ -144,29 +206,28 @@ module AuditionHarness
144
206
  # are inspected for class-level ivars and class variables, then
145
207
  # descended into. const_get can raise (autoload failures) and
146
208
  # anything can lie; every step is rescued and counted.
147
- def scan(root_names, root: nil)
209
+ def scan(root_names, root: nil, limit: nil)
148
210
  # Loaded features are realpathed by require; the target root
149
211
  # must be too, or symlinked paths (macOS /var vs /private/var)
150
212
  # break the own-vs-dependency comparison.
151
- if root
152
- root = begin
153
- File.realpath(root)
154
- rescue
155
- root
156
- end
157
- end
213
+ root = realpath(root)
214
+ limit = (limit || MAX_CONSTS).to_i
158
215
  unshareable = []
159
216
  class_state = []
160
217
  class_vars = []
161
218
  errors = 0
219
+ truncated = false
162
220
  seen = {}
163
221
  queue = root_names.map { |name| [Object, name.to_s] }
164
222
  visited = 0
165
223
 
166
224
  until queue.empty?
225
+ if visited >= limit
226
+ truncated = true
227
+ break
228
+ end
167
229
  owner, name = queue.shift
168
230
  visited += 1
169
- break if visited > MAX_CONSTS
170
231
 
171
232
  begin
172
233
  value = owner.const_get(name, false)
@@ -189,8 +250,11 @@ module AuditionHarness
189
250
  else
190
251
  begin
191
252
  unless Ractor.shareable?(value)
253
+ blocker, blocker_depth = blocker_for(value)
192
254
  unshareable << origin.merge(
193
- "const" => full, "class" => value.class.name
255
+ "const" => full, "class" => value.class.name,
256
+ "blocker" => blocker,
257
+ "blocker_nested" => blocker_depth.positive?
194
258
  )
195
259
  end
196
260
  rescue Exception
@@ -203,6 +267,8 @@ module AuditionHarness
203
267
  "class_state" => class_state,
204
268
  "class_variables" => class_vars,
205
269
  "scanned" => visited,
270
+ "truncated" => truncated,
271
+ "limit" => limit,
206
272
  "errors" => errors}
207
273
  end
208
274
 
@@ -218,10 +284,64 @@ module AuditionHarness
218
284
  end
219
285
  # The separator matters: /x/app must not claim /x/app-helpers.
220
286
  own = root.nil? || path.nil? || path == root ||
221
- path.start_with?(root + File::SEPARATOR)
287
+ (path.start_with?(root + File::SEPARATOR) &&
288
+ !excluded?(path, root))
222
289
  {"path" => path, "line" => line, "own" => own}
223
290
  end
224
291
 
292
+ # Matches the static scanner's exclusion rule: any excluded or
293
+ # dot-prefixed component in the root-relative path means the
294
+ # file is not the target's own code.
295
+ def excluded?(path, root)
296
+ relative = path.delete_prefix(root + File::SEPARATOR)
297
+ relative.split(File::SEPARATOR).any? do |part|
298
+ EXCLUDED_DIRS.include?(part) || part.start_with?(".")
299
+ end
300
+ end
301
+
302
+ NATIVE = /\.(bundle|so)\z/
303
+ DECLARATION = "rb_ext_ractor_safe"
304
+
305
+ # Compiled extensions the require pulled in, with the one fact
306
+ # that decides their Ractor behavior: whether the file imports
307
+ # rb_ext_ractor_safe. Ruby's own extensions (archdir) are flagged
308
+ # so the prober can leave them to Ruby, and files the static check
309
+ # already covers are flagged as known.
310
+ def native_extensions(before, root, known)
311
+ root = realpath(root)
312
+ known = Array(known).map { |path| realpath(path) }
313
+ archdir = RbConfig::CONFIG["archdir"] + File::SEPARATOR
314
+ loaded = $LOADED_FEATURES - before # audition:disable global-variables
315
+ loaded.grep(NATIVE).map do |path|
316
+ {"path" => path,
317
+ "declares" => declares?(path),
318
+ "ruby" => path.start_with?(archdir),
319
+ "known" => known.include?(path),
320
+ "own" => known.include?(path) || own_path?(path, root)}
321
+ end
322
+ end
323
+
324
+ def declares?(path)
325
+ File.binread(path).include?(DECLARATION)
326
+ rescue SystemCallError
327
+ false
328
+ end
329
+
330
+ def own_path?(path, root)
331
+ return false unless root
332
+
333
+ path == root ||
334
+ (path.start_with?(root + File::SEPARATOR) && !excluded?(path, root))
335
+ end
336
+
337
+ def realpath(path)
338
+ return path if path.nil?
339
+
340
+ File.realpath(path)
341
+ rescue SystemCallError
342
+ path
343
+ end
344
+
225
345
  def inspect_module(full, mod, origin, class_state, class_vars)
226
346
  ivars = mod.instance_variables
227
347
  if ivars.any?
@@ -252,6 +372,125 @@ module AuditionHarness
252
372
  false
253
373
  end
254
374
 
375
+ # Ivar/cvar state of every module defined before the boot. The
376
+ # new-constants sweep never revisits these, so state the boot
377
+ # plants on reopened core and stdlib classes is diffed against
378
+ # this snapshot instead. Autoload stubs are left untouched:
379
+ # forcing them here would load code behind the back of the
380
+ # before/after constant accounting.
381
+ def module_state_snapshot(limit = 50_000)
382
+ snap = {}
383
+ seen = {}
384
+ queue = Object.constants.map { |name| [Object, name.to_s] }
385
+ until queue.empty? || snap.size >= limit
386
+ owner, name = queue.shift
387
+ begin
388
+ next if owner.autoload?(name, false)
389
+
390
+ value = owner.const_get(name, false)
391
+ rescue Exception
392
+ next
393
+ end
394
+ next unless value.is_a?(Module)
395
+ next if seen[value.object_id]
396
+
397
+ seen[value.object_id] = true
398
+ begin
399
+ snap[value] = [value.instance_variables,
400
+ value.class_variables(false)]
401
+ rescue Exception
402
+ next
403
+ end
404
+ value.constants(false).each do |child|
405
+ queue << [value, child.to_s]
406
+ end
407
+ end
408
+ snap
409
+ end
410
+
411
+ # RubyGems, Bundler, and the VM mutate their own module state on
412
+ # every require; that is probe machinery, not the target's doing.
413
+ MACHINERY = /\A(?:Gem|Bundler|RubyVM)(?:::|\z)/
414
+
415
+ # State the boot added to pre-existing modules, reported through
416
+ # the same channels as freshly defined class state. No source
417
+ # location exists for a reopen, and unknown origins count as own.
418
+ def merge_reopened(result, snapshot)
419
+ origin = {"path" => nil, "line" => nil, "own" => true}
420
+ snapshot.each do |mod, (ivars, cvars)|
421
+ new_ivars = mod.instance_variables - ivars
422
+ new_cvars = mod.class_variables(false) - cvars
423
+ next if new_ivars.empty? && new_cvars.empty?
424
+
425
+ full = mod.name || mod.inspect
426
+ next if full.match?(MACHINERY)
427
+ if new_ivars.any?
428
+ unshareable = new_ivars.reject do |ivar|
429
+ safe_shareable?(mod.instance_variable_get(ivar))
430
+ end
431
+ result["class_state"] << origin.merge(
432
+ "const" => full,
433
+ "ivars" => new_ivars.map(&:to_s),
434
+ "unshareable" => unshareable.map(&:to_s)
435
+ )
436
+ end
437
+ if new_cvars.any?
438
+ result["class_variables"] << origin.merge(
439
+ "const" => full, "cvars" => new_cvars.map(&:to_s)
440
+ )
441
+ end
442
+ rescue Exception
443
+ next
444
+ end
445
+ result
446
+ end
447
+
448
+ # The innermost unshareable node of an unshareable value, with
449
+ # its depth: an unfrozen node with shareable contents just needs
450
+ # freezing, an inherently unshareable one needs replacing.
451
+ # Bounded, because shareability of each child is itself a
452
+ # recursive check.
453
+ def blocker_for(value, depth = 0)
454
+ return [describe_blocker(value), depth] if depth > 5
455
+
456
+ child = children_of(value).find { |c| !safe_shareable?(c) }
457
+ if child
458
+ blocker_for(child, depth + 1)
459
+ else
460
+ [describe_blocker(value), depth]
461
+ end
462
+ rescue Exception
463
+ [describe_blocker(value), depth]
464
+ end
465
+
466
+ def children_of(value)
467
+ children =
468
+ case value
469
+ when Hash then value.keys + value.values
470
+ when Array then value
471
+ when Struct, Data then value.deconstruct
472
+ else
473
+ value.instance_variables.map do |ivar|
474
+ value.instance_variable_get(ivar)
475
+ end
476
+ end
477
+ children.first(1000)
478
+ end
479
+
480
+ # "unfrozen X" only when freezing would actually flip the
481
+ # verdict; a Proc or IO stays unshareable frozen, and naming it
482
+ # unfrozen would send the reader to a fix that cannot work.
483
+ def describe_blocker(value)
484
+ return value.class.to_s if value.frozen?
485
+
486
+ frozen_helps = begin
487
+ Ractor.shareable?(value.dup.freeze)
488
+ rescue Exception
489
+ false
490
+ end
491
+ frozen_helps ? "unfrozen #{value.class}" : value.class.to_s
492
+ end
493
+
255
494
  # -- rack --------------------------------------------------------
256
495
 
257
496
  # App objects built in config.ru are almost never shareable (the
@@ -261,6 +500,8 @@ module AuditionHarness
261
500
  # and serve one request entirely inside a Ractor.
262
501
  def rack(payload)
263
502
  config_ru = payload.fetch("config_ru")
503
+ root = payload["root"] || File.dirname(config_ru)
504
+ bundler_setup
264
505
  begin
265
506
  require "rack" # audition:disable runtime-require
266
507
  rescue LoadError
@@ -268,6 +509,9 @@ module AuditionHarness
268
509
  end
269
510
 
270
511
  out = {"rack_available" => true}
512
+ baseline = module_state_snapshot
513
+ before = Object.constants
514
+ features = $LOADED_FEATURES.dup # audition:disable global-variables
271
515
  begin
272
516
  app = Rack::Builder.parse_file(config_ru)
273
517
  app = app.first if app.is_a?(Array)
@@ -276,6 +520,16 @@ module AuditionHarness
276
520
  rescue Exception => e
277
521
  out["main_boot_error"] = describe_error(e)
278
522
  end
523
+ # The main-process boot defines the app's constant graph;
524
+ # sweeping it gives rack targets the same backstop as require
525
+ # and rails targets. A failed boot still sweeps what loaded.
526
+ out.merge!(merge_reopened(
527
+ scan(Object.constants - before, root: root,
528
+ limit: payload["max_constants"]), baseline
529
+ ))
530
+ out["native_extensions"] = native_extensions(
531
+ features, root, payload["known_compiled"]
532
+ )
279
533
 
280
534
  out["ractor_boot_call"] = rack_in_ractor(config_ru)
281
535
  if out["ractor_boot_call"]["ok"]
@@ -357,20 +611,58 @@ module AuditionHarness
357
611
 
358
612
  # -- rails -------------------------------------------------------
359
613
 
614
+ # The prober sets BUNDLE_GEMFILE to the target's Gemfile; the
615
+ # target's gems must resolve before its boot files load. A
616
+ # setup failure propagates: it is the true boot failure, and
617
+ # the standard reporting captures it. Bundler.setup is called
618
+ # directly because bundler/setup exits on failure, swallowing
619
+ # the message.
620
+ def bundler_setup
621
+ return unless ENV["BUNDLE_GEMFILE"]
622
+
623
+ require "bundler" # audition:disable runtime-require
624
+ Bundler.ui.silence { Bundler.setup }
625
+ end
626
+
627
+ # A boot failure does not abandon the sweep: everything defined
628
+ # before the failure is still scanned, so the probe reports what
629
+ # it could reach alongside the boot error.
360
630
  def rails(payload)
361
631
  environment = payload.fetch("environment")
632
+ bundler_setup
633
+ baseline = module_state_snapshot
362
634
  before = Object.constants
635
+ features = $LOADED_FEATURES.dup # audition:disable global-variables
363
636
  started = Time.now
364
- require environment # audition:disable runtime-require
637
+ boot_error = nil
365
638
  begin
366
- Rails.application.eager_load!
367
- rescue Exception
368
- nil
639
+ # Absolute requires keep the path as given; realpathing it
640
+ # keeps own-vs-dependency attribution honest under symlinked
641
+ # roots (macOS /var).
642
+ require realpath(environment) # audition:disable runtime-require
643
+ begin
644
+ Rails.application.eager_load!
645
+ rescue Exception
646
+ nil
647
+ end
648
+ rescue Exception => e
649
+ boot_error = describe_error(e)
369
650
  end
370
- boot = {"ok" => true,
371
- "seconds" => (Time.now - started).round(1)}
372
- scan(Object.constants - before, root: payload["root"])
373
- .merge("boot" => boot)
651
+ boot =
652
+ if boot_error
653
+ {"ok" => false, "error" => boot_error}
654
+ else
655
+ {"ok" => true, "seconds" => (Time.now - started).round(1)}
656
+ end
657
+ merge_reopened(
658
+ scan(Object.constants - before, root: payload["root"],
659
+ limit: payload["max_constants"]), baseline
660
+ ).merge(
661
+ "boot" => boot,
662
+ "native_extensions" => native_extensions(
663
+ features, payload["root"], payload["known_compiled"]
664
+ )
665
+ )
374
666
  rescue Exception => e
375
667
  {"boot" => {"ok" => false, "error" => describe_error(e)}}
376
668
  end