audition 0.3.0 → 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.
@@ -15,7 +15,11 @@ require "json"
15
15
  require "rbconfig"
16
16
 
17
17
  module AuditionHarness
18
- 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
19
23
 
20
24
  # Directories under the target root that are not the target's
21
25
  # own surface. Bundler's deployment mode (and bundler-cache in
@@ -82,7 +86,18 @@ module AuditionHarness
82
86
  def describe_error(error)
83
87
  root = unwrap(error)
84
88
  {"class" => scrub(root.class.name.to_s),
85
- "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
+ []
86
101
  end
87
102
 
88
103
  def scrub(text)
@@ -137,10 +152,15 @@ module AuditionHarness
137
152
  Array(payload["load_paths"]).each do |lp|
138
153
  $LOAD_PATH.unshift(lp) # audition:disable global-variables
139
154
  end
155
+ baseline = module_state_snapshot
140
156
  before = Object.constants
141
157
  features = $LOADED_FEATURES.dup # audition:disable global-variables
142
- require_target(payload.fetch("feature"), payload["root"])
143
- scan(Object.constants - before, root: payload["root"]).merge(
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(
144
164
  "native_extensions" => native_extensions(
145
165
  features, payload["root"], payload["known_compiled"]
146
166
  )
@@ -152,17 +172,17 @@ module AuditionHarness
152
172
  # rspec/mocks), and squashed names ship snake_case files
153
173
  # (activesupport provides active_support). The second has no
154
174
  # rule to invert, so when the target ships exactly one top-level
155
- # file under lib/, that file is the entry, required by absolute
156
- # path. An absolute require keeps the path as given, and scan
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
157
177
  # compares constant origins against the realpathed root, so the
158
178
  # candidate is built from the realpath too (a symlinked tmpdir,
159
179
  # macOS /var, otherwise turns own findings into dependency ones).
160
180
  # The error reported is the last one seen: a candidate that loads
161
181
  # but fails inside says more than "cannot load such file".
162
- def require_target(feature, root)
182
+ def require_target(feature, load_paths)
163
183
  require feature # audition:disable runtime-require
164
184
  rescue LoadError => error
165
- entry_candidates(feature, root).each do |candidate|
185
+ entry_candidates(feature, load_paths).each do |candidate|
166
186
  return require candidate # audition:disable runtime-require
167
187
  rescue LoadError => e
168
188
  error = e
@@ -170,11 +190,13 @@ module AuditionHarness
170
190
  raise error
171
191
  end
172
192
 
173
- def entry_candidates(feature, root)
193
+ def entry_candidates(feature, load_paths)
174
194
  candidates = []
175
195
  slashed = feature.tr("-", "/")
176
196
  candidates << slashed if slashed != feature
177
- files = root ? Dir[File.join(realpath(root), "lib", "*.rb")] : []
197
+ files = load_paths.flat_map do |path|
198
+ Dir[File.join(realpath(path), "*.rb")]
199
+ end
178
200
  candidates << files.first.delete_suffix(".rb") if files.size == 1
179
201
  candidates
180
202
  end
@@ -184,23 +206,28 @@ module AuditionHarness
184
206
  # are inspected for class-level ivars and class variables, then
185
207
  # descended into. const_get can raise (autoload failures) and
186
208
  # anything can lie; every step is rescued and counted.
187
- def scan(root_names, root: nil)
209
+ def scan(root_names, root: nil, limit: nil)
188
210
  # Loaded features are realpathed by require; the target root
189
211
  # must be too, or symlinked paths (macOS /var vs /private/var)
190
212
  # break the own-vs-dependency comparison.
191
213
  root = realpath(root)
214
+ limit = (limit || MAX_CONSTS).to_i
192
215
  unshareable = []
193
216
  class_state = []
194
217
  class_vars = []
195
218
  errors = 0
219
+ truncated = false
196
220
  seen = {}
197
221
  queue = root_names.map { |name| [Object, name.to_s] }
198
222
  visited = 0
199
223
 
200
224
  until queue.empty?
225
+ if visited >= limit
226
+ truncated = true
227
+ break
228
+ end
201
229
  owner, name = queue.shift
202
230
  visited += 1
203
- break if visited > MAX_CONSTS
204
231
 
205
232
  begin
206
233
  value = owner.const_get(name, false)
@@ -223,8 +250,11 @@ module AuditionHarness
223
250
  else
224
251
  begin
225
252
  unless Ractor.shareable?(value)
253
+ blocker, blocker_depth = blocker_for(value)
226
254
  unshareable << origin.merge(
227
- "const" => full, "class" => value.class.name
255
+ "const" => full, "class" => value.class.name,
256
+ "blocker" => blocker,
257
+ "blocker_nested" => blocker_depth.positive?
228
258
  )
229
259
  end
230
260
  rescue Exception
@@ -237,6 +267,8 @@ module AuditionHarness
237
267
  "class_state" => class_state,
238
268
  "class_variables" => class_vars,
239
269
  "scanned" => visited,
270
+ "truncated" => truncated,
271
+ "limit" => limit,
240
272
  "errors" => errors}
241
273
  end
242
274
 
@@ -340,6 +372,125 @@ module AuditionHarness
340
372
  false
341
373
  end
342
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
+
343
494
  # -- rack --------------------------------------------------------
344
495
 
345
496
  # App objects built in config.ru are almost never shareable (the
@@ -349,6 +500,8 @@ module AuditionHarness
349
500
  # and serve one request entirely inside a Ractor.
350
501
  def rack(payload)
351
502
  config_ru = payload.fetch("config_ru")
503
+ root = payload["root"] || File.dirname(config_ru)
504
+ bundler_setup
352
505
  begin
353
506
  require "rack" # audition:disable runtime-require
354
507
  rescue LoadError
@@ -356,6 +509,9 @@ module AuditionHarness
356
509
  end
357
510
 
358
511
  out = {"rack_available" => true}
512
+ baseline = module_state_snapshot
513
+ before = Object.constants
514
+ features = $LOADED_FEATURES.dup # audition:disable global-variables
359
515
  begin
360
516
  app = Rack::Builder.parse_file(config_ru)
361
517
  app = app.first if app.is_a?(Array)
@@ -364,6 +520,16 @@ module AuditionHarness
364
520
  rescue Exception => e
365
521
  out["main_boot_error"] = describe_error(e)
366
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
+ )
367
533
 
368
534
  out["ractor_boot_call"] = rack_in_ractor(config_ru)
369
535
  if out["ractor_boot_call"]["ok"]
@@ -445,20 +611,53 @@ module AuditionHarness
445
611
 
446
612
  # -- rails -------------------------------------------------------
447
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.
448
630
  def rails(payload)
449
631
  environment = payload.fetch("environment")
632
+ bundler_setup
633
+ baseline = module_state_snapshot
450
634
  before = Object.constants
451
635
  features = $LOADED_FEATURES.dup # audition:disable global-variables
452
636
  started = Time.now
453
- require environment # audition:disable runtime-require
637
+ boot_error = nil
454
638
  begin
455
- Rails.application.eager_load!
456
- rescue Exception
457
- 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)
458
650
  end
459
- boot = {"ok" => true,
460
- "seconds" => (Time.now - started).round(1)}
461
- scan(Object.constants - before, root: payload["root"]).merge(
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(
462
661
  "boot" => boot,
463
662
  "native_extensions" => native_extensions(
464
663
  features, payload["root"], payload["known_compiled"]