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.
@@ -22,7 +22,7 @@ module Audition
22
22
  # Spawns the harness subprocess per probe mode, parses its JSON,
23
23
  # and converts observations into findings.
24
24
  class Prober
25
- HARNESS = File.expand_path("harness.rb", __dir__)
25
+ HARNESS = File.expand_path("harness.rb", __dir__).freeze
26
26
 
27
27
  RUNTIME_WHY =
28
28
  "Observed on the live object graph after loading the " \
@@ -60,13 +60,13 @@ module Audition
60
60
 
61
61
  def probe_script(entry)
62
62
  path = entry[:path]
63
- ractor = run("script_ractor", "path" => path)
63
+ ractor = run("script_ractor", {"path" => path})
64
64
  if ractor["ok"]
65
65
  return Result.new(mode: :script, raw: ractor,
66
66
  findings: [], passed: true)
67
67
  end
68
68
 
69
- main = run("script_main", "path" => path)
69
+ main = run("script_main", {"path" => path})
70
70
  finding = script_finding(path, ractor, main)
71
71
  Result.new(mode: :script,
72
72
  raw: {"ractor" => ractor, "main" => main},
@@ -76,6 +76,7 @@ module Audition
76
76
  def script_finding(path, ractor, main)
77
77
  if main["ok"]
78
78
  error = describe(ractor)
79
+ site = failure_site(ractor, prefix: "#{path}:")
79
80
  Finding.new(
80
81
  check: "dynamic-script",
81
82
  severity: :error,
@@ -84,11 +85,12 @@ module Audition
84
85
  "failed under Ractor.new; the static findings " \
85
86
  "usually pinpoint the exact line.",
86
87
  fix: "Fix the static findings for this file, then " \
87
- "re-audition.",
88
+ "re-run Audition.",
88
89
  path: path,
89
- line: nil
90
+ line: site&.last
90
91
  )
91
92
  else
93
+ site = failure_site(main, prefix: "#{path}:")
92
94
  Finding.new(
93
95
  check: "dynamic-script",
94
96
  severity: :error,
@@ -97,7 +99,7 @@ module Audition
97
99
  "Ractor, so Ractor-readiness cannot be assessed.",
98
100
  fix: "Make the script run standalone first.",
99
101
  path: path,
100
- line: nil
102
+ line: site&.last
101
103
  )
102
104
  end
103
105
  end
@@ -105,38 +107,51 @@ module Audition
105
107
  def probe_require(entry)
106
108
  feature = entry[:feature]
107
109
  raw = run("require",
108
- "feature" => feature,
109
- "load_paths" => Array(entry[:load_paths]),
110
- "root" => entry[:root],
111
- "known_compiled" => Array(entry[:compiled_files]))
112
- findings = runtime_findings(raw, feature)
110
+ {"feature" => feature,
111
+ "load_paths" => Array(entry[:load_paths]),
112
+ "root" => entry[:root],
113
+ "known_compiled" => Array(entry[:compiled_files]),
114
+ "max_constants" => entry[:max_constants]})
115
+ findings = runtime_findings(raw, feature,
116
+ root: entry[:root])
113
117
  Result.new(mode: :require, raw: raw, findings: findings,
114
118
  passed: own_clean?(findings))
115
119
  end
116
120
 
117
121
  def probe_rails(entry)
118
122
  raw = run("rails",
119
- "environment" => entry[:environment],
120
- "root" => entry[:root],
121
- "known_compiled" => Array(entry[:compiled_files]))
123
+ {"environment" => entry[:environment],
124
+ "root" => entry[:root],
125
+ "known_compiled" => Array(entry[:compiled_files]),
126
+ "max_constants" => entry[:max_constants]},
127
+ root: entry[:root])
122
128
  boot = raw["boot"]
129
+ findings = runtime_findings(raw, entry[:environment],
130
+ root: entry[:root])
123
131
  if boot && !boot["ok"]
124
- finding = Finding.new(
132
+ # The sweep of whatever loaded before the failure stays:
133
+ # a partial dynamic result beats none.
134
+ why = "Ractor-readiness cannot be fully assessed " \
135
+ "until the application boots."
136
+ if raw["scanned"].to_i.positive?
137
+ why += " Findings below cover what loaded before " \
138
+ "the failure."
139
+ end
140
+ site = failure_site(boot, prefix: own_prefix(entry[:root]))
141
+ findings.unshift(Finding.new(
125
142
  check: "dynamic-rails",
126
143
  severity: :error,
127
144
  message: "Rails failed to boot: #{describe(boot)}",
128
- why: "Ractor-readiness cannot be assessed until the " \
129
- "application boots.",
145
+ why: why,
130
146
  fix: "Boot the app (bin/rails runner 1) and fix " \
131
- "whatever breaks, then re-audition.",
132
- path: entry[:environment],
133
- line: nil
134
- )
147
+ "whatever breaks, then re-run Audition.",
148
+ path: site&.first || entry[:environment],
149
+ line: site&.last
150
+ ))
135
151
  return Result.new(mode: :rails, raw: raw,
136
- findings: [finding], passed: false)
152
+ findings: findings, passed: false)
137
153
  end
138
154
 
139
- findings = runtime_findings(raw, entry[:environment])
140
155
  Result.new(mode: :rails, raw: raw, findings: findings,
141
156
  passed: own_clean?(findings))
142
157
  end
@@ -150,10 +165,18 @@ module Audition
150
165
 
151
166
  def probe_rack(entry)
152
167
  config_ru = entry[:config_ru]
153
- raw = run("rack", "config_ru" => config_ru)
154
- findings = rack_findings(raw, config_ru)
168
+ root = entry[:root] || File.dirname(config_ru)
169
+ raw = run("rack",
170
+ {"config_ru" => config_ru,
171
+ "root" => root,
172
+ "known_compiled" => Array(entry[:compiled_files]),
173
+ "max_constants" => entry[:max_constants]},
174
+ root: root)
175
+ findings = rack_findings(raw, config_ru, root)
176
+ findings += runtime_findings(raw, config_ru, root: root) unless raw["error"]
155
177
  passed = raw.dig("ractor_boot_call", "ok") == true &&
156
- raw.dig("concurrency", "failures").to_i.zero?
178
+ raw.dig("concurrency", "failures").to_i.zero? &&
179
+ own_clean?(findings)
157
180
  Result.new(mode: :rack, raw: raw, findings: findings,
158
181
  passed: passed)
159
182
  end
@@ -166,19 +189,28 @@ module Audition
166
189
 
167
190
  # -- findings builders ---------------------------------------
168
191
 
169
- def runtime_findings(raw, label)
192
+ def runtime_findings(raw, label, root: nil)
170
193
  if raw["error"]
171
- return [load_failure_finding(raw, label)]
194
+ return [load_failure_finding(raw, label, root)]
172
195
  end
173
196
 
174
197
  findings = []
175
198
  raw.fetch("unshareable_constants", []).each do |entry|
199
+ blocker = entry["blocker"]
200
+ detail =
201
+ if blocker.nil? || blocker == entry["class"]
202
+ ""
203
+ elsif entry["blocker_nested"]
204
+ " (blocked by #{blocker} inside)"
205
+ else
206
+ " (just not frozen)"
207
+ end
176
208
  findings << runtime_finding(
177
209
  entry, label,
178
210
  check: "runtime-unshareable-constant",
179
211
  severity: :error,
180
212
  message: "constant #{entry["const"]} holds an " \
181
- "unshareable #{entry["class"]}",
213
+ "unshareable #{entry["class"]}#{detail}",
182
214
  why: "Reading it from a non-main Ractor raises " \
183
215
  "Ractor::IsolationError. #{RUNTIME_WHY}",
184
216
  fix: "Freeze it deeply at definition time " \
@@ -208,6 +240,22 @@ module Audition
208
240
 
209
241
  findings << native_finding(entry, label)
210
242
  end
243
+ # A truncated sweep must never read as a clean one.
244
+ if raw["truncated"]
245
+ findings.unshift(Finding.new(
246
+ check: "runtime-scan",
247
+ severity: :warning,
248
+ message: "constant sweep truncated after " \
249
+ "#{raw["scanned"]} constants " \
250
+ "(limit #{raw["limit"]})",
251
+ why: "Constants beyond the limit were never probed, " \
252
+ "so their absence from the findings proves " \
253
+ "nothing.",
254
+ fix: "Raise the probe's max_constants and re-run.",
255
+ path: label,
256
+ line: nil
257
+ ))
258
+ end
211
259
  findings
212
260
  end
213
261
 
@@ -292,7 +340,8 @@ module Audition
292
340
  )
293
341
  end
294
342
 
295
- def load_failure_finding(raw, label)
343
+ def load_failure_finding(raw, label, root = nil)
344
+ site = failure_site(raw, prefix: own_prefix(root))
296
345
  Finding.new(
297
346
  check: "runtime-load",
298
347
  severity: :error,
@@ -300,14 +349,14 @@ module Audition
300
349
  why: "Ractor-readiness cannot be assessed until the " \
301
350
  "target loads.",
302
351
  fix: "Make `require` succeed on a bare Ruby first.",
303
- path: label,
304
- line: nil
352
+ path: site&.first || label,
353
+ line: site&.last
305
354
  )
306
355
  end
307
356
 
308
- def rack_findings(raw, config_ru)
357
+ def rack_findings(raw, config_ru, root = nil)
309
358
  if raw.dig("ractor_boot_call", "ok")
310
- return concurrency_findings(raw, config_ru)
359
+ return concurrency_findings(raw, config_ru, root)
311
360
  end
312
361
 
313
362
  if raw["rack_available"] == false
@@ -316,7 +365,7 @@ module Audition
316
365
  severity: :warning,
317
366
  message: "rack gem not available in the probe process",
318
367
  why: "The rack probe boots the app via Rack::Builder.",
319
- fix: "Install rack next to audition and re-run.",
368
+ fix: "Install rack next to Audition and re-run.",
320
369
  path: config_ru,
321
370
  line: nil
322
371
  )]
@@ -332,6 +381,10 @@ module Audition
332
381
  "booting config.ru and serving one GET / inside a " \
333
382
  "Ractor failed."
334
383
  end
384
+ site = failure_site(raw["ractor_boot_call"],
385
+ prefix: own_prefix(root)) ||
386
+ failure_site({"error" => raw["main_boot_error"]},
387
+ prefix: own_prefix(root))
335
388
  [Finding.new(
336
389
  check: "dynamic-rack",
337
390
  severity: :error,
@@ -340,16 +393,18 @@ module Audition
340
393
  fix: "Remove global/class-level state touched during " \
341
394
  "boot and request handling; keep middleware config " \
342
395
  "frozen; open connections per-Ractor.",
343
- path: config_ru,
344
- line: nil
396
+ path: site&.first || config_ru,
397
+ line: site&.last
345
398
  )]
346
399
  end
347
400
 
348
- def concurrency_findings(raw, config_ru)
401
+ def concurrency_findings(raw, config_ru, root = nil)
349
402
  stats = raw["concurrency"] || {}
350
403
  failures = stats["failures"].to_i
351
404
  return [] if failures.zero?
352
405
 
406
+ site = failure_site({"error" => stats["first_error"]},
407
+ prefix: own_prefix(root))
353
408
  [Finding.new(
354
409
  check: "dynamic-rack-concurrency",
355
410
  severity: :error,
@@ -361,11 +416,43 @@ module Audition
361
416
  "shared state races. #{RUNTIME_WHY}",
362
417
  fix: "Look for process-global state touched during " \
363
418
  "request handling and boot.",
364
- path: config_ru,
365
- line: nil
419
+ path: site&.first || config_ru,
420
+ line: site&.last
366
421
  )]
367
422
  end
368
423
 
424
+ # A backtrace frame inside the target names the failing
425
+ # line; frames outside it stay unattributed rather than
426
+ # pinning a finding to a dependency's file.
427
+ BACKTRACE_FRAME = /\A(.+?):(\d+):in /
428
+
429
+ def failure_site(hash, prefix:)
430
+ prefixes = Array(prefix)
431
+ return nil if prefixes.empty? || !hash.is_a?(Hash)
432
+
433
+ error = hash["error"].is_a?(Hash) ? hash["error"] : hash
434
+ frame = Array(error["backtrace"]).find do |f|
435
+ f.is_a?(String) && prefixes.any? { |p| f.start_with?(p) }
436
+ end
437
+ match = frame&.match(BACKTRACE_FRAME)
438
+ match && [match[1], Integer(match[2], 10)]
439
+ end
440
+
441
+ # require realpaths frames while eval keeps paths as given,
442
+ # so a symlinked root (macOS /var) must match both spellings.
443
+ def own_prefix(root)
444
+ return nil if root.nil?
445
+
446
+ [root + File::SEPARATOR,
447
+ realpath(root) + File::SEPARATOR].uniq
448
+ end
449
+
450
+ def realpath(path)
451
+ File.realpath(path)
452
+ rescue SystemCallError
453
+ path
454
+ end
455
+
369
456
  def describe(hash)
370
457
  error = hash.is_a?(Hash) ? (hash["error"] || hash) : {}
371
458
  klass = error["class"] || "UnknownError"
@@ -378,8 +465,8 @@ module Audition
378
465
  # Harness output can carry arbitrary target bytes; force
379
466
  # valid UTF-8 before any string work or a binary exception
380
467
  # message crashes the whole run.
381
- def run(mode, payload = {})
382
- out, err, timed_out = execute(mode, payload)
468
+ def run(mode, payload = {}, root: nil)
469
+ out, err, timed_out = execute(mode, payload, root: root)
383
470
  out = sanitize(out)
384
471
  err = sanitize(err)
385
472
  if timed_out
@@ -405,9 +492,28 @@ module Audition
405
492
  # child the target spawned inherits our pipes and would
406
493
  # otherwise hold the read until it exits, defeating the
407
494
  # timeout and leaving orphans behind.
408
- def execute(mode, payload)
495
+ def execute(mode, payload, root: nil)
409
496
  cmd = [@ruby, "-W0", HARNESS, mode]
410
- Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait|
497
+ # An app boots against its own bundle: the subprocess runs
498
+ # from the target root with the target's Gemfile. When
499
+ # Audition itself runs under bundle exec, the inherited
500
+ # Bundler environment (RUBYOPT's -rbundler/setup above all)
501
+ # would activate Audition's bundle inside the child before
502
+ # the harness starts, so it is scrubbed first.
503
+ env = {}
504
+ opts = {pgroup: true}
505
+ if root && File.directory?(root)
506
+ opts[:chdir] = root
507
+ gemfile = File.join(root, "Gemfile")
508
+ if File.file?(gemfile)
509
+ ENV.each_key do |key|
510
+ env[key] = nil if key.start_with?("BUNDLE") ||
511
+ %w[RUBYOPT RUBYLIB].include?(key)
512
+ end
513
+ env["BUNDLE_GEMFILE"] = gemfile
514
+ end
515
+ end
516
+ Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait|
411
517
  stdin.write(JSON.generate(payload))
412
518
  stdin.close
413
519
  out_reader = reader(stdout)
@@ -64,12 +64,14 @@ module Audition
64
64
  # @return [Autofix, nil] machine-applicable correction
65
65
  # @!attribute [r] dependency
66
66
  # @return [Boolean] see {#dependency?}
67
+ # @!attribute [r] test
68
+ # @return [Boolean] see {#test?}
67
69
  Finding = Data.define(
68
70
  :check, :severity, :message, :why, :fix,
69
- :path, :line, :source, :autofix, :dependency
71
+ :path, :line, :source, :autofix, :dependency, :test
70
72
  ) do
71
73
  def initialize(source: nil, autofix: nil, dependency: false,
72
- **rest)
74
+ test: false, **rest)
73
75
  super
74
76
  end
75
77
 
@@ -82,6 +84,12 @@ module Audition
82
84
  # @return [Boolean]
83
85
  def dependency? = dependency
84
86
 
87
+ # True when the problem lives in the target's test or spec
88
+ # code: real, but never loaded by the production boot.
89
+ #
90
+ # @return [Boolean]
91
+ def test? = test
92
+
85
93
  # @return [Boolean] whether an {Autofix} is attached
86
94
  def fixable? = !autofix.nil?
87
95