audition 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52c12c94d193fa6946553c81ab22b8c21272f269ad4f6f758e99fa4a1c05217e
4
- data.tar.gz: 858d3ef4e5742ca430337656b9ecea865a89c3f3525ef990073499c8eb438bca
3
+ metadata.gz: c47dfccb03a9d164583e3ae0a5530c74a41cc176f62865807fa4a4d1ed11d7f4
4
+ data.tar.gz: 75697dacb090c469d8f2966d5174fb08186e1808b85737872b0db5f6034a688d
5
5
  SHA512:
6
- metadata.gz: 60d78e8622cfe35764e7dd30be43ba7e1ceda4f0faf10796230dd6ddb85d952736b345bd380b58823cf97c6687209f6c0fe709193f58326464b61950af1c8a70
7
- data.tar.gz: 8edc6f7ba79f531317e26f5f1866540ec784d3791db0b6a43ae56d990b22bd173b5234a05618a8b75ba4c5bf1d95bb06a9898c726b493456c36604cdb7c90b66
6
+ metadata.gz: a631d1f7cd378ff04c47dcffb9608d332bfb048fc37dbda66179b085c487e936206359b3ea61a1e8fb5c70d121b6b7d94275b2e6d436f32772e1ea68a540fbd4
7
+ data.tar.gz: 974bbcd0a531588edf1d2474e9c8afb7bd5d3869a985b483ebb2c628007837ed20e50d10a580f6f0204db95e42128bb3de061237bbdaeab28f3d7008d7838a37
data/README.md CHANGED
@@ -28,12 +28,13 @@ core itself is being ractorized; see the
28
28
  (which rule of the Ractor model it violates) and a `fix`
29
29
  (what to write instead).
30
30
  - **`--fix` like RuboCop, in two tiers.** Safe corrections:
31
- `.freeze` on string constants, sentinels, and containers whose
32
- elements are all shareable, `Ractor.make_shareable(...)` for
33
- the remaining mutable and shallow-frozen containers and for
34
- Proc constants, and
35
- boot-time hoisting of method-body requires. `--fix-unsafe` adds
36
- semantics-affecting rewrites: magic-comment insertion,
31
+ `.freeze` on string constants (literals as well as the fresh
32
+ strings core methods return), sentinels, and containers whose
33
+ elements are all shareable, and boot-time hoisting of
34
+ method-body requires. `--fix-unsafe` adds semantics-affecting
35
+ rewrites: `Ractor.make_shareable(...)` for the remaining mutable
36
+ and shallow-frozen containers, for containers a core method
37
+ allocates, and for Proc constants, magic-comment insertion,
37
38
  freeze-on-memoize for class-level memoization (both `@x ||=`
38
39
  and `return @x if defined?(@x)` idioms keep their caching, the
39
40
  memoized value becomes shareable, Rails-core style;
@@ -323,14 +324,29 @@ Static, with file:line precision:
323
324
  - **Class variables**, resolved on the rubydex graph.
324
325
  - **Class-level instance variables**, unified across the class
325
326
  body, `def self.`, and `class << self`, across files; the classic
326
- `@cache ||= {}` and `return @x if defined?(@x)` memoizations.
327
+ `@cache ||= {}` and `return @x if defined?(@x)` memoizations. A
328
+ memo whose write is proxied to the main Ractor
329
+ (`@x || on_main(self) { @x ||= ... }`) is reported as that
330
+ escape hatch, not as a raw memo.
327
331
  - **Constants that are not deeply shareable**: bare mutable
328
332
  literals, interpolated strings, the subtle shallow freeze
329
333
  (`[[1], [2]].freeze` still raises; Audition explains why), and
330
- call results the magic comment never covers (`X.tr(":", "")`,
331
- `Regexp.new`, `Regexp.union`, `format`), the shapes Rails fixed
332
- last in its own ractorization. Honors `# frozen_string_literal:`
333
- and `# shareable_constant_value:` magic comments.
334
+ call results the magic comment never covers, from the
335
+ return-type contracts of core methods: fresh strings
336
+ (`X.tr(":", "")`, `[8, 2, 0].join(".")`, `Regexp.new`,
337
+ `format`), fresh containers (`TYPES.keys`, `LIST.map { }`,
338
+ `BASE + [:x]`, `DEFAULTS.merge(...)`, `.dup`), string splitters
339
+ under a shallow freeze (`".*".chars.freeze`), and Method
340
+ objects, which no freeze makes shareable. A spec executes the
341
+ tables against the running Ruby. Integer arithmetic,
342
+ comparisons, and negation are recognized as shareable. Honors
343
+ `# frozen_string_literal:` and `# shareable_constant_value:`
344
+ magic comments.
345
+ - **Instance memoization on classes that get frozen**: a lazy
346
+ `@x ||=` on a class whose initialize freezes self raises
347
+ FrozenError on first use; on a class with a `freeze` override
348
+ the memo must be warmed inside the override before `super`
349
+ (compute on freeze), and one left cold is reported.
334
350
  - **Sync primitives and Procs in constants** (Mutex, Queue,
335
351
  lambdas), including `Hash.new { }` default procs, which stay
336
352
  unshareable even after `.freeze`.
@@ -341,7 +357,12 @@ Static, with file:line precision:
341
357
  - **Runtime require and autoload** (serializes all Ractors through
342
358
  the main-Ractor proxy).
343
359
  - **`Ractor.new` blocks capturing outer locals** (the ArgumentError
344
- at creation time), resolved through Prism's exact scope depths.
360
+ at creation time), resolved through Prism's exact scope depths;
361
+ and **blocks that `Ractor.shareable_proc` would refuse**, handed
362
+ to it directly or to a Rails callback macro (`before_create`,
363
+ `validate`, `on_load`, ...), because a captured local holds a
364
+ provably unshareable value (`prefix = +"Draft: "`) or is
365
+ assigned more than once.
345
366
  - **Hostile or removed APIs**: `Ractor.yield`/`take` (gone in 4.0),
346
367
  ActiveSupport `cattr_*`/`mattr_*` class variables (with the
347
368
  `class_attribute` migration Rails itself made) and
@@ -371,10 +392,15 @@ Dynamic, on the live object graph:
371
392
 
372
393
  - Runs scripts inside a real Ractor (via `load`, which is not
373
394
  proxied) and reports the actual exception.
374
- - Requires a library, then sweeps every constant it introduced with
395
+ - Requires a library, then sweeps every constant it introduced,
396
+ under new namespaces and pre-existing ones alike, with
375
397
  `Ractor.shareable?`, and inspects every class and module for
376
398
  class-level ivars and class variables, with
377
- `const_source_location` attribution.
399
+ `const_source_location` attribution. What the sweep proves
400
+ shareable retires the static guesses about the same objects:
401
+ an unproven constant warning disappears, and class-level state
402
+ that held only shareable values after boot keeps an info note
403
+ instead of an error.
378
404
  - Records every compiled extension the load pulled in,
379
405
  dependencies included, and byte-scans each for the
380
406
  `rb_ext_ractor_safe` import; silent ones are reported against
@@ -384,8 +410,14 @@ Dynamic, on the live object graph:
384
410
  the per-worker model of Ractor web servers; then hammers it from
385
411
  4 Ractors x 25 requests to surface failures that only appear
386
412
  under concurrency.
387
- - Boots Rails (`config/environment.rb`), eager-loads, and sweeps
388
- the application's namespaces.
413
+ - Boots Rails (`config/environment.rb`) with
414
+ `unshareable_proc_action` armed, so every callback block Rails
415
+ cannot make shareable is reported at the Proc's definition site;
416
+ eager-loads; on Rails 8.2 calls `ractorize!` to freeze the
417
+ application graph, then serves one GET / on the main Ractor
418
+ (where a lazy memo on a now-frozen object raises FrozenError)
419
+ and one inside a Ractor; and sweeps the application's
420
+ namespaces. Without `ractorize!` an info note says so.
389
421
 
390
422
  ## Agent skill
391
423
 
@@ -80,6 +80,7 @@ module Audition
80
80
  findings = filter(static_findings(target, config),
81
81
  directives, config)
82
82
  results = dynamic_results(target)
83
+ findings = Reconciliation.apply(findings, results)
83
84
  findings += filter(results.flat_map(&:findings),
84
85
  directives, config)
85
86
  report = Report.new(
data/lib/audition/cli.rb CHANGED
@@ -189,6 +189,7 @@ module Audition
189
189
  )
190
190
  end
191
191
 
192
+ findings = Reconciliation.apply(findings, results)
192
193
  all = findings +
193
194
  filter(results.flat_map(&:findings), directives, config)
194
195
 
@@ -5,13 +5,15 @@
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
@@ -70,19 +72,27 @@ module AuditionHarness
70
72
  else {"error" => {"class" => "ArgumentError",
71
73
  "message" => "unknown mode #{mode}"}}
72
74
  end
73
- out.puts(JSON.generate(result))
75
+ emit(out, result)
74
76
  rescue Exception => e
75
77
  begin
76
- out.puts(JSON.generate("error" => describe_error(e)))
78
+ emit(out, "error" => describe_error(e))
77
79
  rescue Exception
78
- out.puts('{"error":{"class":"HarnessFailure",' \
79
- '"message":"unreportable error"}}')
80
+ emit(out, "error" => {"class" => "HarnessFailure",
81
+ "message" => "unreportable error"})
80
82
  end
81
83
  end
82
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
+
83
93
  # Exception messages can carry arbitrary bytes (C extensions,
84
- # binary filenames); unscrubbed they blow up JSON.generate
85
- # inside the rescue and the harness dies without output.
94
+ # binary filenames); scrubbed to UTF-8 so the report can print
95
+ # them.
86
96
  def describe_error(error)
87
97
  root = unwrap(error)
88
98
  {"class" => scrub(root.class.name.to_s),
@@ -116,12 +126,13 @@ module AuditionHarness
116
126
 
117
127
  def in_ractor(*args, &block)
118
128
  ractor = Ractor.new(*args, &block)
119
- {"ok" => true, "value" => jsonable(ractor.value)}
129
+ {"ok" => true, "value" => plain_value(ractor.value)}
120
130
  rescue Exception => e
121
131
  {"ok" => false, "error" => describe_error(e)}
122
132
  end
123
133
 
124
- def jsonable(value)
134
+ # Only primitives cross the pipe; anything else is described.
135
+ def plain_value(value)
125
136
  case value
126
137
  when Numeric, String, Symbol, true, false, nil then value
127
138
  else value.inspect[0, 200]
@@ -152,21 +163,35 @@ module AuditionHarness
152
163
  Array(payload["load_paths"]).each do |lp|
153
164
  $LOAD_PATH.unshift(lp) # audition:disable global-variables
154
165
  end
166
+ root = payload["root"]
167
+ limit = payload["max_constants"]
168
+ known = known_paths(payload["known_compiled"])
155
169
  baseline = module_state_snapshot
156
170
  before = Object.constants
157
171
  features = $LOADED_FEATURES.dup # audition:disable global-variables
158
172
  require_target(payload.fetch("feature"),
159
173
  Array(payload["load_paths"]))
160
174
  merge_reopened(
161
- scan(Object.constants - before, root: payload["root"],
162
- limit: payload["max_constants"]), baseline
175
+ scan(top_level(before), root: root, limit: limit, known: known),
176
+ baseline, root: root, limit: limit, known: known
163
177
  ).merge(
164
178
  "native_extensions" => native_extensions(
165
- features, payload["root"], payload["known_compiled"]
179
+ features, root, payload["known_compiled"]
166
180
  )
167
181
  )
168
182
  end
169
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
+
170
195
  # Gem names and entry files diverge in two conventional ways:
171
196
  # dashed names ship slashed files (rspec-mocks provides
172
197
  # rspec/mocks), and squashed names ship snake_case files
@@ -206,19 +231,20 @@ module AuditionHarness
206
231
  # are inspected for class-level ivars and class variables, then
207
232
  # descended into. const_get can raise (autoload failures) and
208
233
  # anything can lie; every step is rescued and counted.
209
- def scan(root_names, root: nil, limit: nil)
234
+ def scan(roots, root: nil, limit: nil, known: [])
210
235
  # Loaded features are realpathed by require; the target root
211
236
  # must be too, or symlinked paths (macOS /var vs /private/var)
212
237
  # break the own-vs-dependency comparison.
213
238
  root = realpath(root)
214
239
  limit = (limit || MAX_CONSTS).to_i
215
240
  unshareable = []
241
+ proven = []
216
242
  class_state = []
217
243
  class_vars = []
218
244
  errors = 0
219
245
  truncated = false
220
246
  seen = {}
221
- queue = root_names.map { |name| [Object, name.to_s] }
247
+ queue = roots.map { |owner, name| [owner, name.to_s] }
222
248
  visited = 0
223
249
 
224
250
  until queue.empty?
@@ -236,7 +262,7 @@ module AuditionHarness
236
262
  next
237
263
  end
238
264
  full = owner.equal?(Object) ? name : "#{owner}::#{name}"
239
- origin = origin_for(owner, name, root)
265
+ origin = origin_for(owner, name, root, known)
240
266
 
241
267
  if value.is_a?(Module)
242
268
  next if seen[value.object_id]
@@ -249,7 +275,10 @@ module AuditionHarness
249
275
  end
250
276
  else
251
277
  begin
252
- 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
253
282
  blocker, blocker_depth = blocker_for(value)
254
283
  unshareable << origin.merge(
255
284
  "const" => full, "class" => value.class.name,
@@ -264,6 +293,7 @@ module AuditionHarness
264
293
  end
265
294
 
266
295
  {"unshareable_constants" => unshareable,
296
+ "proven_constants" => proven,
267
297
  "class_state" => class_state,
268
298
  "class_variables" => class_vars,
269
299
  "scanned" => visited,
@@ -274,9 +304,10 @@ module AuditionHarness
274
304
 
275
305
  # Where was this constant defined, and does that location belong
276
306
  # to the audited target (as opposed to a dependency it loaded)?
277
- # Unknown locations (C extensions, core) count as own so nothing
278
- # gets silently downgraded.
279
- 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 = [])
280
311
  path, line = begin
281
312
  owner.const_source_location(name)
282
313
  rescue Exception
@@ -284,11 +315,23 @@ module AuditionHarness
284
315
  end
285
316
  # The separator matters: /x/app must not claim /x/app-helpers.
286
317
  own = root.nil? || path.nil? || path == root ||
318
+ own_compiled?(path, root, known) ||
287
319
  (path.start_with?(root + File::SEPARATOR) &&
288
320
  !excluded?(path, root))
289
321
  {"path" => path, "line" => line, "own" => own}
290
322
  end
291
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
+
292
335
  # Matches the static scanner's exclusion rule: any excluded or
293
336
  # dot-prefixed component in the root-relative path means the
294
337
  # file is not the target's own code.
@@ -397,7 +440,7 @@ module AuditionHarness
397
440
  seen[value.object_id] = true
398
441
  begin
399
442
  snap[value] = [value.instance_variables,
400
- value.class_variables(false)]
443
+ value.class_variables(false), value.constants(false)]
401
444
  rescue Exception
402
445
  next
403
446
  end
@@ -413,17 +456,31 @@ module AuditionHarness
413
456
  MACHINERY = /\A(?:Gem|Bundler|RubyVM)(?:::|\z)/
414
457
 
415
458
  # 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)
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: [])
419
466
  origin = {"path" => nil, "line" => nil, "own" => true}
420
- snapshot.each do |mod, (ivars, cvars)|
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
421
480
  new_ivars = mod.instance_variables - ivars
422
481
  new_cvars = mod.class_variables(false) - cvars
423
482
  next if new_ivars.empty? && new_cvars.empty?
424
483
 
425
- full = mod.name || mod.inspect
426
- next if full.match?(MACHINERY)
427
484
  if new_ivars.any?
428
485
  unshareable = new_ivars.reject do |ivar|
429
486
  safe_shareable?(mod.instance_variable_get(ivar))
@@ -439,9 +496,17 @@ module AuditionHarness
439
496
  "const" => full, "cvars" => new_cvars.map(&:to_s)
440
497
  )
441
498
  end
442
- rescue Exception
443
- next
444
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"]
445
510
  result
446
511
  end
447
512
 
@@ -523,9 +588,11 @@ module AuditionHarness
523
588
  # The main-process boot defines the app's constant graph;
524
589
  # sweeping it gives rack targets the same backstop as require
525
590
  # and rails targets. A failed boot still sweeps what loaded.
591
+ limit = payload["max_constants"]
592
+ known = known_paths(payload["known_compiled"])
526
593
  out.merge!(merge_reopened(
527
- scan(Object.constants - before, root: root,
528
- limit: payload["max_constants"]), baseline
594
+ scan(top_level(before), root: root, limit: limit, known: known),
595
+ baseline, root: root, limit: limit, known: known
529
596
  ))
530
597
  out["native_extensions"] = native_extensions(
531
598
  features, root, payload["known_compiled"]
@@ -629,17 +696,26 @@ module AuditionHarness
629
696
  # it could reach alongside the boot error.
630
697
  def rails(payload)
631
698
  environment = payload.fetch("environment")
699
+ root = payload["root"]
632
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
633
704
  baseline = module_state_snapshot
634
705
  before = Object.constants
635
706
  features = $LOADED_FEATURES.dup # audition:disable global-variables
636
707
  started = Time.now
637
708
  boot_error = nil
709
+ proc_warnings = []
710
+ armed = arm_proc_gate(proc_warnings, preload: true)
638
711
  begin
639
712
  # Absolute requires keep the path as given; realpathing it
640
713
  # keeps own-vs-dependency attribution honest under symlinked
641
714
  # roots (macOS /var).
642
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
643
719
  begin
644
720
  Rails.application.eager_load!
645
721
  rescue Exception
@@ -654,19 +730,118 @@ module AuditionHarness
654
730
  else
655
731
  {"ok" => true, "seconds" => (Time.now - started).round(1)}
656
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"])
657
737
  merge_reopened(
658
- scan(Object.constants - before, root: payload["root"],
659
- limit: payload["max_constants"]), baseline
738
+ scan(top_level(before), root: root, limit: limit, known: known),
739
+ baseline, root: root, limit: limit, known: known
660
740
  ).merge(
661
741
  "boot" => boot,
742
+ "ractorize" => ractorize,
743
+ "unshareable_procs" => unshareable_procs(proc_warnings, root),
662
744
  "native_extensions" => native_extensions(
663
- features, payload["root"], payload["known_compiled"]
745
+ features, root, payload["known_compiled"]
664
746
  )
665
747
  )
666
748
  rescue Exception => e
667
749
  {"boot" => {"ok" => false, "error" => describe_error(e)}}
668
750
  end
669
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
+
670
845
  # -- capabilities ------------------------------------------------
671
846
 
672
847
  def capabilities
@@ -735,7 +910,7 @@ if $PROGRAM_NAME == __FILE__ # audition:disable global-variables
735
910
  real_stdout = $stdout.dup
736
911
  $stdout.reopen($stderr)
737
912
  mode = ARGV.fetch(0, "capabilities")
738
- raw = $stdin.tty? ? "" : $stdin.read
739
- payload = raw.empty? ? {} : JSON.parse(raw)
913
+ raw = $stdin.tty? ? "" : $stdin.binmode.read
914
+ payload = raw.empty? ? {} : Marshal.load(raw)
740
915
  AuditionHarness.main(mode, payload, out: real_stdout)
741
916
  end