audition 0.1.0

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.
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Audition's dynamic probe harness. Executed as a subprocess, never
4
+ # required into the host process:
5
+ #
6
+ # ruby harness.rb MODE < payload.json
7
+ #
8
+ # Prints exactly one JSON document on stdout and never raises.
9
+ # Stdlib only; must stay runnable on a bare Ruby 4.0.
10
+
11
+ Warning[:experimental] = false
12
+ Thread.report_on_exception = false
13
+
14
+ require "json"
15
+
16
+ module AuditionHarness
17
+ MAX_CONSTS = 5000
18
+
19
+ # Fixtures for capability probes.
20
+ CAP_CONST = [1, 2] # audition:disable mutable-constants
21
+
22
+ class CapClassVar
23
+ @@flag = true # audition:disable class-variables
24
+
25
+ def self.read
26
+ @@flag
27
+ end
28
+ end
29
+
30
+ class CapIvar
31
+ @mutable = {"k" => 1} # audition:disable class-level-state
32
+
33
+ def self.write
34
+ @x = 1 # audition:disable class-level-state
35
+ end
36
+
37
+ def self.read_mutable
38
+ @mutable
39
+ end
40
+ end
41
+
42
+ module_function
43
+
44
+ def main(mode, payload, out:)
45
+ result =
46
+ case mode
47
+ when "script_main" then script_main(payload.fetch("path"))
48
+ when "script_ractor" then script_ractor(payload.fetch("path"))
49
+ when "require" then library(payload)
50
+ when "rack" then rack(payload)
51
+ when "rails" then rails(payload)
52
+ when "capabilities" then capabilities
53
+ else {"error" => {"class" => "ArgumentError",
54
+ "message" => "unknown mode #{mode}"}}
55
+ end
56
+ out.puts(JSON.generate(result))
57
+ rescue Exception => e
58
+ out.puts(JSON.generate("error" => describe_error(e)))
59
+ end
60
+
61
+ def describe_error(error)
62
+ root = unwrap(error)
63
+ {"class" => root.class.name,
64
+ "message" => root.message.to_s[0, 500]}
65
+ end
66
+
67
+ def unwrap(error)
68
+ if error.is_a?(Ractor::RemoteError) && error.cause
69
+ error.cause
70
+ else
71
+ error
72
+ end
73
+ end
74
+
75
+ def in_ractor(*args, &block)
76
+ ractor = Ractor.new(*args, &block)
77
+ {"ok" => true, "value" => jsonable(ractor.value)}
78
+ rescue Exception => e
79
+ {"ok" => false, "error" => describe_error(e)}
80
+ end
81
+
82
+ def jsonable(value)
83
+ case value
84
+ when Numeric, String, Symbol, true, false, nil then value
85
+ else value.inspect[0, 200]
86
+ end
87
+ end
88
+
89
+ # -- scripts -----------------------------------------------------
90
+
91
+ def script_main(path)
92
+ load path # audition:disable runtime-require
93
+ {"ok" => true}
94
+ rescue Exception => e
95
+ {"ok" => false, "error" => describe_error(e)}
96
+ end
97
+
98
+ # `load` is not proxied to the main Ractor (unlike `require` on
99
+ # Ruby 4.0), so the script body truly executes inside the Ractor.
100
+ def script_ractor(path)
101
+ in_ractor(path) do |p|
102
+ load p # audition:disable runtime-require
103
+ :ok
104
+ end
105
+ end
106
+
107
+ # -- libraries ---------------------------------------------------
108
+
109
+ def library(payload)
110
+ Array(payload["load_paths"]).each do |lp|
111
+ $LOAD_PATH.unshift(lp) # audition:disable global-variables
112
+ end
113
+ before = Object.constants
114
+ require payload.fetch("feature") # audition:disable runtime-require
115
+ scan(Object.constants - before, root: payload["root"])
116
+ end
117
+
118
+ # Breadth-first walk of every constant the require introduced:
119
+ # plain values get a Ractor.shareable? verdict; classes and modules
120
+ # are inspected for class-level ivars and class variables, then
121
+ # descended into. const_get can raise (autoload failures) and
122
+ # anything can lie; every step is rescued and counted.
123
+ def scan(root_names, root: nil)
124
+ # Loaded features are realpathed by require; the target root
125
+ # must be too, or symlinked paths (macOS /var vs /private/var)
126
+ # break the own-vs-dependency comparison.
127
+ if root
128
+ root = begin
129
+ File.realpath(root)
130
+ rescue
131
+ root
132
+ end
133
+ end
134
+ unshareable = []
135
+ class_state = []
136
+ class_vars = []
137
+ errors = 0
138
+ seen = {}
139
+ queue = root_names.map { |name| [Object, name.to_s] }
140
+ visited = 0
141
+
142
+ until queue.empty?
143
+ owner, name = queue.shift
144
+ visited += 1
145
+ break if visited > MAX_CONSTS
146
+
147
+ begin
148
+ value = owner.const_get(name, false)
149
+ rescue Exception
150
+ errors += 1
151
+ next
152
+ end
153
+ full = owner.equal?(Object) ? name : "#{owner}::#{name}"
154
+ origin = origin_for(owner, name, root)
155
+
156
+ if value.is_a?(Module)
157
+ next if seen[value.object_id]
158
+
159
+ seen[value.object_id] = true
160
+ errors += inspect_module(full, value, origin,
161
+ class_state, class_vars)
162
+ value.constants(false).each do |child|
163
+ queue << [value, child.to_s]
164
+ end
165
+ else
166
+ begin
167
+ unless Ractor.shareable?(value)
168
+ unshareable << origin.merge(
169
+ "const" => full, "class" => value.class.name
170
+ )
171
+ end
172
+ rescue Exception
173
+ errors += 1
174
+ end
175
+ end
176
+ end
177
+
178
+ {"unshareable_constants" => unshareable,
179
+ "class_state" => class_state,
180
+ "class_variables" => class_vars,
181
+ "scanned" => visited,
182
+ "errors" => errors}
183
+ end
184
+
185
+ # Where was this constant defined, and does that location belong
186
+ # to the audited target (as opposed to a dependency it loaded)?
187
+ # Unknown locations (C extensions, core) count as own so nothing
188
+ # gets silently downgraded.
189
+ def origin_for(owner, name, root)
190
+ path, line = begin
191
+ owner.const_source_location(name)
192
+ rescue Exception
193
+ nil
194
+ end
195
+ own = root.nil? || path.nil? || path.start_with?(root)
196
+ {"path" => path, "line" => line, "own" => own}
197
+ end
198
+
199
+ def inspect_module(full, mod, origin, class_state, class_vars)
200
+ ivars = mod.instance_variables
201
+ if ivars.any?
202
+ shareability = ivars.map do |ivar|
203
+ value = mod.instance_variable_get(ivar)
204
+ [ivar.to_s, safe_shareable?(value)]
205
+ end
206
+ class_state << origin.merge(
207
+ "const" => full,
208
+ "ivars" => shareability.map(&:first),
209
+ "unshareable" => shareability.reject(&:last).map(&:first)
210
+ )
211
+ end
212
+ cvars = mod.class_variables(false)
213
+ if cvars.any?
214
+ class_vars << origin.merge(
215
+ "const" => full, "cvars" => cvars.map(&:to_s)
216
+ )
217
+ end
218
+ 0
219
+ rescue Exception
220
+ 1
221
+ end
222
+
223
+ def safe_shareable?(value)
224
+ Ractor.shareable?(value)
225
+ rescue Exception
226
+ false
227
+ end
228
+
229
+ # -- rack --------------------------------------------------------
230
+
231
+ # App objects built in config.ru are almost never shareable (the
232
+ # file is instance_eval'd inside Rack::Builder, so every lambda's
233
+ # self is the Builder). Ractor web servers therefore boot the app
234
+ # once per Ractor; the probe mirrors that model: parse config.ru
235
+ # and serve one request entirely inside a Ractor.
236
+ def rack(payload)
237
+ config_ru = payload.fetch("config_ru")
238
+ begin
239
+ require "rack" # audition:disable runtime-require
240
+ rescue LoadError
241
+ return {"rack_available" => false}
242
+ end
243
+
244
+ out = {"rack_available" => true}
245
+ begin
246
+ app = Rack::Builder.parse_file(config_ru)
247
+ app = app.first if app.is_a?(Array)
248
+ out["app_class"] = app.class.name
249
+ out["shareable"] = Ractor.shareable?(app)
250
+ rescue Exception => e
251
+ out["main_boot_error"] = describe_error(e)
252
+ end
253
+
254
+ out["ractor_boot_call"] = rack_in_ractor(config_ru)
255
+ if out["ractor_boot_call"]["ok"]
256
+ out["concurrency"] = rack_concurrent(
257
+ config_ru,
258
+ payload.fetch("ractors", 4),
259
+ payload.fetch("requests", 25)
260
+ )
261
+ end
262
+ out
263
+ end
264
+
265
+ # Real failures (races on shared state, require-proxy
266
+ # serialization) only show up under load: boot the app in N
267
+ # Ractors and serve M requests from each.
268
+ def rack_concurrent(config_ru, workers, requests)
269
+ ractors = workers.times.map do
270
+ Ractor.new(config_ru, requests) do |path, n|
271
+ require "rack" # audition:disable runtime-require
272
+ require "stringio" # audition:disable runtime-require
273
+ builder = Rack::Builder.new
274
+ builder.instance_eval(File.read(path), path, 1)
275
+ app = builder.to_app
276
+ statuses = Hash.new(0)
277
+ n.times do
278
+ statuses[app.call(AuditionHarness.base_env).first] += 1
279
+ end
280
+ statuses
281
+ end
282
+ end
283
+ results = ractors.map do |ractor|
284
+ {"ok" => true, "statuses" => ractor.value}
285
+ rescue Exception => e
286
+ {"ok" => false, "error" => describe_error(e)}
287
+ end
288
+
289
+ merged = Hash.new(0)
290
+ results.each do |result|
291
+ next unless result["ok"]
292
+
293
+ result["statuses"].each { |code, n| merged[code.to_s] += n }
294
+ end
295
+ {"workers" => workers,
296
+ "requests_per_worker" => requests,
297
+ "failures" => results.count { |r| !r["ok"] },
298
+ "first_error" => results.find { |r| !r["ok"] }&.dig("error"),
299
+ "statuses" => merged}
300
+ end
301
+
302
+ def base_env
303
+ {
304
+ "REQUEST_METHOD" => "GET",
305
+ "PATH_INFO" => "/",
306
+ "QUERY_STRING" => "",
307
+ "SERVER_NAME" => "localhost",
308
+ "SERVER_PORT" => "80",
309
+ "SERVER_PROTOCOL" => "HTTP/1.1",
310
+ "rack.url_scheme" => "http",
311
+ "rack.input" => StringIO.new(+""),
312
+ "rack.errors" => StringIO.new(+"")
313
+ }
314
+ end
315
+
316
+ # Rack::Builder.parse_file cannot run inside a Ractor at all on
317
+ # rack 3.2 (Rack::BUILDER_TOPLEVEL_BINDING holds an unshareable
318
+ # Binding), so the per-Ractor boot rebuilds the app by
319
+ # instance_eval'ing config.ru into a fresh Builder; same DSL,
320
+ # no poisoned constant.
321
+ def rack_in_ractor(config_ru)
322
+ in_ractor(config_ru) do |path|
323
+ require "rack" # audition:disable runtime-require
324
+ require "stringio" # audition:disable runtime-require
325
+ builder = Rack::Builder.new
326
+ builder.instance_eval(File.read(path), path, 1)
327
+ app = builder.to_app
328
+ app.call(AuditionHarness.base_env).first
329
+ end
330
+ end
331
+
332
+ # -- rails -------------------------------------------------------
333
+
334
+ def rails(payload)
335
+ environment = payload.fetch("environment")
336
+ before = Object.constants
337
+ started = Time.now
338
+ require environment # audition:disable runtime-require
339
+ begin
340
+ Rails.application.eager_load!
341
+ rescue Exception
342
+ nil
343
+ end
344
+ boot = {"ok" => true,
345
+ "seconds" => (Time.now - started).round(1)}
346
+ scan(Object.constants - before, root: payload["root"])
347
+ .merge("boot" => boot)
348
+ rescue Exception => e
349
+ {"boot" => {"ok" => false, "error" => describe_error(e)}}
350
+ end
351
+
352
+ # -- capabilities ------------------------------------------------
353
+
354
+ def capabilities
355
+ caps = {}
356
+ capability_probes.each do |label, probe|
357
+ probe.call
358
+ caps[label] = {"ok" => true, "error" => nil}
359
+ rescue Exception => e
360
+ caps[label] = {"ok" => false,
361
+ "error" => unwrap(e).class.name}
362
+ end
363
+ {"capabilities" => caps}
364
+ end
365
+
366
+ def capability_probes
367
+ {
368
+ "global variable read" =>
369
+ -> { Ractor.new { $audition_cap }.value }, # audition:disable
370
+ "global variable write" =>
371
+ -> { Ractor.new { $audition_cap = 1 }.value }, # audition:disable
372
+ "class variable access" =>
373
+ -> { Ractor.new { CapClassVar.read }.value },
374
+ "class ivar write" =>
375
+ -> { Ractor.new { CapIvar.write }.value },
376
+ "class ivar read (mutable value)" =>
377
+ -> { Ractor.new { CapIvar.read_mutable }.value },
378
+ "unshareable constant read" =>
379
+ -> { Ractor.new { CAP_CONST }.value },
380
+ "constant set (unshareable value)" =>
381
+ -> { Ractor.new { Object.const_set(:AUDITION_X, +"s") }.value },
382
+ "ENV read" =>
383
+ -> { Ractor.new { ENV.fetch("HOME", "none") }.value },
384
+ "ENV write" =>
385
+ -> { Ractor.new { ENV["AUD_CAP"] = "1" }.value }, # audition:disable
386
+ "require inside Ractor" =>
387
+ -> { Ractor.new { require "date" }.value }, # audition:disable
388
+ "ObjectSpace.each_object" =>
389
+ -> { Ractor.new { ObjectSpace.each_object(Class).first }.value },
390
+ "Signal.trap" =>
391
+ -> { Ractor.new { Signal.trap("USR2") {} }.value }, # audition:disable
392
+ "Thread.current storage" =>
393
+ -> { Ractor.new { Thread.current[:x] = 1 }.value },
394
+ "Timeout.timeout" =>
395
+ lambda do
396
+ require "timeout" # audition:disable runtime-require
397
+ Ractor.new { Timeout.timeout(2) { :ok } }.value
398
+ end,
399
+ "proc copied into Ractor" =>
400
+ lambda do
401
+ pr = proc { 1 }
402
+ Ractor.new(pr) { |_p| :ok }.value
403
+ end,
404
+ "outer local capture" =>
405
+ lambda do
406
+ z = [1]
407
+ Ractor.new { z }.value # audition:disable ractor-isolation
408
+ end
409
+ }
410
+ end
411
+ end
412
+
413
+ if $PROGRAM_NAME == __FILE__ # audition:disable global-variables
414
+ # The audited code may print anything, from any Ractor, straight
415
+ # to fd 1. Keep a private dup of the real stdout for the JSON
416
+ # document and point fd 1 at stderr for everyone else.
417
+ real_stdout = $stdout.dup
418
+ $stdout.reopen($stderr)
419
+ mode = ARGV.fetch(0, "capabilities")
420
+ raw = $stdin.tty? ? "" : $stdin.read
421
+ payload = raw.empty? ? {} : JSON.parse(raw)
422
+ AuditionHarness.main(mode, payload, out: real_stdout)
423
+ end