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.
@@ -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 " \
@@ -38,7 +38,10 @@ module Audition
38
38
 
39
39
  # Runs the probe described by a {Target#entry} hash.
40
40
  #
41
- # @param entry [Hash] `:mode` plus mode-specific keys
41
+ # @param entry [Hash] `:mode` plus mode-specific keys; require
42
+ # and rails entries may carry `:compiled_files`, the target's
43
+ # own compiled extensions, which the static check already
44
+ # covers and the probe therefore does not report again
42
45
  # @return [Result]
43
46
  # @raise [Audition::Error] on an unknown mode
44
47
  def probe(entry)
@@ -57,13 +60,13 @@ module Audition
57
60
 
58
61
  def probe_script(entry)
59
62
  path = entry[:path]
60
- ractor = run("script_ractor", "path" => path)
63
+ ractor = run("script_ractor", {"path" => path})
61
64
  if ractor["ok"]
62
65
  return Result.new(mode: :script, raw: ractor,
63
66
  findings: [], passed: true)
64
67
  end
65
68
 
66
- main = run("script_main", "path" => path)
69
+ main = run("script_main", {"path" => path})
67
70
  finding = script_finding(path, ractor, main)
68
71
  Result.new(mode: :script,
69
72
  raw: {"ractor" => ractor, "main" => main},
@@ -73,6 +76,7 @@ module Audition
73
76
  def script_finding(path, ractor, main)
74
77
  if main["ok"]
75
78
  error = describe(ractor)
79
+ site = failure_site(ractor, prefix: "#{path}:")
76
80
  Finding.new(
77
81
  check: "dynamic-script",
78
82
  severity: :error,
@@ -81,11 +85,12 @@ module Audition
81
85
  "failed under Ractor.new; the static findings " \
82
86
  "usually pinpoint the exact line.",
83
87
  fix: "Fix the static findings for this file, then " \
84
- "re-audition.",
88
+ "re-run Audition.",
85
89
  path: path,
86
- line: nil
90
+ line: site&.last
87
91
  )
88
92
  else
93
+ site = failure_site(main, prefix: "#{path}:")
89
94
  Finding.new(
90
95
  check: "dynamic-script",
91
96
  severity: :error,
@@ -94,7 +99,7 @@ module Audition
94
99
  "Ractor, so Ractor-readiness cannot be assessed.",
95
100
  fix: "Make the script run standalone first.",
96
101
  path: path,
97
- line: nil
102
+ line: site&.last
98
103
  )
99
104
  end
100
105
  end
@@ -102,36 +107,51 @@ module Audition
102
107
  def probe_require(entry)
103
108
  feature = entry[:feature]
104
109
  raw = run("require",
105
- "feature" => feature,
106
- "load_paths" => Array(entry[:load_paths]),
107
- "root" => entry[:root])
108
- 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])
109
117
  Result.new(mode: :require, raw: raw, findings: findings,
110
118
  passed: own_clean?(findings))
111
119
  end
112
120
 
113
121
  def probe_rails(entry)
114
122
  raw = run("rails",
115
- "environment" => entry[:environment],
116
- "root" => entry[:root])
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])
117
128
  boot = raw["boot"]
129
+ findings = runtime_findings(raw, entry[:environment],
130
+ root: entry[:root])
118
131
  if boot && !boot["ok"]
119
- 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(
120
142
  check: "dynamic-rails",
121
143
  severity: :error,
122
144
  message: "Rails failed to boot: #{describe(boot)}",
123
- why: "Ractor-readiness cannot be assessed until the " \
124
- "application boots.",
145
+ why: why,
125
146
  fix: "Boot the app (bin/rails runner 1) and fix " \
126
- "whatever breaks, then re-audition.",
127
- path: entry[:environment],
128
- line: nil
129
- )
147
+ "whatever breaks, then re-run Audition.",
148
+ path: site&.first || entry[:environment],
149
+ line: site&.last
150
+ ))
130
151
  return Result.new(mode: :rails, raw: raw,
131
- findings: [finding], passed: false)
152
+ findings: findings, passed: false)
132
153
  end
133
154
 
134
- findings = runtime_findings(raw, entry[:environment])
135
155
  Result.new(mode: :rails, raw: raw, findings: findings,
136
156
  passed: own_clean?(findings))
137
157
  end
@@ -145,10 +165,18 @@ module Audition
145
165
 
146
166
  def probe_rack(entry)
147
167
  config_ru = entry[:config_ru]
148
- raw = run("rack", "config_ru" => config_ru)
149
- 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"]
150
177
  passed = raw.dig("ractor_boot_call", "ok") == true &&
151
- raw.dig("concurrency", "failures").to_i.zero?
178
+ raw.dig("concurrency", "failures").to_i.zero? &&
179
+ own_clean?(findings)
152
180
  Result.new(mode: :rack, raw: raw, findings: findings,
153
181
  passed: passed)
154
182
  end
@@ -161,19 +189,28 @@ module Audition
161
189
 
162
190
  # -- findings builders ---------------------------------------
163
191
 
164
- def runtime_findings(raw, label)
192
+ def runtime_findings(raw, label, root: nil)
165
193
  if raw["error"]
166
- return [load_failure_finding(raw, label)]
194
+ return [load_failure_finding(raw, label, root)]
167
195
  end
168
196
 
169
197
  findings = []
170
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
171
208
  findings << runtime_finding(
172
209
  entry, label,
173
210
  check: "runtime-unshareable-constant",
174
211
  severity: :error,
175
212
  message: "constant #{entry["const"]} holds an " \
176
- "unshareable #{entry["class"]}",
213
+ "unshareable #{entry["class"]}#{detail}",
177
214
  why: "Reading it from a non-main Ractor raises " \
178
215
  "Ractor::IsolationError. #{RUNTIME_WHY}",
179
216
  fix: "Freeze it deeply at definition time " \
@@ -198,9 +235,61 @@ module Audition
198
235
  "or Ractor-local storage."
199
236
  )
200
237
  end
238
+ raw.fetch("native_extensions", []).each do |entry|
239
+ next if entry["ruby"] || entry["known"]
240
+
241
+ findings << native_finding(entry, label)
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
201
259
  findings
202
260
  end
203
261
 
262
+ # Ruby's own extensions are left to Ruby, and files the static
263
+ # check already reported are not repeated; what remains is the
264
+ # native code the target pulls in through its dependencies.
265
+ def native_finding(entry, label)
266
+ name = File.basename(entry["path"])
267
+ native = Static::NativeExtensions
268
+ if entry["declares"]
269
+ runtime_finding(
270
+ entry, label,
271
+ check: "runtime-native-extension",
272
+ severity: :info,
273
+ message: "compiled extension #{name} declares Ractor " \
274
+ "safety (imports #{native::SYMBOL})",
275
+ why: "#{native::DECLARED_WHY} Loaded while requiring " \
276
+ "the target.",
277
+ fix: native::DECLARED_FIX
278
+ )
279
+ else
280
+ runtime_finding(
281
+ entry, label,
282
+ check: "runtime-native-extension",
283
+ severity: :warning,
284
+ message: "compiled extension #{name} does not declare " \
285
+ "Ractor safety",
286
+ why: "#{native::SILENT_WHY}#{native::COMPILED_TAIL} " \
287
+ "Loaded while requiring the target.",
288
+ fix: native::SILENT_FIX
289
+ )
290
+ end
291
+ end
292
+
204
293
  # Class-level state holding only shareable values is the
205
294
  # warmed frozen-memoization shape: reads are legal from any
206
295
  # Ractor, so it rates an info note; unshareable values are
@@ -251,7 +340,8 @@ module Audition
251
340
  )
252
341
  end
253
342
 
254
- def load_failure_finding(raw, label)
343
+ def load_failure_finding(raw, label, root = nil)
344
+ site = failure_site(raw, prefix: own_prefix(root))
255
345
  Finding.new(
256
346
  check: "runtime-load",
257
347
  severity: :error,
@@ -259,14 +349,14 @@ module Audition
259
349
  why: "Ractor-readiness cannot be assessed until the " \
260
350
  "target loads.",
261
351
  fix: "Make `require` succeed on a bare Ruby first.",
262
- path: label,
263
- line: nil
352
+ path: site&.first || label,
353
+ line: site&.last
264
354
  )
265
355
  end
266
356
 
267
- def rack_findings(raw, config_ru)
357
+ def rack_findings(raw, config_ru, root = nil)
268
358
  if raw.dig("ractor_boot_call", "ok")
269
- return concurrency_findings(raw, config_ru)
359
+ return concurrency_findings(raw, config_ru, root)
270
360
  end
271
361
 
272
362
  if raw["rack_available"] == false
@@ -275,7 +365,7 @@ module Audition
275
365
  severity: :warning,
276
366
  message: "rack gem not available in the probe process",
277
367
  why: "The rack probe boots the app via Rack::Builder.",
278
- fix: "Install rack next to audition and re-run.",
368
+ fix: "Install rack next to Audition and re-run.",
279
369
  path: config_ru,
280
370
  line: nil
281
371
  )]
@@ -291,6 +381,10 @@ module Audition
291
381
  "booting config.ru and serving one GET / inside a " \
292
382
  "Ractor failed."
293
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))
294
388
  [Finding.new(
295
389
  check: "dynamic-rack",
296
390
  severity: :error,
@@ -299,16 +393,18 @@ module Audition
299
393
  fix: "Remove global/class-level state touched during " \
300
394
  "boot and request handling; keep middleware config " \
301
395
  "frozen; open connections per-Ractor.",
302
- path: config_ru,
303
- line: nil
396
+ path: site&.first || config_ru,
397
+ line: site&.last
304
398
  )]
305
399
  end
306
400
 
307
- def concurrency_findings(raw, config_ru)
401
+ def concurrency_findings(raw, config_ru, root = nil)
308
402
  stats = raw["concurrency"] || {}
309
403
  failures = stats["failures"].to_i
310
404
  return [] if failures.zero?
311
405
 
406
+ site = failure_site({"error" => stats["first_error"]},
407
+ prefix: own_prefix(root))
312
408
  [Finding.new(
313
409
  check: "dynamic-rack-concurrency",
314
410
  severity: :error,
@@ -320,11 +416,43 @@ module Audition
320
416
  "shared state races. #{RUNTIME_WHY}",
321
417
  fix: "Look for process-global state touched during " \
322
418
  "request handling and boot.",
323
- path: config_ru,
324
- line: nil
419
+ path: site&.first || config_ru,
420
+ line: site&.last
325
421
  )]
326
422
  end
327
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
+
328
456
  def describe(hash)
329
457
  error = hash.is_a?(Hash) ? (hash["error"] || hash) : {}
330
458
  klass = error["class"] || "UnknownError"
@@ -337,8 +465,8 @@ module Audition
337
465
  # Harness output can carry arbitrary target bytes; force
338
466
  # valid UTF-8 before any string work or a binary exception
339
467
  # message crashes the whole run.
340
- def run(mode, payload = {})
341
- out, err, timed_out = execute(mode, payload)
468
+ def run(mode, payload = {}, root: nil)
469
+ out, err, timed_out = execute(mode, payload, root: root)
342
470
  out = sanitize(out)
343
471
  err = sanitize(err)
344
472
  if timed_out
@@ -364,9 +492,28 @@ module Audition
364
492
  # child the target spawned inherits our pipes and would
365
493
  # otherwise hold the read until it exits, defeating the
366
494
  # timeout and leaving orphans behind.
367
- def execute(mode, payload)
495
+ def execute(mode, payload, root: nil)
368
496
  cmd = [@ruby, "-W0", HARNESS, mode]
369
- 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|
370
517
  stdin.write(JSON.generate(payload))
371
518
  stdin.close
372
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