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.
- checksums.yaml +4 -4
- data/README.md +125 -49
- data/lib/audition/bundle_sweep.rb +25 -15
- data/lib/audition/cli.rb +128 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +415 -41
- data/lib/audition/dynamic/prober.rb +271 -54
- data/lib/audition/finding.rb +14 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/reconciliation.rb +86 -0
- data/lib/audition/report/github.rb +4 -3
- data/lib/audition/report/json.rb +5 -1
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +13 -2
- data/lib/audition/report.rb +8 -1
- data/lib/audition/rewriters.rb +29 -2
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +172 -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/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +6 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1113 -15
- data/lib/audition/static/literal_classifier.rb +385 -20
- data/lib/audition/static/native_extensions.rb +28 -16
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +82 -13
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +12 -4
|
@@ -5,17 +5,23 @@
|
|
|
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
|
|
18
|
-
|
|
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
|
|
75
|
+
emit(out, result)
|
|
70
76
|
rescue Exception => e
|
|
71
77
|
begin
|
|
72
|
-
out
|
|
78
|
+
emit(out, "error" => describe_error(e))
|
|
73
79
|
rescue Exception
|
|
74
|
-
out
|
|
75
|
-
|
|
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);
|
|
81
|
-
#
|
|
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" =>
|
|
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
|
-
|
|
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"),
|
|
143
|
-
|
|
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,
|
|
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
|
|
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,
|
|
207
|
+
def require_target(feature, load_paths)
|
|
163
208
|
require feature # audition:disable runtime-require
|
|
164
209
|
rescue LoadError => error
|
|
165
|
-
entry_candidates(feature,
|
|
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,
|
|
218
|
+
def entry_candidates(feature, load_paths)
|
|
174
219
|
candidates = []
|
|
175
220
|
slashed = feature.tr("-", "/")
|
|
176
221
|
candidates << slashed if slashed != feature
|
|
177
|
-
files =
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
246
|
-
#
|
|
247
|
-
|
|
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
|
-
|
|
708
|
+
boot_error = nil
|
|
709
|
+
proc_warnings = []
|
|
710
|
+
armed = arm_proc_gate(proc_warnings, preload: true)
|
|
454
711
|
begin
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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 =
|
|
460
|
-
|
|
461
|
-
|
|
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,
|
|
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? ? {} :
|
|
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
|