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 +4 -4
- data/README.md +48 -16
- data/lib/audition/bundle_sweep.rb +1 -0
- data/lib/audition/cli.rb +1 -0
- data/lib/audition/dynamic/harness.rb +212 -37
- data/lib/audition/dynamic/prober.rb +121 -10
- data/lib/audition/finding.rb +6 -2
- data/lib/audition/reconciliation.rb +86 -0
- data/lib/audition/rewriters.rb +25 -1
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +101 -9
- data/lib/audition/static/checks/ractor_isolation.rb +214 -15
- data/lib/audition/static/checks/unsafe_calls.rb +7 -4
- data/lib/audition/static/checks.rb +4 -3
- data/lib/audition/static/graph_audit.rb +67 -12
- data/lib/audition/static/literal_classifier.rb +267 -2
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c47dfccb03a9d164583e3ae0a5530c74a41cc176f62865807fa4a4d1ed11d7f4
|
|
4
|
+
data.tar.gz: 75697dacb090c469d8f2966d5174fb08186e1808b85737872b0db5f6034a688d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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`)
|
|
388
|
-
|
|
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
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
#
|
|
6
6
|
# ruby harness.rb MODE < payload.json
|
|
7
7
|
#
|
|
8
|
-
# Prints exactly one
|
|
9
|
-
# Stdlib only
|
|
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
|
|
75
|
+
emit(out, result)
|
|
74
76
|
rescue Exception => e
|
|
75
77
|
begin
|
|
76
|
-
out
|
|
78
|
+
emit(out, "error" => describe_error(e))
|
|
77
79
|
rescue Exception
|
|
78
|
-
out
|
|
79
|
-
|
|
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);
|
|
85
|
-
#
|
|
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" =>
|
|
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
|
-
|
|
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(
|
|
162
|
-
|
|
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,
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
278
|
-
#
|
|
279
|
-
|
|
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
|
|
417
|
-
#
|
|
418
|
-
|
|
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
|
-
|
|
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(
|
|
528
|
-
|
|
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(
|
|
659
|
-
|
|
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,
|
|
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? ? {} :
|
|
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
|