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.
@@ -5,17 +5,23 @@
5
5
  #
6
6
  # ruby harness.rb MODE < payload.json
7
7
  #
8
- # Prints exactly one JSON document on stdout and never raises.
9
- # Stdlib only; must stay runnable on a bare Ruby 4.0.
8
+ # Prints exactly one Marshal document on stdout and never raises.
9
+ # Stdlib only, and nothing beyond rbconfig loaded before the
10
+ # target: a library the harness required first (json, once)
11
+ # would be swept as pre-existing and its own state never seen.
12
+ # Must stay runnable on a bare Ruby 4.0.
10
13
 
11
14
  Warning[:experimental] = false
12
15
  Thread.report_on_exception = false
13
16
 
14
- require "json"
15
17
  require "rbconfig"
16
18
 
17
19
  module AuditionHarness
18
- MAX_CONSTS = 5000
20
+ # High enough for the largest targets: the sweep is the backstop
21
+ # for everything static analysis cannot prove, so it must reach
22
+ # every constant the boot defined. A hit is reported as
23
+ # truncated, never silent.
24
+ MAX_CONSTS = 200_000
19
25
 
20
26
  # Directories under the target root that are not the target's
21
27
  # own surface. Bundler's deployment mode (and bundler-cache in
@@ -66,23 +72,42 @@ module AuditionHarness
66
72
  else {"error" => {"class" => "ArgumentError",
67
73
  "message" => "unknown mode #{mode}"}}
68
74
  end
69
- out.puts(JSON.generate(result))
75
+ emit(out, result)
70
76
  rescue Exception => e
71
77
  begin
72
- out.puts(JSON.generate("error" => describe_error(e)))
78
+ emit(out, "error" => describe_error(e))
73
79
  rescue Exception
74
- out.puts('{"error":{"class":"HarnessFailure",' \
75
- '"message":"unreportable error"}}')
80
+ emit(out, "error" => {"class" => "HarnessFailure",
81
+ "message" => "unreportable error"})
76
82
  end
77
83
  end
78
84
 
85
+ # One Marshal document: the prober loads it back into the same
86
+ # strings, arrays, and hashes, binary bytes included.
87
+ def emit(out, result)
88
+ out.binmode
89
+ out.write(Marshal.dump(result))
90
+ out.flush
91
+ end
92
+
79
93
  # Exception messages can carry arbitrary bytes (C extensions,
80
- # binary filenames); unscrubbed they blow up JSON.generate
81
- # inside the rescue and the harness dies without output.
94
+ # binary filenames); scrubbed to UTF-8 so the report can print
95
+ # them.
82
96
  def describe_error(error)
83
97
  root = unwrap(error)
84
98
  {"class" => scrub(root.class.name.to_s),
85
- "message" => scrub(root.message.to_s)[0, 500]}
99
+ "message" => scrub(root.message.to_s)[0, 500],
100
+ "backtrace" => backtrace_for(root)}
101
+ end
102
+
103
+ # Enough frames to reach the target's code under framework
104
+ # wrappers; frames carry paths, which can carry arbitrary bytes.
105
+ def backtrace_for(error)
106
+ Array(error.backtrace).first(30).map do |frame|
107
+ scrub(frame.to_s)[0, 300]
108
+ end
109
+ rescue Exception
110
+ []
86
111
  end
87
112
 
88
113
  def scrub(text)
@@ -101,12 +126,13 @@ module AuditionHarness
101
126
 
102
127
  def in_ractor(*args, &block)
103
128
  ractor = Ractor.new(*args, &block)
104
- {"ok" => true, "value" => jsonable(ractor.value)}
129
+ {"ok" => true, "value" => plain_value(ractor.value)}
105
130
  rescue Exception => e
106
131
  {"ok" => false, "error" => describe_error(e)}
107
132
  end
108
133
 
109
- def jsonable(value)
134
+ # Only primitives cross the pipe; anything else is described.
135
+ def plain_value(value)
110
136
  case value
111
137
  when Numeric, String, Symbol, true, false, nil then value
112
138
  else value.inspect[0, 200]
@@ -137,32 +163,51 @@ module AuditionHarness
137
163
  Array(payload["load_paths"]).each do |lp|
138
164
  $LOAD_PATH.unshift(lp) # audition:disable global-variables
139
165
  end
166
+ root = payload["root"]
167
+ limit = payload["max_constants"]
168
+ known = known_paths(payload["known_compiled"])
169
+ baseline = module_state_snapshot
140
170
  before = Object.constants
141
171
  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(
172
+ require_target(payload.fetch("feature"),
173
+ Array(payload["load_paths"]))
174
+ merge_reopened(
175
+ scan(top_level(before), root: root, limit: limit, known: known),
176
+ baseline, root: root, limit: limit, known: known
177
+ ).merge(
144
178
  "native_extensions" => native_extensions(
145
- features, payload["root"], payload["known_compiled"]
179
+ features, root, payload["known_compiled"]
146
180
  )
147
181
  )
148
182
  end
149
183
 
184
+ # The top-level constants a load introduced, as scan roots.
185
+ def top_level(before)
186
+ (Object.constants - before).map { |name| [Object, name.to_s] }
187
+ end
188
+
189
+ # The target's own compiled files, realpathed like everything
190
+ # the sweep compares against them.
191
+ def known_paths(paths)
192
+ Array(paths).map { |path| realpath(path) }
193
+ end
194
+
150
195
  # Gem names and entry files diverge in two conventional ways:
151
196
  # dashed names ship slashed files (rspec-mocks provides
152
197
  # rspec/mocks), and squashed names ship snake_case files
153
198
  # (activesupport provides active_support). The second has no
154
199
  # 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
200
+ # file on its load paths, that file is the entry, required by
201
+ # absolute path. An absolute require keeps the path as given, and scan
157
202
  # compares constant origins against the realpathed root, so the
158
203
  # candidate is built from the realpath too (a symlinked tmpdir,
159
204
  # macOS /var, otherwise turns own findings into dependency ones).
160
205
  # The error reported is the last one seen: a candidate that loads
161
206
  # but fails inside says more than "cannot load such file".
162
- def require_target(feature, root)
207
+ def require_target(feature, load_paths)
163
208
  require feature # audition:disable runtime-require
164
209
  rescue LoadError => error
165
- entry_candidates(feature, root).each do |candidate|
210
+ entry_candidates(feature, load_paths).each do |candidate|
166
211
  return require candidate # audition:disable runtime-require
167
212
  rescue LoadError => e
168
213
  error = e
@@ -170,11 +215,13 @@ module AuditionHarness
170
215
  raise error
171
216
  end
172
217
 
173
- def entry_candidates(feature, root)
218
+ def entry_candidates(feature, load_paths)
174
219
  candidates = []
175
220
  slashed = feature.tr("-", "/")
176
221
  candidates << slashed if slashed != feature
177
- files = root ? Dir[File.join(realpath(root), "lib", "*.rb")] : []
222
+ files = load_paths.flat_map do |path|
223
+ Dir[File.join(realpath(path), "*.rb")]
224
+ end
178
225
  candidates << files.first.delete_suffix(".rb") if files.size == 1
179
226
  candidates
180
227
  end
@@ -184,23 +231,29 @@ module AuditionHarness
184
231
  # are inspected for class-level ivars and class variables, then
185
232
  # descended into. const_get can raise (autoload failures) and
186
233
  # anything can lie; every step is rescued and counted.
187
- def scan(root_names, root: nil)
234
+ def scan(roots, root: nil, limit: nil, known: [])
188
235
  # Loaded features are realpathed by require; the target root
189
236
  # must be too, or symlinked paths (macOS /var vs /private/var)
190
237
  # break the own-vs-dependency comparison.
191
238
  root = realpath(root)
239
+ limit = (limit || MAX_CONSTS).to_i
192
240
  unshareable = []
241
+ proven = []
193
242
  class_state = []
194
243
  class_vars = []
195
244
  errors = 0
245
+ truncated = false
196
246
  seen = {}
197
- queue = root_names.map { |name| [Object, name.to_s] }
247
+ queue = roots.map { |owner, name| [owner, name.to_s] }
198
248
  visited = 0
199
249
 
200
250
  until queue.empty?
251
+ if visited >= limit
252
+ truncated = true
253
+ break
254
+ end
201
255
  owner, name = queue.shift
202
256
  visited += 1
203
- break if visited > MAX_CONSTS
204
257
 
205
258
  begin
206
259
  value = owner.const_get(name, false)
@@ -209,7 +262,7 @@ module AuditionHarness
209
262
  next
210
263
  end
211
264
  full = owner.equal?(Object) ? name : "#{owner}::#{name}"
212
- origin = origin_for(owner, name, root)
265
+ origin = origin_for(owner, name, root, known)
213
266
 
214
267
  if value.is_a?(Module)
215
268
  next if seen[value.object_id]
@@ -222,9 +275,15 @@ module AuditionHarness
222
275
  end
223
276
  else
224
277
  begin
225
- unless Ractor.shareable?(value)
278
+ if Ractor.shareable?(value)
279
+ # Proof the static pass can retire its guesses with.
280
+ proven << [origin["path"], origin["line"]] if origin["path"]
281
+ else
282
+ blocker, blocker_depth = blocker_for(value)
226
283
  unshareable << origin.merge(
227
- "const" => full, "class" => value.class.name
284
+ "const" => full, "class" => value.class.name,
285
+ "blocker" => blocker,
286
+ "blocker_nested" => blocker_depth.positive?
228
287
  )
229
288
  end
230
289
  rescue Exception
@@ -234,17 +293,21 @@ module AuditionHarness
234
293
  end
235
294
 
236
295
  {"unshareable_constants" => unshareable,
296
+ "proven_constants" => proven,
237
297
  "class_state" => class_state,
238
298
  "class_variables" => class_vars,
239
299
  "scanned" => visited,
300
+ "truncated" => truncated,
301
+ "limit" => limit,
240
302
  "errors" => errors}
241
303
  end
242
304
 
243
305
  # Where was this constant defined, and does that location belong
244
306
  # to the audited target (as opposed to a dependency it loaded)?
245
- # Unknown locations (C extensions, core) count as own so nothing
246
- # gets silently downgraded.
247
- def origin_for(owner, name, root)
307
+ # Unknown locations (core) count as own so nothing gets silently
308
+ # downgraded, and so does the target's own compiled extension,
309
+ # which RubyGems installs outside the gem's root.
310
+ def origin_for(owner, name, root, known = [])
248
311
  path, line = begin
249
312
  owner.const_source_location(name)
250
313
  rescue Exception
@@ -252,11 +315,23 @@ module AuditionHarness
252
315
  end
253
316
  # The separator matters: /x/app must not claim /x/app-helpers.
254
317
  own = root.nil? || path.nil? || path == root ||
318
+ own_compiled?(path, root, known) ||
255
319
  (path.start_with?(root + File::SEPARATOR) &&
256
320
  !excluded?(path, root))
257
321
  {"path" => path, "line" => line, "own" => own}
258
322
  end
259
323
 
324
+ # A compiled file the target listed, or the copy of it RubyGems
325
+ # built into its extensions directory, which sits outside the
326
+ # gem's root under a directory named after the gem
327
+ # (extensions/<platform>/<abi>/stringio-3.2.0/stringio.bundle).
328
+ def own_compiled?(path, root, known)
329
+ return true if known.include?(path) || known.include?(realpath(path))
330
+ return false unless path.match?(NATIVE) && root
331
+
332
+ path.include?(File::SEPARATOR + File.basename(root) + File::SEPARATOR)
333
+ end
334
+
260
335
  # Matches the static scanner's exclusion rule: any excluded or
261
336
  # dot-prefixed component in the root-relative path means the
262
337
  # file is not the target's own code.
@@ -340,6 +415,147 @@ module AuditionHarness
340
415
  false
341
416
  end
342
417
 
418
+ # Ivar/cvar state of every module defined before the boot. The
419
+ # new-constants sweep never revisits these, so state the boot
420
+ # plants on reopened core and stdlib classes is diffed against
421
+ # this snapshot instead. Autoload stubs are left untouched:
422
+ # forcing them here would load code behind the back of the
423
+ # before/after constant accounting.
424
+ def module_state_snapshot(limit = 50_000)
425
+ snap = {}
426
+ seen = {}
427
+ queue = Object.constants.map { |name| [Object, name.to_s] }
428
+ until queue.empty? || snap.size >= limit
429
+ owner, name = queue.shift
430
+ begin
431
+ next if owner.autoload?(name, false)
432
+
433
+ value = owner.const_get(name, false)
434
+ rescue Exception
435
+ next
436
+ end
437
+ next unless value.is_a?(Module)
438
+ next if seen[value.object_id]
439
+
440
+ seen[value.object_id] = true
441
+ begin
442
+ snap[value] = [value.instance_variables,
443
+ value.class_variables(false), value.constants(false)]
444
+ rescue Exception
445
+ next
446
+ end
447
+ value.constants(false).each do |child|
448
+ queue << [value, child.to_s]
449
+ end
450
+ end
451
+ snap
452
+ end
453
+
454
+ # RubyGems, Bundler, and the VM mutate their own module state on
455
+ # every require; that is probe machinery, not the target's doing.
456
+ MACHINERY = /\A(?:Gem|Bundler|RubyVM)(?:::|\z)/
457
+
458
+ # State the boot added to pre-existing modules, reported through
459
+ # the same channels as freshly defined class state, and the
460
+ # constants it added under them, swept like top-level ones
461
+ # (Ractor::Dispatch lives under the core Ractor class, and the
462
+ # new-constants sweep never looks there). No source location
463
+ # exists for a reopen, and unknown origins count as own.
464
+ def merge_reopened(result, snapshot, root: nil, limit: nil,
465
+ known: [])
466
+ origin = {"path" => nil, "line" => nil, "own" => true}
467
+ added = []
468
+ snapshot.each do |mod, (ivars, cvars, consts)|
469
+ full = mod.name || mod.inspect
470
+ next if full.match?(MACHINERY)
471
+
472
+ unless mod.equal?(Object)
473
+ new_consts = begin
474
+ mod.constants(false) - consts
475
+ rescue Exception
476
+ []
477
+ end
478
+ new_consts.each { |name| added << [mod, name.to_s] }
479
+ end
480
+ new_ivars = mod.instance_variables - ivars
481
+ new_cvars = mod.class_variables(false) - cvars
482
+ next if new_ivars.empty? && new_cvars.empty?
483
+
484
+ if new_ivars.any?
485
+ unshareable = new_ivars.reject do |ivar|
486
+ safe_shareable?(mod.instance_variable_get(ivar))
487
+ end
488
+ result["class_state"] << origin.merge(
489
+ "const" => full,
490
+ "ivars" => new_ivars.map(&:to_s),
491
+ "unshareable" => unshareable.map(&:to_s)
492
+ )
493
+ end
494
+ if new_cvars.any?
495
+ result["class_variables"] << origin.merge(
496
+ "const" => full, "cvars" => new_cvars.map(&:to_s)
497
+ )
498
+ end
499
+ end
500
+ return result if added.empty?
501
+
502
+ extra = scan(added, root: root, limit: limit, known: known)
503
+ %w[unshareable_constants proven_constants class_state
504
+ class_variables].each do |key|
505
+ result[key] = Array(result[key]) + extra[key]
506
+ end
507
+ result["scanned"] = result["scanned"].to_i + extra["scanned"]
508
+ result["errors"] = result["errors"].to_i + extra["errors"]
509
+ result["truncated"] ||= extra["truncated"]
510
+ result
511
+ end
512
+
513
+ # The innermost unshareable node of an unshareable value, with
514
+ # its depth: an unfrozen node with shareable contents just needs
515
+ # freezing, an inherently unshareable one needs replacing.
516
+ # Bounded, because shareability of each child is itself a
517
+ # recursive check.
518
+ def blocker_for(value, depth = 0)
519
+ return [describe_blocker(value), depth] if depth > 5
520
+
521
+ child = children_of(value).find { |c| !safe_shareable?(c) }
522
+ if child
523
+ blocker_for(child, depth + 1)
524
+ else
525
+ [describe_blocker(value), depth]
526
+ end
527
+ rescue Exception
528
+ [describe_blocker(value), depth]
529
+ end
530
+
531
+ def children_of(value)
532
+ children =
533
+ case value
534
+ when Hash then value.keys + value.values
535
+ when Array then value
536
+ when Struct, Data then value.deconstruct
537
+ else
538
+ value.instance_variables.map do |ivar|
539
+ value.instance_variable_get(ivar)
540
+ end
541
+ end
542
+ children.first(1000)
543
+ end
544
+
545
+ # "unfrozen X" only when freezing would actually flip the
546
+ # verdict; a Proc or IO stays unshareable frozen, and naming it
547
+ # unfrozen would send the reader to a fix that cannot work.
548
+ def describe_blocker(value)
549
+ return value.class.to_s if value.frozen?
550
+
551
+ frozen_helps = begin
552
+ Ractor.shareable?(value.dup.freeze)
553
+ rescue Exception
554
+ false
555
+ end
556
+ frozen_helps ? "unfrozen #{value.class}" : value.class.to_s
557
+ end
558
+
343
559
  # -- rack --------------------------------------------------------
344
560
 
345
561
  # App objects built in config.ru are almost never shareable (the
@@ -349,6 +565,8 @@ module AuditionHarness
349
565
  # and serve one request entirely inside a Ractor.
350
566
  def rack(payload)
351
567
  config_ru = payload.fetch("config_ru")
568
+ root = payload["root"] || File.dirname(config_ru)
569
+ bundler_setup
352
570
  begin
353
571
  require "rack" # audition:disable runtime-require
354
572
  rescue LoadError
@@ -356,6 +574,9 @@ module AuditionHarness
356
574
  end
357
575
 
358
576
  out = {"rack_available" => true}
577
+ baseline = module_state_snapshot
578
+ before = Object.constants
579
+ features = $LOADED_FEATURES.dup # audition:disable global-variables
359
580
  begin
360
581
  app = Rack::Builder.parse_file(config_ru)
361
582
  app = app.first if app.is_a?(Array)
@@ -364,6 +585,18 @@ module AuditionHarness
364
585
  rescue Exception => e
365
586
  out["main_boot_error"] = describe_error(e)
366
587
  end
588
+ # The main-process boot defines the app's constant graph;
589
+ # sweeping it gives rack targets the same backstop as require
590
+ # and rails targets. A failed boot still sweeps what loaded.
591
+ limit = payload["max_constants"]
592
+ known = known_paths(payload["known_compiled"])
593
+ out.merge!(merge_reopened(
594
+ scan(top_level(before), root: root, limit: limit, known: known),
595
+ baseline, root: root, limit: limit, known: known
596
+ ))
597
+ out["native_extensions"] = native_extensions(
598
+ features, root, payload["known_compiled"]
599
+ )
367
600
 
368
601
  out["ractor_boot_call"] = rack_in_ractor(config_ru)
369
602
  if out["ractor_boot_call"]["ok"]
@@ -445,29 +678,170 @@ module AuditionHarness
445
678
 
446
679
  # -- rails -------------------------------------------------------
447
680
 
681
+ # The prober sets BUNDLE_GEMFILE to the target's Gemfile; the
682
+ # target's gems must resolve before its boot files load. A
683
+ # setup failure propagates: it is the true boot failure, and
684
+ # the standard reporting captures it. Bundler.setup is called
685
+ # directly because bundler/setup exits on failure, swallowing
686
+ # the message.
687
+ def bundler_setup
688
+ return unless ENV["BUNDLE_GEMFILE"]
689
+
690
+ require "bundler" # audition:disable runtime-require
691
+ Bundler.ui.silence { Bundler.setup }
692
+ end
693
+
694
+ # A boot failure does not abandon the sweep: everything defined
695
+ # before the failure is still scanned, so the probe reports what
696
+ # it could reach alongside the boot error.
448
697
  def rails(payload)
449
698
  environment = payload.fetch("environment")
699
+ root = payload["root"]
700
+ bundler_setup
701
+ # The post-freeze requests need it; loaded before the snapshot
702
+ # so the sweep never attributes it to the target.
703
+ require "stringio" # audition:disable runtime-require
704
+ baseline = module_state_snapshot
450
705
  before = Object.constants
451
706
  features = $LOADED_FEATURES.dup # audition:disable global-variables
452
707
  started = Time.now
453
- require environment # audition:disable runtime-require
708
+ boot_error = nil
709
+ proc_warnings = []
710
+ armed = arm_proc_gate(proc_warnings, preload: true)
454
711
  begin
455
- Rails.application.eager_load!
456
- rescue Exception
457
- nil
712
+ # Absolute requires keep the path as given; realpathing it
713
+ # keeps own-vs-dependency attribution honest under symlinked
714
+ # roots (macOS /var).
715
+ require realpath(environment) # audition:disable runtime-require
716
+ # Boot may have replaced the deprecation behavior, and an
717
+ # app that only defines the shim during boot is armed here.
718
+ arm_proc_gate(proc_warnings, preload: false) || armed
719
+ begin
720
+ Rails.application.eager_load!
721
+ rescue Exception
722
+ nil
723
+ end
724
+ rescue Exception => e
725
+ boot_error = describe_error(e)
458
726
  end
459
- boot = {"ok" => true,
460
- "seconds" => (Time.now - started).round(1)}
461
- scan(Object.constants - before, root: payload["root"]).merge(
727
+ boot =
728
+ if boot_error
729
+ {"ok" => false, "error" => boot_error}
730
+ else
731
+ {"ok" => true, "seconds" => (Time.now - started).round(1)}
732
+ end
733
+ # Freezing first lets the sweep see warmed state as shareable.
734
+ ractorize = boot_error ? nil : ractorize_application
735
+ limit = payload["max_constants"]
736
+ known = known_paths(payload["known_compiled"])
737
+ merge_reopened(
738
+ scan(top_level(before), root: root, limit: limit, known: known),
739
+ baseline, root: root, limit: limit, known: known
740
+ ).merge(
462
741
  "boot" => boot,
742
+ "ractorize" => ractorize,
743
+ "unshareable_procs" => unshareable_procs(proc_warnings, root),
463
744
  "native_extensions" => native_extensions(
464
- features, payload["root"], payload["known_compiled"]
745
+ features, root, payload["known_compiled"]
465
746
  )
466
747
  )
467
748
  rescue Exception => e
468
749
  {"boot" => {"ok" => false, "error" => describe_error(e)}}
469
750
  end
470
751
 
752
+ # Rails 8.2 tries to make every callback block shareable once
753
+ # unshareable_proc_action is set, and reports each one it
754
+ # cannot as a deprecation naming the Proc. The probe arms :warn
755
+ # and collects those messages. Arming before boot covers apps
756
+ # that eager load while booting; the shim file only exists on
757
+ # 8.2, so older targets load nothing extra. Returns whether the
758
+ # gate is armed.
759
+ def arm_proc_gate(warnings, preload:)
760
+ if preload
761
+ begin
762
+ require "active_support/ractors" # audition:disable runtime-require
763
+ require "active_support" # audition:disable runtime-require
764
+ rescue LoadError
765
+ return false
766
+ end
767
+ end
768
+ return false unless defined?(ActiveSupport::Ractors) &&
769
+ ActiveSupport::Ractors.respond_to?(:unshareable_proc_action=)
770
+
771
+ ActiveSupport::Ractors.unshareable_proc_action = :warn
772
+ return true unless ActiveSupport.respond_to?(:deprecator)
773
+
774
+ deprecator = ActiveSupport.deprecator
775
+ collector = lambda do |message, _callstack|
776
+ warnings << message.to_s
777
+ end
778
+ deprecator.behavior = [collector]
779
+ deprecator.silenced = false if deprecator.respond_to?(:silenced=)
780
+ if deprecator.respond_to?(:disallowed_warnings=)
781
+ deprecator.disallowed_warnings = []
782
+ end
783
+ true
784
+ rescue Exception
785
+ false
786
+ end
787
+
788
+ # The deprecation names the Proc; its inspect carries the
789
+ # definition site, which is where the fix goes.
790
+ PROC_SITE = /#<Proc:0x\h+(?: \(lambda\))? (.+?):(\d+)>/
791
+
792
+ def unshareable_procs(warnings, root)
793
+ warnings.filter_map do |message|
794
+ next unless message.include?("Ractor shareable")
795
+
796
+ match = message.match(PROC_SITE)
797
+ path = match && match[1]
798
+ path = File.expand_path(path, root) if path && root &&
799
+ !path.start_with?("/")
800
+ shown = match ? match[0] : message.lines.last.to_s.strip
801
+ shown = shown.sub("#{root}/", "") if root
802
+ {"proc" => shown,
803
+ "path" => path,
804
+ "line" => match && Integer(match[2], 10),
805
+ "own" => path.nil? || own_path?(path, root)}
806
+ end
807
+ end
808
+
809
+ # Rails 8.2 adds Application#ractorize!, which deep-freezes the
810
+ # application graph. One GET / on the main Ractor afterwards
811
+ # surfaces lazy memoization on now-frozen objects (FrozenError),
812
+ # and one inside a Ractor surfaces state a worker cannot reach.
813
+ def ractorize_application
814
+ app = Rails.application
815
+ version = Rails.respond_to?(:version) ? Rails.version.to_s : nil
816
+ result = {"rails" => version}
817
+ return result.merge("available" => false) unless
818
+ app.respond_to?(:ractorize!)
819
+
820
+ result["available"] = true
821
+ begin
822
+ app.ractorize!
823
+ rescue Exception => e
824
+ return result.merge("ok" => false, "error" => describe_error(e))
825
+ end
826
+ result["ok"] = true
827
+ result["main_request"] = main_request(app)
828
+ if result["main_request"]["ok"]
829
+ result["ractor_request"] = in_ractor do
830
+ require "stringio" # audition:disable runtime-require
831
+ Rails.application.call(AuditionHarness.base_env).first
832
+ end
833
+ end
834
+ result
835
+ rescue Exception => e
836
+ {"available" => true, "ok" => false, "error" => describe_error(e)}
837
+ end
838
+
839
+ def main_request(app)
840
+ {"ok" => true, "status" => plain_value(app.call(base_env).first)}
841
+ rescue Exception => e
842
+ {"ok" => false, "error" => describe_error(e)}
843
+ end
844
+
471
845
  # -- capabilities ------------------------------------------------
472
846
 
473
847
  def capabilities
@@ -536,7 +910,7 @@ if $PROGRAM_NAME == __FILE__ # audition:disable global-variables
536
910
  real_stdout = $stdout.dup
537
911
  $stdout.reopen($stderr)
538
912
  mode = ARGV.fetch(0, "capabilities")
539
- raw = $stdin.tty? ? "" : $stdin.read
540
- payload = raw.empty? ? {} : JSON.parse(raw)
913
+ raw = $stdin.tty? ? "" : $stdin.binmode.read
914
+ payload = raw.empty? ? {} : Marshal.load(raw)
541
915
  AuditionHarness.main(mode, payload, out: real_stdout)
542
916
  end