audition 0.3.0 → 0.4.1

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,17 +12,17 @@ module Audition
12
12
  # @return [Symbol] `:script`, `:require`, `:rack`, `:rails`,
13
13
  # or `:capabilities`
14
14
  # @!attribute [r] raw
15
- # @return [Hash] the harness's parsed JSON, verbatim
15
+ # @return [Hash] the harness's document, verbatim
16
16
  # @!attribute [r] findings
17
17
  # @return [Array<Finding>] findings derived from `raw`
18
18
  # @!attribute [r] passed
19
19
  # @return [Boolean] whether the target's own surface passed
20
20
  Result = Data.define(:mode, :raw, :findings, :passed)
21
21
 
22
- # Spawns the harness subprocess per probe mode, parses its JSON,
23
- # and converts observations into findings.
22
+ # Spawns the harness subprocess per probe mode, loads the
23
+ # document it prints, 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,42 +107,144 @@ 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])
155
+ findings.concat(ractorize_findings(raw["ractorize"], entry))
140
156
  Result.new(mode: :rails, raw: raw, findings: findings,
141
157
  passed: own_clean?(findings))
142
158
  end
143
159
 
160
+ # What happened once the booted application was frozen: no
161
+ # entry point on this Rails, the first object it could not
162
+ # share, a request that broke on the frozen graph, or one
163
+ # that only broke inside a worker.
164
+ def ractorize_findings(info, entry)
165
+ return [] unless info.is_a?(Hash)
166
+
167
+ environment = entry[:environment]
168
+ prefix = own_prefix(entry[:root])
169
+ unless info["available"]
170
+ return [Finding.new(
171
+ check: "dynamic-rails",
172
+ severity: :info,
173
+ message: "Rails #{info["rails"]} has no ractorize!; " \
174
+ "the application graph was not frozen",
175
+ why: "Rails 8.2 adds Rails::Application#ractorize!, " \
176
+ "which deep-freezes the application and " \
177
+ "everything it reaches; a lazy memoization on " \
178
+ "any object in that graph raises FrozenError " \
179
+ "afterwards. Without it the probe can only " \
180
+ "sweep constants and class-level state.",
181
+ fix: "Upgrade to Rails 8.2 so the probe can freeze " \
182
+ "the application and serve a request through it.",
183
+ path: environment,
184
+ line: nil
185
+ )]
186
+ end
187
+
188
+ unless info["ok"]
189
+ site = failure_site(info, prefix: prefix)
190
+ return [Finding.new(
191
+ check: "dynamic-rails",
192
+ severity: :error,
193
+ message: "ractorize! failed: #{describe(info)}",
194
+ why: "Rails::Application#ractorize! deep-freezes the " \
195
+ "application and everything it reaches (routes, " \
196
+ "middleware, configuration); the object the " \
197
+ "error names is the first one that cannot be " \
198
+ "shared. #{RUNTIME_WHY}",
199
+ fix: "Make the named object shareable: freeze it, " \
200
+ "drop the Proc, Mutex, or IO it holds, or keep " \
201
+ "it per-Ractor; then re-run.",
202
+ path: site&.first || environment,
203
+ line: site&.last
204
+ )]
205
+ end
206
+
207
+ main = info["main_request"] || {}
208
+ unless main["ok"]
209
+ site = failure_site(main, prefix: prefix)
210
+ return [Finding.new(
211
+ check: "dynamic-rails",
212
+ severity: :error,
213
+ message: "GET / after ractorize! failed on the main " \
214
+ "Ractor: #{describe(main)}",
215
+ why: "The application is frozen, so a lazy " \
216
+ "memoization on any object the request touches " \
217
+ "writes an instance variable on a frozen object " \
218
+ "and raises FrozenError. #{RUNTIME_WHY}",
219
+ fix: "Compute the value eagerly in initialize, warm " \
220
+ "it in a freeze override that calls the reader " \
221
+ "before super, or drop the memo and recompute.",
222
+ path: site&.first || environment,
223
+ line: site&.last
224
+ )]
225
+ end
226
+
227
+ ractor = info["ractor_request"] || {}
228
+ return [] if ractor["ok"]
229
+
230
+ site = failure_site(ractor, prefix: prefix)
231
+ [Finding.new(
232
+ check: "dynamic-rails",
233
+ severity: :error,
234
+ message: "GET / inside a Ractor after ractorize! " \
235
+ "failed: #{describe(ractor)}",
236
+ why: "Serving on the main Ractor after ractorize! " \
237
+ "worked; inside a worker the request touched " \
238
+ "state a non-main Ractor cannot reach. " \
239
+ "#{RUNTIME_WHY}",
240
+ fix: "Remove global and class-level state touched " \
241
+ "during request handling; keep per-Ractor state " \
242
+ "in Ractor.store_if_absent.",
243
+ path: site&.first || environment,
244
+ line: site&.last
245
+ )]
246
+ end
247
+
144
248
  # A probe passes when the target's own surface is clean;
145
249
  # dependency errors surface in the findings and drive the
146
250
  # blocked verdict instead.
@@ -150,10 +254,18 @@ module Audition
150
254
 
151
255
  def probe_rack(entry)
152
256
  config_ru = entry[:config_ru]
153
- raw = run("rack", "config_ru" => config_ru)
154
- findings = rack_findings(raw, config_ru)
257
+ root = entry[:root] || File.dirname(config_ru)
258
+ raw = run("rack",
259
+ {"config_ru" => config_ru,
260
+ "root" => root,
261
+ "known_compiled" => Array(entry[:compiled_files]),
262
+ "max_constants" => entry[:max_constants]},
263
+ root: root)
264
+ findings = rack_findings(raw, config_ru, root)
265
+ findings += runtime_findings(raw, config_ru, root: root) unless raw["error"]
155
266
  passed = raw.dig("ractor_boot_call", "ok") == true &&
156
- raw.dig("concurrency", "failures").to_i.zero?
267
+ raw.dig("concurrency", "failures").to_i.zero? &&
268
+ own_clean?(findings)
157
269
  Result.new(mode: :rack, raw: raw, findings: findings,
158
270
  passed: passed)
159
271
  end
@@ -166,19 +278,28 @@ module Audition
166
278
 
167
279
  # -- findings builders ---------------------------------------
168
280
 
169
- def runtime_findings(raw, label)
281
+ def runtime_findings(raw, label, root: nil)
170
282
  if raw["error"]
171
- return [load_failure_finding(raw, label)]
283
+ return [load_failure_finding(raw, label, root)]
172
284
  end
173
285
 
174
286
  findings = []
175
287
  raw.fetch("unshareable_constants", []).each do |entry|
288
+ blocker = entry["blocker"]
289
+ detail =
290
+ if blocker.nil? || blocker == entry["class"]
291
+ ""
292
+ elsif entry["blocker_nested"]
293
+ " (blocked by #{blocker} inside)"
294
+ else
295
+ " (just not frozen)"
296
+ end
176
297
  findings << runtime_finding(
177
298
  entry, label,
178
299
  check: "runtime-unshareable-constant",
179
300
  severity: :error,
180
301
  message: "constant #{entry["const"]} holds an " \
181
- "unshareable #{entry["class"]}",
302
+ "unshareable #{entry["class"]}#{detail}",
182
303
  why: "Reading it from a non-main Ractor raises " \
183
304
  "Ractor::IsolationError. #{RUNTIME_WHY}",
184
305
  fix: "Freeze it deeply at definition time " \
@@ -188,6 +309,24 @@ module Audition
188
309
  raw.fetch("class_state", []).each do |entry|
189
310
  findings << class_state_finding(entry, label)
190
311
  end
312
+ raw.fetch("unshareable_procs", []).each do |entry|
313
+ findings << runtime_finding(
314
+ entry, label,
315
+ check: "runtime-unshareable-proc",
316
+ severity: :warning,
317
+ message: "Rails could not make a callback block " \
318
+ "Ractor-shareable: #{entry["proc"]}",
319
+ why: "With unshareable_proc_action set to :warn, " \
320
+ "Rails ran Ractor.shareable_proc on this block " \
321
+ "and it raised Ractor::IsolationError, so the " \
322
+ "callback keeps an unshareable Proc; a non-main " \
323
+ "Ractor running it raises. #{RUNTIME_WHY}",
324
+ fix: "Capture only shareable values: freeze the " \
325
+ "local, inline it, or hoist a shareable leaf " \
326
+ "such as a Symbol into a fresh local assigned " \
327
+ "once before the block."
328
+ )
329
+ end
191
330
  raw.fetch("class_variables", []).each do |entry|
192
331
  findings << runtime_finding(
193
332
  entry, label,
@@ -208,6 +347,22 @@ module Audition
208
347
 
209
348
  findings << native_finding(entry, label)
210
349
  end
350
+ # A truncated sweep must never read as a clean one.
351
+ if raw["truncated"]
352
+ findings.unshift(Finding.new(
353
+ check: "runtime-scan",
354
+ severity: :warning,
355
+ message: "constant sweep truncated after " \
356
+ "#{raw["scanned"]} constants " \
357
+ "(limit #{raw["limit"]})",
358
+ why: "Constants beyond the limit were never probed, " \
359
+ "so their absence from the findings proves " \
360
+ "nothing.",
361
+ fix: "Raise the probe's max_constants and re-run.",
362
+ path: label,
363
+ line: nil
364
+ ))
365
+ end
211
366
  findings
212
367
  end
213
368
 
@@ -292,7 +447,8 @@ module Audition
292
447
  )
293
448
  end
294
449
 
295
- def load_failure_finding(raw, label)
450
+ def load_failure_finding(raw, label, root = nil)
451
+ site = failure_site(raw, prefix: own_prefix(root))
296
452
  Finding.new(
297
453
  check: "runtime-load",
298
454
  severity: :error,
@@ -300,14 +456,14 @@ module Audition
300
456
  why: "Ractor-readiness cannot be assessed until the " \
301
457
  "target loads.",
302
458
  fix: "Make `require` succeed on a bare Ruby first.",
303
- path: label,
304
- line: nil
459
+ path: site&.first || label,
460
+ line: site&.last
305
461
  )
306
462
  end
307
463
 
308
- def rack_findings(raw, config_ru)
464
+ def rack_findings(raw, config_ru, root = nil)
309
465
  if raw.dig("ractor_boot_call", "ok")
310
- return concurrency_findings(raw, config_ru)
466
+ return concurrency_findings(raw, config_ru, root)
311
467
  end
312
468
 
313
469
  if raw["rack_available"] == false
@@ -316,7 +472,7 @@ module Audition
316
472
  severity: :warning,
317
473
  message: "rack gem not available in the probe process",
318
474
  why: "The rack probe boots the app via Rack::Builder.",
319
- fix: "Install rack next to audition and re-run.",
475
+ fix: "Install rack next to Audition and re-run.",
320
476
  path: config_ru,
321
477
  line: nil
322
478
  )]
@@ -332,6 +488,10 @@ module Audition
332
488
  "booting config.ru and serving one GET / inside a " \
333
489
  "Ractor failed."
334
490
  end
491
+ site = failure_site(raw["ractor_boot_call"],
492
+ prefix: own_prefix(root)) ||
493
+ failure_site({"error" => raw["main_boot_error"]},
494
+ prefix: own_prefix(root))
335
495
  [Finding.new(
336
496
  check: "dynamic-rack",
337
497
  severity: :error,
@@ -340,16 +500,18 @@ module Audition
340
500
  fix: "Remove global/class-level state touched during " \
341
501
  "boot and request handling; keep middleware config " \
342
502
  "frozen; open connections per-Ractor.",
343
- path: config_ru,
344
- line: nil
503
+ path: site&.first || config_ru,
504
+ line: site&.last
345
505
  )]
346
506
  end
347
507
 
348
- def concurrency_findings(raw, config_ru)
508
+ def concurrency_findings(raw, config_ru, root = nil)
349
509
  stats = raw["concurrency"] || {}
350
510
  failures = stats["failures"].to_i
351
511
  return [] if failures.zero?
352
512
 
513
+ site = failure_site({"error" => stats["first_error"]},
514
+ prefix: own_prefix(root))
353
515
  [Finding.new(
354
516
  check: "dynamic-rack-concurrency",
355
517
  severity: :error,
@@ -361,11 +523,43 @@ module Audition
361
523
  "shared state races. #{RUNTIME_WHY}",
362
524
  fix: "Look for process-global state touched during " \
363
525
  "request handling and boot.",
364
- path: config_ru,
365
- line: nil
526
+ path: site&.first || config_ru,
527
+ line: site&.last
366
528
  )]
367
529
  end
368
530
 
531
+ # A backtrace frame inside the target names the failing
532
+ # line; frames outside it stay unattributed rather than
533
+ # pinning a finding to a dependency's file.
534
+ BACKTRACE_FRAME = /\A(.+?):(\d+):in /
535
+
536
+ def failure_site(hash, prefix:)
537
+ prefixes = Array(prefix)
538
+ return nil if prefixes.empty? || !hash.is_a?(Hash)
539
+
540
+ error = hash["error"].is_a?(Hash) ? hash["error"] : hash
541
+ frame = Array(error["backtrace"]).find do |f|
542
+ f.is_a?(String) && prefixes.any? { |p| f.start_with?(p) }
543
+ end
544
+ match = frame&.match(BACKTRACE_FRAME)
545
+ match && [match[1], Integer(match[2], 10)]
546
+ end
547
+
548
+ # require realpaths frames while eval keeps paths as given,
549
+ # so a symlinked root (macOS /var) must match both spellings.
550
+ def own_prefix(root)
551
+ return nil if root.nil?
552
+
553
+ [root + File::SEPARATOR,
554
+ realpath(root) + File::SEPARATOR].uniq
555
+ end
556
+
557
+ def realpath(path)
558
+ File.realpath(path)
559
+ rescue SystemCallError
560
+ path
561
+ end
562
+
369
563
  def describe(hash)
370
564
  error = hash.is_a?(Hash) ? (hash["error"] || hash) : {}
371
565
  klass = error["class"] || "UnknownError"
@@ -375,12 +569,12 @@ module Audition
375
569
 
376
570
  # -- subprocess plumbing -------------------------------------
377
571
 
378
- # Harness output can carry arbitrary target bytes; force
379
- # valid UTF-8 before any string work or a binary exception
380
- # message crashes the whole run.
381
- def run(mode, payload = {})
382
- out, err, timed_out = execute(mode, payload)
383
- out = sanitize(out)
572
+ # The harness prints one Marshal document; anything else on
573
+ # its stdout (a crash before the document) is reported with
574
+ # the tail of its stderr. Stderr can carry arbitrary target
575
+ # bytes, so it is forced to valid UTF-8 first.
576
+ def run(mode, payload = {}, root: nil)
577
+ out, err, timed_out = execute(mode, payload, root: root)
384
578
  err = sanitize(err)
385
579
  if timed_out
386
580
  return {"error" => {
@@ -388,8 +582,11 @@ module Audition
388
582
  "message" => "harness exceeded #{@timeout}s"
389
583
  }}
390
584
  end
391
- JSON.parse(out)
392
- rescue JSON::ParserError
585
+ document = Marshal.load(out)
586
+ raise TypeError, "not a document" unless document.is_a?(Hash)
587
+
588
+ document
589
+ rescue TypeError, ArgumentError, EOFError
393
590
  {"error" => {
394
591
  "class" => "HarnessFailure",
395
592
  "message" => err.split("\n").last(5).join("; ")
@@ -405,10 +602,30 @@ module Audition
405
602
  # child the target spawned inherits our pipes and would
406
603
  # otherwise hold the read until it exits, defeating the
407
604
  # timeout and leaving orphans behind.
408
- def execute(mode, payload)
605
+ def execute(mode, payload, root: nil)
409
606
  cmd = [@ruby, "-W0", HARNESS, mode]
410
- Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait|
411
- stdin.write(JSON.generate(payload))
607
+ # An app boots against its own bundle: the subprocess runs
608
+ # from the target root with the target's Gemfile. When
609
+ # Audition itself runs under bundle exec, the inherited
610
+ # Bundler environment (RUBYOPT's -rbundler/setup above all)
611
+ # would activate Audition's bundle inside the child before
612
+ # the harness starts, so it is scrubbed first.
613
+ env = {}
614
+ opts = {pgroup: true}
615
+ if root && File.directory?(root)
616
+ opts[:chdir] = root
617
+ gemfile = File.join(root, "Gemfile")
618
+ if File.file?(gemfile)
619
+ ENV.each_key do |key|
620
+ env[key] = nil if key.start_with?("BUNDLE") ||
621
+ %w[RUBYOPT RUBYLIB].include?(key)
622
+ end
623
+ env["BUNDLE_GEMFILE"] = gemfile
624
+ end
625
+ end
626
+ Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait|
627
+ stdin.binmode
628
+ stdin.write(Marshal.dump(payload))
412
629
  stdin.close
413
630
  out_reader = reader(stdout)
414
631
  err_reader = reader(stderr)
@@ -64,12 +64,18 @@ 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?}
69
+ # @!attribute [r] subject
70
+ # @return [String, nil] the object a finding is about, in a
71
+ # form the dynamic probe can match ("Owner/@ivar"), so a
72
+ # runtime proof of shareability can retire the static guess
67
73
  Finding = Data.define(
68
74
  :check, :severity, :message, :why, :fix,
69
- :path, :line, :source, :autofix, :dependency
75
+ :path, :line, :source, :autofix, :dependency, :test, :subject
70
76
  ) do
71
77
  def initialize(source: nil, autofix: nil, dependency: false,
72
- **rest)
78
+ test: false, subject: nil, **rest)
73
79
  super
74
80
  end
75
81
 
@@ -82,6 +88,12 @@ module Audition
82
88
  # @return [Boolean]
83
89
  def dependency? = dependency
84
90
 
91
+ # True when the problem lives in the target's test or spec
92
+ # code: real, but never loaded by the production boot.
93
+ #
94
+ # @return [Boolean]
95
+ def test? = test
96
+
85
97
  # @return [Boolean] whether an {Autofix} is attached
86
98
  def fixable? = !autofix.nil?
87
99