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.
@@ -12,15 +12,15 @@ module Audition
12
12
  # @return [Symbol] `:script`, `:require`, `:rack`, `:rails`,
13
13
  # or `:capabilities`
14
14
  # @!attribute [r] raw
15
- # @return [Hash] the harness's parsed JSON, verbatim
15
+ # @return [Hash] the harness's document, verbatim
16
16
  # @!attribute [r] findings
17
17
  # @return [Array<Finding>] findings derived from `raw`
18
18
  # @!attribute [r] passed
19
19
  # @return [Boolean] whether the target's own surface passed
20
20
  Result = Data.define(:mode, :raw, :findings, :passed)
21
21
 
22
- # Spawns the harness subprocess per probe mode, parses its JSON,
23
- # and converts observations into findings.
22
+ # Spawns the harness subprocess per probe mode, loads the
23
+ # document it prints, and converts observations into findings.
24
24
  class Prober
25
25
  HARNESS = File.expand_path("harness.rb", __dir__).freeze
26
26
 
@@ -152,10 +152,99 @@ module Audition
152
152
  findings: findings, passed: false)
153
153
  end
154
154
 
155
+ findings.concat(ractorize_findings(raw["ractorize"], entry))
155
156
  Result.new(mode: :rails, raw: raw, findings: findings,
156
157
  passed: own_clean?(findings))
157
158
  end
158
159
 
160
+ # What happened once the booted application was frozen: no
161
+ # entry point on this Rails, the first object it could not
162
+ # share, a request that broke on the frozen graph, or one
163
+ # that only broke inside a worker.
164
+ def ractorize_findings(info, entry)
165
+ return [] unless info.is_a?(Hash)
166
+
167
+ environment = entry[:environment]
168
+ prefix = own_prefix(entry[:root])
169
+ unless info["available"]
170
+ return [Finding.new(
171
+ check: "dynamic-rails",
172
+ severity: :info,
173
+ message: "Rails #{info["rails"]} has no ractorize!; " \
174
+ "the application graph was not frozen",
175
+ why: "Rails 8.2 adds Rails::Application#ractorize!, " \
176
+ "which deep-freezes the application and " \
177
+ "everything it reaches; a lazy memoization on " \
178
+ "any object in that graph raises FrozenError " \
179
+ "afterwards. Without it the probe can only " \
180
+ "sweep constants and class-level state.",
181
+ fix: "Upgrade to Rails 8.2 so the probe can freeze " \
182
+ "the application and serve a request through it.",
183
+ path: environment,
184
+ line: nil
185
+ )]
186
+ end
187
+
188
+ unless info["ok"]
189
+ site = failure_site(info, prefix: prefix)
190
+ return [Finding.new(
191
+ check: "dynamic-rails",
192
+ severity: :error,
193
+ message: "ractorize! failed: #{describe(info)}",
194
+ why: "Rails::Application#ractorize! deep-freezes the " \
195
+ "application and everything it reaches (routes, " \
196
+ "middleware, configuration); the object the " \
197
+ "error names is the first one that cannot be " \
198
+ "shared. #{RUNTIME_WHY}",
199
+ fix: "Make the named object shareable: freeze it, " \
200
+ "drop the Proc, Mutex, or IO it holds, or keep " \
201
+ "it per-Ractor; then re-run.",
202
+ path: site&.first || environment,
203
+ line: site&.last
204
+ )]
205
+ end
206
+
207
+ main = info["main_request"] || {}
208
+ unless main["ok"]
209
+ site = failure_site(main, prefix: prefix)
210
+ return [Finding.new(
211
+ check: "dynamic-rails",
212
+ severity: :error,
213
+ message: "GET / after ractorize! failed on the main " \
214
+ "Ractor: #{describe(main)}",
215
+ why: "The application is frozen, so a lazy " \
216
+ "memoization on any object the request touches " \
217
+ "writes an instance variable on a frozen object " \
218
+ "and raises FrozenError. #{RUNTIME_WHY}",
219
+ fix: "Compute the value eagerly in initialize, warm " \
220
+ "it in a freeze override that calls the reader " \
221
+ "before super, or drop the memo and recompute.",
222
+ path: site&.first || environment,
223
+ line: site&.last
224
+ )]
225
+ end
226
+
227
+ ractor = info["ractor_request"] || {}
228
+ return [] if ractor["ok"]
229
+
230
+ site = failure_site(ractor, prefix: prefix)
231
+ [Finding.new(
232
+ check: "dynamic-rails",
233
+ severity: :error,
234
+ message: "GET / inside a Ractor after ractorize! " \
235
+ "failed: #{describe(ractor)}",
236
+ why: "Serving on the main Ractor after ractorize! " \
237
+ "worked; inside a worker the request touched " \
238
+ "state a non-main Ractor cannot reach. " \
239
+ "#{RUNTIME_WHY}",
240
+ fix: "Remove global and class-level state touched " \
241
+ "during request handling; keep per-Ractor state " \
242
+ "in Ractor.store_if_absent.",
243
+ path: site&.first || environment,
244
+ line: site&.last
245
+ )]
246
+ end
247
+
159
248
  # A probe passes when the target's own surface is clean;
160
249
  # dependency errors surface in the findings and drive the
161
250
  # blocked verdict instead.
@@ -220,6 +309,24 @@ module Audition
220
309
  raw.fetch("class_state", []).each do |entry|
221
310
  findings << class_state_finding(entry, label)
222
311
  end
312
+ raw.fetch("unshareable_procs", []).each do |entry|
313
+ findings << runtime_finding(
314
+ entry, label,
315
+ check: "runtime-unshareable-proc",
316
+ severity: :warning,
317
+ message: "Rails could not make a callback block " \
318
+ "Ractor-shareable: #{entry["proc"]}",
319
+ why: "With unshareable_proc_action set to :warn, " \
320
+ "Rails ran Ractor.shareable_proc on this block " \
321
+ "and it raised Ractor::IsolationError, so the " \
322
+ "callback keeps an unshareable Proc; a non-main " \
323
+ "Ractor running it raises. #{RUNTIME_WHY}",
324
+ fix: "Capture only shareable values: freeze the " \
325
+ "local, inline it, or hoist a shareable leaf " \
326
+ "such as a Symbol into a fresh local assigned " \
327
+ "once before the block."
328
+ )
329
+ end
223
330
  raw.fetch("class_variables", []).each do |entry|
224
331
  findings << runtime_finding(
225
332
  entry, label,
@@ -462,12 +569,12 @@ module Audition
462
569
 
463
570
  # -- subprocess plumbing -------------------------------------
464
571
 
465
- # Harness output can carry arbitrary target bytes; force
466
- # valid UTF-8 before any string work or a binary exception
467
- # message crashes the whole run.
572
+ # The harness prints one Marshal document; anything else on
573
+ # its stdout (a crash before the document) is reported with
574
+ # the tail of its stderr. Stderr can carry arbitrary target
575
+ # bytes, so it is forced to valid UTF-8 first.
468
576
  def run(mode, payload = {}, root: nil)
469
577
  out, err, timed_out = execute(mode, payload, root: root)
470
- out = sanitize(out)
471
578
  err = sanitize(err)
472
579
  if timed_out
473
580
  return {"error" => {
@@ -475,8 +582,11 @@ module Audition
475
582
  "message" => "harness exceeded #{@timeout}s"
476
583
  }}
477
584
  end
478
- JSON.parse(out)
479
- rescue JSON::ParserError
585
+ document = Marshal.load(out)
586
+ raise TypeError, "not a document" unless document.is_a?(Hash)
587
+
588
+ document
589
+ rescue TypeError, ArgumentError, EOFError
480
590
  {"error" => {
481
591
  "class" => "HarnessFailure",
482
592
  "message" => err.split("\n").last(5).join("; ")
@@ -514,7 +624,8 @@ module Audition
514
624
  end
515
625
  end
516
626
  Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait|
517
- stdin.write(JSON.generate(payload))
627
+ stdin.binmode
628
+ stdin.write(Marshal.dump(payload))
518
629
  stdin.close
519
630
  out_reader = reader(stdout)
520
631
  err_reader = reader(stderr)
@@ -66,12 +66,16 @@ module Audition
66
66
  # @return [Boolean] see {#dependency?}
67
67
  # @!attribute [r] test
68
68
  # @return [Boolean] see {#test?}
69
+ # @!attribute [r] subject
70
+ # @return [String, nil] the object a finding is about, in a
71
+ # form the dynamic probe can match ("Owner/@ivar"), so a
72
+ # runtime proof of shareability can retire the static guess
69
73
  Finding = Data.define(
70
74
  :check, :severity, :message, :why, :fix,
71
- :path, :line, :source, :autofix, :dependency, :test
75
+ :path, :line, :source, :autofix, :dependency, :test, :subject
72
76
  ) do
73
77
  def initialize(source: nil, autofix: nil, dependency: false,
74
- test: false, **rest)
78
+ test: false, subject: nil, **rest)
75
79
  super
76
80
  end
77
81
 
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ # What the dynamic probe proved shareable retires the static
5
+ # pass's guesses about the same objects. A constant the probe
6
+ # read as shareable needs no static warning at all; class-level
7
+ # state whose every value was shareable after boot keeps its
8
+ # finding as an info note, because reads are legal from any
9
+ # Ractor and only a later write would raise.
10
+ module Reconciliation
11
+ PROVEN_WHY =
12
+ "The dynamic probe found every value this variable held " \
13
+ "after boot shareable, so reads from any Ractor are legal; " \
14
+ "only a later write would raise Ractor::IsolationError, " \
15
+ "and writes must stay on the main Ractor."
16
+ PROVEN_FIX =
17
+ "Keep every write at boot on the main Ractor, or warm the " \
18
+ "memo before spawning Ractors."
19
+
20
+ # @param findings [Array<Finding>] static findings
21
+ # @param results [Array<Dynamic::Result>] the probes that ran
22
+ # @return [Array<Finding>] findings with the disproven ones
23
+ # dropped or downgraded
24
+ def self.apply(findings, results)
25
+ constants, ivars = proven(results)
26
+ return findings if constants.empty? && ivars.empty?
27
+
28
+ findings.filter_map do |finding|
29
+ case finding.check
30
+ when "mutable-constants"
31
+ site = [realpath(finding.path), finding.line]
32
+ constants.include?(site) ? nil : finding
33
+ when "class-level-state"
34
+ if finding.severity != :info && finding.subject &&
35
+ ivars.include?(finding.subject)
36
+ finding.with(
37
+ severity: :info,
38
+ message: "#{finding.message}, shareable in the " \
39
+ "dynamic probe",
40
+ why: PROVEN_WHY,
41
+ fix: PROVEN_FIX
42
+ )
43
+ else
44
+ finding
45
+ end
46
+ else
47
+ finding
48
+ end
49
+ end
50
+ end
51
+
52
+ # @return [Array(Set, Set)] constant sites ([path, line]) and
53
+ # class-level ivars ("Owner/@name") observed shareable
54
+ def self.proven(results)
55
+ constants = Set.new
56
+ ivars = Set.new
57
+ results.each do |result|
58
+ raw = result.raw
59
+ next unless raw.is_a?(Hash)
60
+
61
+ Array(raw["proven_constants"]).each do |path, line|
62
+ constants << [realpath(path), line]
63
+ end
64
+ Array(raw["class_state"]).each do |entry|
65
+ unshareable = Array(entry["unshareable"])
66
+ owner = entry["const"].to_s
67
+ Array(entry["ivars"]).each do |ivar|
68
+ next if unshareable.include?(ivar)
69
+
70
+ ivars << "#{owner}/#{ivar}"
71
+ # A write through a singleton attribute only knows
72
+ # its owner's last segment.
73
+ ivars << "#{owner.split("::").last}/#{ivar}"
74
+ end
75
+ end
76
+ end
77
+ [constants, ivars]
78
+ end
79
+
80
+ def self.realpath(path)
81
+ File.realpath(path)
82
+ rescue SystemCallError, TypeError
83
+ path
84
+ end
85
+ end
86
+ end
@@ -227,6 +227,9 @@ module Audition
227
227
  kinds = ops.map { |op| op[:kind] }
228
228
  next if kinds.include?(:other)
229
229
  next if ops.any? { |op| op[:body] }
230
+ # A memo written inside an on_main block already runs
231
+ # on the main Ractor; the audit rates the shape itself.
232
+ next if ops.any? { |op| op[:proxied] }
230
233
 
231
234
  memos = memo_sites(ops)
232
235
  if memos.empty?
@@ -620,9 +623,29 @@ module Audition
620
623
  @sclass_depth = 0
621
624
  @def_stack = []
622
625
  @defined_depth = 0
626
+ @proxy_depth = 0
623
627
  super
624
628
  end
625
629
 
630
+ # A block handed to `on_main` runs on the main Ractor by
631
+ # construction (the read-then-proxy escape hatch), so the
632
+ # ivar writes inside it are recorded as proxied.
633
+ def visit_call_node(node)
634
+ block = node.block
635
+ unless node.name == :on_main && block.is_a?(Prism::BlockNode)
636
+ return super
637
+ end
638
+
639
+ visit(node.receiver) if node.receiver
640
+ visit(node.arguments) if node.arguments
641
+ @proxy_depth += 1
642
+ begin
643
+ visit(block)
644
+ ensure
645
+ @proxy_depth -= 1
646
+ end
647
+ end
648
+
626
649
  def visit_class_node(node)
627
650
  scoped(node.constant_path.location.slice, :class) do
628
651
  super(node)
@@ -766,7 +789,8 @@ module Audition
766
789
  def_id: current_def && current_def[:id],
767
790
  def_name: current_def && current_def[:name],
768
791
  def_node: current_def && current_def[:node],
769
- class_owner: @namespace.last[:kind] == :class
792
+ class_owner: @namespace.last[:kind] == :class,
793
+ proxied: @proxy_depth.positive?
770
794
  }
771
795
  end
772
796
  end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Lazy memoization on an instance is harmless until the
7
+ # instance is frozen: Ractor.make_shareable freezes every
8
+ # object it reaches, and the next `@x ||=` raises FrozenError.
9
+ # Two shapes make the freeze provable from the class alone: an
10
+ # initialize that ends by freezing self (a value object), and a
11
+ # `freeze` override (the class expects to be frozen). In the
12
+ # first the memo can never run; in the second it must be
13
+ # warmed inside the override, before super, which is the
14
+ # compute-on-freeze pattern. Class-level memos belong to the
15
+ # graph audit.
16
+ class InstanceMemoization < Base
17
+ explain :memo_after_self_freeze,
18
+ severity: :error,
19
+ message: "instance memoization %{ivar} in #%{method} on " \
20
+ "a class that freezes itself in initialize",
21
+ why: "The instance is frozen before any other method " \
22
+ "runs, so the first call writes an instance " \
23
+ "variable on a frozen object and raises " \
24
+ "FrozenError.",
25
+ fix: "Compute the value in initialize, before the " \
26
+ "freeze, and expose it with attr_reader; or drop " \
27
+ "the memo and recompute on each call."
28
+
29
+ explain :memo_not_warmed,
30
+ severity: :warning,
31
+ message: "freeze override leaves %{ivar} cold; " \
32
+ "#%{method} memoizes it lazily",
33
+ why: "Ractor.make_shareable calls freeze, so an " \
34
+ "instance frozen through this override raises " \
35
+ "FrozenError the first time #%{method} runs " \
36
+ "afterwards.",
37
+ fix: "Warm it in the override: call #%{method} (or " \
38
+ "assign %{ivar}) before super, the " \
39
+ "compute-on-freeze pattern; or compute it in " \
40
+ "initialize."
41
+
42
+ def initialize(file)
43
+ super
44
+ @contexts = []
45
+ @sclass_depth = 0
46
+ end
47
+
48
+ def visit_class_node(node) = scoped { super }
49
+
50
+ def visit_module_node(node) = scoped { super }
51
+
52
+ def visit_singleton_class_node(node)
53
+ @sclass_depth += 1
54
+ super
55
+ ensure
56
+ @sclass_depth -= 1
57
+ end
58
+
59
+ # Only plain instance methods count; a def is not entered,
60
+ # so nothing inside a method body opens a context.
61
+ def visit_def_node(node)
62
+ context = @contexts.last
63
+ return unless context && node.receiver.nil? &&
64
+ @sclass_depth.zero?
65
+
66
+ record_method(context, node)
67
+ end
68
+
69
+ private
70
+
71
+ def scoped
72
+ @contexts.push(
73
+ {memos: {}, methods: {}, self_freeze: false, warm: nil}
74
+ )
75
+ saved = @sclass_depth
76
+ @sclass_depth = 0
77
+ yield
78
+ ensure
79
+ @sclass_depth = saved
80
+ report(@contexts.pop)
81
+ end
82
+
83
+ def record_method(context, node)
84
+ case node.name
85
+ when :initialize
86
+ context[:self_freeze] = self_freezing?(node)
87
+ when :freeze
88
+ context[:warm] = touched_by(node)
89
+ else
90
+ context[:methods][node.name] ||= touched_by(node)
91
+ memo_sites(node).each do |ivar, write|
92
+ context[:memos][ivar] ||= {method: node.name, node: write}
93
+ end
94
+ end
95
+ end
96
+
97
+ def report(context)
98
+ memos = context[:memos]
99
+ return if memos.empty?
100
+
101
+ if context[:self_freeze]
102
+ memos.each do |ivar, memo|
103
+ flag(memo[:node], :memo_after_self_freeze,
104
+ ivar: ivar, method: memo[:method])
105
+ end
106
+ elsif context[:warm]
107
+ reached, warmed = warmed_closure(context)
108
+ memos.each do |ivar, memo|
109
+ next if reached.include?(memo[:method]) ||
110
+ warmed.include?(ivar)
111
+
112
+ flag(memo[:node], :memo_not_warmed,
113
+ ivar: ivar, method: memo[:method])
114
+ end
115
+ end
116
+ end
117
+
118
+ # Everything the override reaches through the class's own
119
+ # instance methods: a memo is warm when its method runs on
120
+ # the way, or when any method on the way assigns its ivar.
121
+ def warmed_closure(context)
122
+ methods = context[:methods]
123
+ reached = []
124
+ warmed = context[:warm][:ivars].dup
125
+ queue = context[:warm][:calls].dup
126
+ until queue.empty?
127
+ name = queue.shift
128
+ next if reached.include?(name)
129
+
130
+ reached << name
131
+ touched = methods[name] or next
132
+
133
+ warmed.concat(touched[:ivars])
134
+ queue.concat(touched[:calls])
135
+ end
136
+ [reached, warmed]
137
+ end
138
+
139
+ # initialize ends with `freeze`, `self.freeze`, or
140
+ # `Ractor.make_shareable(self)`.
141
+ def self_freezing?(node)
142
+ last = statements_of(node.body)&.last
143
+ return false unless last.is_a?(Prism::CallNode)
144
+
145
+ receiver = last.receiver
146
+ if last.name == :freeze
147
+ (receiver.nil? || receiver.is_a?(Prism::SelfNode)) &&
148
+ last.arguments.nil?
149
+ elsif last.name == :make_shareable
150
+ last.arguments&.arguments&.first.is_a?(Prism::SelfNode)
151
+ else
152
+ false
153
+ end
154
+ end
155
+
156
+ def statements_of(body)
157
+ case body
158
+ when Prism::StatementsNode then body.body
159
+ when Prism::BeginNode then body.statements&.body
160
+ end
161
+ end
162
+
163
+ # The methods a body calls on self and the ivars it assigns.
164
+ def touched_by(node)
165
+ calls = []
166
+ ivars = []
167
+ each_descendant(node.body) do |child|
168
+ case child
169
+ when Prism::CallNode
170
+ receiver = child.receiver
171
+ if receiver.nil? || receiver.is_a?(Prism::SelfNode)
172
+ calls << child.name
173
+ end
174
+ when Prism::InstanceVariableWriteNode,
175
+ Prism::InstanceVariableOrWriteNode
176
+ ivars << child.name.to_s
177
+ end
178
+ end
179
+ {calls: calls, ivars: ivars}
180
+ end
181
+
182
+ # `@x ||= v`, and `@x = v` guarded by `defined?(@x)` in the
183
+ # same method.
184
+ def memo_sites(node)
185
+ or_writes = []
186
+ writes = {}
187
+ guarded = []
188
+ each_descendant(node.body) do |child|
189
+ case child
190
+ when Prism::InstanceVariableOrWriteNode
191
+ or_writes << [child.name.to_s, child]
192
+ when Prism::InstanceVariableWriteNode
193
+ writes[child.name.to_s] ||= child
194
+ when Prism::DefinedNode
195
+ value = child.value
196
+ if value.is_a?(Prism::InstanceVariableReadNode)
197
+ guarded << value.name.to_s
198
+ end
199
+ end
200
+ end
201
+ guarded.each do |ivar|
202
+ or_writes << [ivar, writes[ivar]] if writes[ivar]
203
+ end
204
+ or_writes
205
+ end
206
+
207
+ # Blocks and lambdas inside a method still write self's
208
+ # ivars; a nested def or class does not.
209
+ def each_descendant(node)
210
+ queue = [node].compact
211
+ until queue.empty?
212
+ current = queue.shift
213
+ yield current
214
+ current.compact_child_nodes.each do |child|
215
+ next if child.is_a?(Prism::DefNode) ||
216
+ child.is_a?(Prism::ClassNode) ||
217
+ child.is_a?(Prism::ModuleNode)
218
+
219
+ queue << child
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end