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
|
@@ -37,6 +37,33 @@ module Audition
|
|
|
37
37
|
"BasicObject has no #freeze: use " \
|
|
38
38
|
"Object.new.freeze for such a sentinel."
|
|
39
39
|
|
|
40
|
+
explain :fresh_container,
|
|
41
|
+
severity: :error,
|
|
42
|
+
message: "constant %{name} holds an unfrozen %{type} " \
|
|
43
|
+
"returned by %{method}",
|
|
44
|
+
why: "The call allocates a new %{type} on every core " \
|
|
45
|
+
"receiver that defines it, and " \
|
|
46
|
+
"`# frozen_string_literal: true` covers literals " \
|
|
47
|
+
"only, so the constant holds an unfrozen object " \
|
|
48
|
+
"and a non-main Ractor reading it raises " \
|
|
49
|
+
"Ractor::IsolationError.",
|
|
50
|
+
fix: "Make the value deeply shareable at definition " \
|
|
51
|
+
"time with Ractor.make_shareable(...), or append " \
|
|
52
|
+
"`.freeze` when every element is itself shareable, " \
|
|
53
|
+
"since a bare `.freeze` is shallow."
|
|
54
|
+
|
|
55
|
+
explain :unshareable_object,
|
|
56
|
+
severity: :error,
|
|
57
|
+
message: "constant %{name} holds a Method object from " \
|
|
58
|
+
"%{method}, which is never shareable",
|
|
59
|
+
why: "Method and UnboundMethod objects are not " \
|
|
60
|
+
"Ractor-shareable and freezing one does not help " \
|
|
61
|
+
"(verified on Ruby 4.0); a non-main Ractor reading " \
|
|
62
|
+
"this constant raises Ractor::IsolationError.",
|
|
63
|
+
fix: "Store the method name as a Symbol and look the " \
|
|
64
|
+
"method up where it is called, or keep the Method " \
|
|
65
|
+
"object per-Ractor with Ractor.store_if_absent."
|
|
66
|
+
|
|
40
67
|
explain :mutable_container,
|
|
41
68
|
severity: :error,
|
|
42
69
|
message: "constant %{name} holds a mutable %{type} " \
|
|
@@ -46,7 +73,11 @@ module Audition
|
|
|
46
73
|
"shareable; otherwise make it deeply shareable with " \
|
|
47
74
|
"`# shareable_constant_value: literal` or " \
|
|
48
75
|
"Ractor.make_shareable(...), since a bare " \
|
|
49
|
-
"`.freeze` is shallow."
|
|
76
|
+
"`.freeze` is shallow. A public constant that " \
|
|
77
|
+
"applications mutate keeps its name and is read " \
|
|
78
|
+
"through an initialize keyword default into an " \
|
|
79
|
+
"ivar, so a shareable instance carries a frozen " \
|
|
80
|
+
"copy; mutating the constant is then deprecated."
|
|
50
81
|
|
|
51
82
|
explain :shallow_freeze,
|
|
52
83
|
severity: :error,
|
|
@@ -153,12 +184,16 @@ module Audition
|
|
|
153
184
|
"freeze it (each_with_object then .freeze), " \
|
|
154
185
|
"or move the registry behind a writer that " \
|
|
155
186
|
"rebuilds and refreezes on each change " \
|
|
156
|
-
"(copy-on-write
|
|
187
|
+
"(copy-on-write; Ractor.make_shareable when the " \
|
|
188
|
+
"additions may be unfrozen). A " \
|
|
157
189
|
"registry that plugins extend during boot is " \
|
|
158
190
|
"frozen in the last boot hook (after_initialize) " \
|
|
159
191
|
"rather than at definition, and writes after the " \
|
|
160
192
|
"freeze merge into a fresh frozen copy with a " \
|
|
161
|
-
"deprecation instead of raising."
|
|
193
|
+
"deprecation instead of raising. A public " \
|
|
194
|
+
"constant that applications mutate keeps its " \
|
|
195
|
+
"name and is read through an initialize keyword " \
|
|
196
|
+
"default into an ivar; mutating it is deprecated."
|
|
162
197
|
|
|
163
198
|
on :constant_write_node, :constant_or_write_node do |node|
|
|
164
199
|
examine(node.name.to_s, node, node.value)
|
|
@@ -213,6 +248,19 @@ module Audition
|
|
|
213
248
|
flag(node, :mutable_container, name: name,
|
|
214
249
|
type: container_type(value),
|
|
215
250
|
autofix: fix_ok ? freeze_container(value) : nil)
|
|
251
|
+
when :fresh_container
|
|
252
|
+
# A later bare freeze settles the top level; only
|
|
253
|
+
# provably mutable elements are still worth a report.
|
|
254
|
+
unless frozen_later?(name) &&
|
|
255
|
+
classifier.fresh_elements(value) != :mutable
|
|
256
|
+
flag(node, :fresh_container, name: name,
|
|
257
|
+
type: classifier.fresh_type(value),
|
|
258
|
+
method: call_display(value),
|
|
259
|
+
autofix: fix_ok ? freeze_fresh(value) : nil)
|
|
260
|
+
end
|
|
261
|
+
when :unshareable_object
|
|
262
|
+
flag(node, :unshareable_object, name: name,
|
|
263
|
+
method: call_display(value))
|
|
216
264
|
when :shallow_freeze
|
|
217
265
|
flag(node, :shallow_freeze, name: name,
|
|
218
266
|
autofix:
|
|
@@ -354,19 +402,50 @@ module Audition
|
|
|
354
402
|
|
|
355
403
|
# Ternaries classify as strings when both branches are;
|
|
356
404
|
# `.freeze` binds tighter than `?:`, so they get parens.
|
|
405
|
+
# `X + "s"` is a String or a Pathname, whatever X is; only
|
|
406
|
+
# a string literal receiver pins the type.
|
|
357
407
|
def call_type(call)
|
|
358
408
|
case classifier.const_name(call.receiver)
|
|
359
409
|
when "Regexp" then "Regexp"
|
|
360
410
|
when "Object", "BasicObject" then "Object"
|
|
361
|
-
else
|
|
411
|
+
else
|
|
412
|
+
if call.name == :+ && !string_receiver?(call)
|
|
413
|
+
"object"
|
|
414
|
+
else
|
|
415
|
+
"String"
|
|
416
|
+
end
|
|
362
417
|
end
|
|
363
418
|
end
|
|
364
419
|
|
|
420
|
+
def string_receiver?(call)
|
|
421
|
+
receiver = call.receiver
|
|
422
|
+
receiver.is_a?(Prism::StringNode) ||
|
|
423
|
+
receiver.is_a?(Prism::InterpolatedStringNode)
|
|
424
|
+
end
|
|
425
|
+
|
|
365
426
|
def call_display(call)
|
|
366
|
-
|
|
427
|
+
name = call.name
|
|
428
|
+
return "the #{name} operator" if name.match?(/\A[^a-z_]/i)
|
|
429
|
+
return name.to_s if call.receiver.nil?
|
|
367
430
|
|
|
368
431
|
owner = classifier.const_name(call.receiver)
|
|
369
|
-
|
|
432
|
+
return "#{owner}.#{name}" if owner
|
|
433
|
+
return ".#{name}" unless literal_receiver?(call)
|
|
434
|
+
|
|
435
|
+
"#{classifier.fresh_string_owner(call)}##{name}"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def literal_receiver?(call)
|
|
439
|
+
receiver = call.receiver
|
|
440
|
+
receiver.is_a?(Prism::StringNode) ||
|
|
441
|
+
receiver.is_a?(Prism::InterpolatedStringNode) ||
|
|
442
|
+
receiver.is_a?(Prism::SymbolNode) ||
|
|
443
|
+
receiver.is_a?(Prism::RegularExpressionNode) ||
|
|
444
|
+
receiver.is_a?(Prism::ArrayNode) ||
|
|
445
|
+
receiver.is_a?(Prism::HashNode) ||
|
|
446
|
+
LiteralClassifier::NUMERIC_LITERALS
|
|
447
|
+
.any? { |type| receiver.is_a?(type) } ||
|
|
448
|
+
!classifier.array_root(receiver).nil?
|
|
370
449
|
end
|
|
371
450
|
|
|
372
451
|
# The receiver is arbitrary, often a chain, so the
|
|
@@ -385,9 +464,10 @@ module Audition
|
|
|
385
464
|
end
|
|
386
465
|
|
|
387
466
|
# `.freeze` binds tighter than an operator: `"a" + "b".freeze`
|
|
388
|
-
# freezes only "b"
|
|
389
|
-
#
|
|
390
|
-
#
|
|
467
|
+
# freezes only "b" and `+"a".freeze` dups the frozen
|
|
468
|
+
# literal back into a mutable one, so operator and unary
|
|
469
|
+
# calls get parentheses while literals and parenthesized
|
|
470
|
+
# or argument-free calls take the bare suffix.
|
|
391
471
|
def bare_freezable?(value)
|
|
392
472
|
case value
|
|
393
473
|
when Prism::StringNode, Prism::InterpolatedStringNode,
|
|
@@ -396,6 +476,8 @@ module Audition
|
|
|
396
476
|
when Prism::ArrayNode
|
|
397
477
|
!value.opening_loc.nil?
|
|
398
478
|
when Prism::CallNode
|
|
479
|
+
return false if value.name.end_with?("@")
|
|
480
|
+
|
|
399
481
|
!value.opening_loc.nil? ||
|
|
400
482
|
(!value.receiver.nil? && value.arguments.nil?)
|
|
401
483
|
else
|
|
@@ -445,6 +527,16 @@ module Audition
|
|
|
445
527
|
)
|
|
446
528
|
end
|
|
447
529
|
|
|
530
|
+
# A fresh container's elements are unknown, so the deep
|
|
531
|
+
# wrap is the fix, except for arrays of Integers.
|
|
532
|
+
def freeze_fresh(value)
|
|
533
|
+
if classifier.fresh_elements(value) == :shareable
|
|
534
|
+
append_freeze(value)
|
|
535
|
+
else
|
|
536
|
+
wrap_make_shareable(value)
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
448
540
|
# Plain `.freeze` where every element is provably
|
|
449
541
|
# shareable, the plain-Ruby shape; the deep wrap only
|
|
450
542
|
# where a shallow freeze would not be enough.
|
|
@@ -9,6 +9,15 @@ module Audition
|
|
|
9
9
|
# resolution depth of every local reference, so captures are
|
|
10
10
|
# detectable exactly: a reference whose depth reaches past the
|
|
11
11
|
# Ractor block's own scope is an outer capture.
|
|
12
|
+
#
|
|
13
|
+
# Ractor.shareable_proc applies a weaker rule to any block at
|
|
14
|
+
# conversion time: a captured local may not hold an
|
|
15
|
+
# unshareable object and may not be assigned more than once.
|
|
16
|
+
# Rails converts the blocks handed to its callback macros the
|
|
17
|
+
# same way once unshareable_proc_action is set, so a callback
|
|
18
|
+
# whose capture is provably unshareable is reported too; a
|
|
19
|
+
# capture of unknown value stays silent, and the boot gate is
|
|
20
|
+
# its detector.
|
|
12
21
|
class RactorIsolation < Base
|
|
13
22
|
explain :outer_capture,
|
|
14
23
|
severity: :error,
|
|
@@ -22,6 +31,105 @@ module Audition
|
|
|
22
31
|
"Ractor.new(x) { |x| ... }, or send them " \
|
|
23
32
|
"through a Ractor::Port."
|
|
24
33
|
|
|
34
|
+
CAPTURE_FIX =
|
|
35
|
+
"Capture a shareable value: freeze the local (a " \
|
|
36
|
+
"frozen literal or .freeze), inline it, or hoist a " \
|
|
37
|
+
"shareable leaf such as a Symbol into a fresh local " \
|
|
38
|
+
"assigned once before the block."
|
|
39
|
+
|
|
40
|
+
explain :shareable_proc_capture,
|
|
41
|
+
severity: :error,
|
|
42
|
+
message: "%{method} block captures %{what}",
|
|
43
|
+
why: "Ractor.shareable_proc refuses a block that can " \
|
|
44
|
+
"refer to an unshareable object through an outer " \
|
|
45
|
+
"local, or whose outer local is assigned more " \
|
|
46
|
+
"than once, with Ractor::IsolationError at " \
|
|
47
|
+
"conversion time.",
|
|
48
|
+
fix: CAPTURE_FIX
|
|
49
|
+
|
|
50
|
+
explain :callback_capture,
|
|
51
|
+
severity: :warning,
|
|
52
|
+
message: "block passed to %{method} captures %{what}",
|
|
53
|
+
why: "Rails stores the block as a callback and, with " \
|
|
54
|
+
"unshareable_proc_action set to :warn or :raise, " \
|
|
55
|
+
"runs it through Ractor.shareable_proc; a block " \
|
|
56
|
+
"that refers to an unshareable local, or to one " \
|
|
57
|
+
"assigned more than once, cannot be made " \
|
|
58
|
+
"shareable and stays an unshareable Proc, which " \
|
|
59
|
+
"raises Ractor::IsolationError once a non-main " \
|
|
60
|
+
"Ractor runs the callback.",
|
|
61
|
+
fix: CAPTURE_FIX
|
|
62
|
+
|
|
63
|
+
# Methods that keep their block for later, as a callback or
|
|
64
|
+
# boot hook; Rails runs each through try_shareable_proc.
|
|
65
|
+
CALLBACK_MACROS = %i[
|
|
66
|
+
validate validates_each set_callback on_load initializer
|
|
67
|
+
to_prepare rescue_from scope default_scope
|
|
68
|
+
].freeze
|
|
69
|
+
CALLBACK_PREFIXES = %w[
|
|
70
|
+
before_ after_ around_ prepend_before_ prepend_after_
|
|
71
|
+
prepend_around_ append_before_ append_after_
|
|
72
|
+
append_around_
|
|
73
|
+
].freeze
|
|
74
|
+
CONVERTERS = %i[shareable_proc shareable_lambda].freeze
|
|
75
|
+
|
|
76
|
+
# Classifications a captured value must have for the
|
|
77
|
+
# conversion to fail for certain.
|
|
78
|
+
UNSHAREABLE_KINDS = %i[
|
|
79
|
+
mutable_string mutable_container mutable_call
|
|
80
|
+
sync_primitive default_proc proc
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
def initialize(file)
|
|
84
|
+
super
|
|
85
|
+
@frames = []
|
|
86
|
+
@pending = []
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Captures are judged once the whole file is read: a local
|
|
90
|
+
# reassigned after the block is refused just the same.
|
|
91
|
+
def visit_program_node(node)
|
|
92
|
+
framed { super }
|
|
93
|
+
@pending.each { |entry| judge(entry) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def visit_class_node(node) = framed { super }
|
|
97
|
+
|
|
98
|
+
def visit_module_node(node) = framed { super }
|
|
99
|
+
|
|
100
|
+
def visit_singleton_class_node(node) = framed { super }
|
|
101
|
+
|
|
102
|
+
def visit_def_node(node) = framed { super }
|
|
103
|
+
|
|
104
|
+
def visit_block_node(node) = framed { super }
|
|
105
|
+
|
|
106
|
+
def visit_lambda_node(node) = framed { super }
|
|
107
|
+
|
|
108
|
+
def visit_local_variable_write_node(node)
|
|
109
|
+
assign(node.name, node.depth, node.value)
|
|
110
|
+
super
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def visit_local_variable_or_write_node(node)
|
|
114
|
+
assign(node.name, node.depth, nil)
|
|
115
|
+
super
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def visit_local_variable_operator_write_node(node)
|
|
119
|
+
assign(node.name, node.depth, nil)
|
|
120
|
+
super
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def visit_local_variable_and_write_node(node)
|
|
124
|
+
assign(node.name, node.depth, nil)
|
|
125
|
+
super
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def visit_local_variable_target_node(node)
|
|
129
|
+
assign(node.name, node.depth, nil)
|
|
130
|
+
super
|
|
131
|
+
end
|
|
132
|
+
|
|
25
133
|
def visit_call_node(node)
|
|
26
134
|
examine(node)
|
|
27
135
|
super
|
|
@@ -29,18 +137,37 @@ module Audition
|
|
|
29
137
|
|
|
30
138
|
private
|
|
31
139
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
140
|
+
def framed
|
|
141
|
+
@frames.push({})
|
|
142
|
+
yield
|
|
143
|
+
ensure
|
|
144
|
+
@frames.pop
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Prism resolves a local write to the scope `depth` levels
|
|
148
|
+
# up; the frame at that level records every assignment so
|
|
149
|
+
# the capture judge can count them and classify the value.
|
|
150
|
+
def assign(name, depth, value)
|
|
151
|
+
frame = @frames[-1 - depth]
|
|
152
|
+
return unless frame
|
|
153
|
+
|
|
154
|
+
(frame[name.to_s] ||= []) << value
|
|
155
|
+
end
|
|
35
156
|
|
|
157
|
+
def examine(node)
|
|
36
158
|
block = node.block
|
|
37
|
-
return unless block.is_a?(Prism::BlockNode)
|
|
38
|
-
return unless block.body
|
|
159
|
+
return unless block.is_a?(Prism::BlockNode) && block.body
|
|
39
160
|
|
|
40
|
-
|
|
41
|
-
|
|
161
|
+
if node.name == :new && ractor_receiver?(node.receiver)
|
|
162
|
+
names = CaptureScanner.scan(block.body)
|
|
163
|
+
return if names.empty?
|
|
42
164
|
|
|
43
|
-
|
|
165
|
+
flag(node, :outer_capture, names: names.join(", "))
|
|
166
|
+
elsif CONVERTERS.include?(node.name)
|
|
167
|
+
defer(node, :shareable_proc_capture)
|
|
168
|
+
elsif callback_macro?(node.name)
|
|
169
|
+
defer(node, :callback_capture)
|
|
170
|
+
end
|
|
44
171
|
end
|
|
45
172
|
|
|
46
173
|
def ractor_receiver?(receiver)
|
|
@@ -48,20 +175,90 @@ module Audition
|
|
|
48
175
|
receiver.name == :Ractor
|
|
49
176
|
end
|
|
50
177
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
178
|
+
def callback_macro?(name)
|
|
179
|
+
return true if CALLBACK_MACROS.include?(name)
|
|
180
|
+
|
|
181
|
+
text = name.to_s
|
|
182
|
+
CALLBACK_PREFIXES.any? { |prefix| text.start_with?(prefix) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The frames a capture resolves to are the ones open now;
|
|
186
|
+
# they keep filling as traversal continues, so the entry
|
|
187
|
+
# holds references and is judged at the end.
|
|
188
|
+
def defer(node, key)
|
|
189
|
+
captures = CaptureScanner.captures(node.block.body)
|
|
190
|
+
return if captures.empty?
|
|
191
|
+
|
|
192
|
+
resolved = captures.filter_map do |name, depth|
|
|
193
|
+
frame = @frames[-depth]
|
|
194
|
+
[name, frame] if frame
|
|
195
|
+
end
|
|
196
|
+
return if resolved.empty?
|
|
197
|
+
|
|
198
|
+
@pending << {node: node, key: key, captures: resolved}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def judge(entry)
|
|
202
|
+
what = entry[:captures].filter_map do |name, frame|
|
|
203
|
+
describe_capture(name, frame[name])
|
|
204
|
+
end
|
|
205
|
+
return if what.empty?
|
|
206
|
+
|
|
207
|
+
node = entry[:node]
|
|
208
|
+
flag(node, entry[:key],
|
|
209
|
+
method: method_display(node), what: what.join(", "))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def describe_capture(name, assignments)
|
|
213
|
+
return nil if assignments.nil? || assignments.empty?
|
|
214
|
+
return "reassigned local #{name}" if assignments.size > 1
|
|
215
|
+
|
|
216
|
+
value = assignments.first
|
|
217
|
+
return nil if value.nil?
|
|
218
|
+
|
|
219
|
+
if UNSHAREABLE_KINDS.include?(classifier.classify(value))
|
|
220
|
+
"unshareable local #{name}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def method_display(node)
|
|
225
|
+
receiver = node.receiver
|
|
226
|
+
case receiver
|
|
227
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
228
|
+
"#{receiver.location.slice}.#{node.name}"
|
|
229
|
+
else
|
|
230
|
+
node.name.to_s
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def classifier
|
|
235
|
+
@classifier ||= LiteralClassifier.new(
|
|
236
|
+
frozen_string_literal: file.frozen_string_literal?
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Walks a block's body tracking how many block scopes deep
|
|
241
|
+
# we are; a local reference with depth greater than that
|
|
242
|
+
# resolves outside the block, `depth - level` scopes above
|
|
243
|
+
# it.
|
|
54
244
|
class CaptureScanner < Prism::Visitor
|
|
55
245
|
def self.scan(body)
|
|
246
|
+
captures(body).map(&:first)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @return [Array<Array(String, Integer)>] each captured
|
|
250
|
+
# name once, with how many scopes above the block it
|
|
251
|
+
# lives
|
|
252
|
+
def self.captures(body)
|
|
56
253
|
scanner = new
|
|
57
254
|
scanner.visit(body)
|
|
58
|
-
scanner.
|
|
255
|
+
scanner.captures.uniq(&:first)
|
|
59
256
|
end
|
|
60
257
|
|
|
61
|
-
attr_reader :
|
|
258
|
+
attr_reader :captures
|
|
62
259
|
|
|
63
260
|
def initialize
|
|
64
|
-
@
|
|
261
|
+
@captures = []
|
|
65
262
|
@level = 0
|
|
66
263
|
super
|
|
67
264
|
end
|
|
@@ -126,7 +323,9 @@ module Audition
|
|
|
126
323
|
private
|
|
127
324
|
|
|
128
325
|
def note(node)
|
|
129
|
-
|
|
326
|
+
return unless node.depth > @level
|
|
327
|
+
|
|
328
|
+
@captures << [node.name.to_s, node.depth - @level]
|
|
130
329
|
end
|
|
131
330
|
end
|
|
132
331
|
end
|
|
@@ -48,10 +48,13 @@ module Audition
|
|
|
48
48
|
"same way). Writes always need the main Ractor.",
|
|
49
49
|
fix: "Give it a frozen default (default: [].freeze) " \
|
|
50
50
|
"and write copy-on-write: self.x = (x | [v])" \
|
|
51
|
-
".freeze
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
51
|
+
".freeze when every element is shareable, " \
|
|
52
|
+
"self.x = Ractor.make_shareable(x | [v]) when the " \
|
|
53
|
+
"additions may be unfrozen (Symbol#to_s returns " \
|
|
54
|
+
"an unfrozen String), since a plain freeze is " \
|
|
55
|
+
"shallow; do every write at boot on the main " \
|
|
56
|
+
"Ractor. The dynamic probe reports ground truth " \
|
|
57
|
+
"for the installed Rails."
|
|
55
58
|
|
|
56
59
|
explain :objectspace_id2ref,
|
|
57
60
|
severity: :warning,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "checks/base"
|
|
4
4
|
require_relative "checks/dependency_class_state"
|
|
5
5
|
require_relative "checks/global_variables"
|
|
6
|
+
require_relative "checks/instance_memoization"
|
|
6
7
|
require_relative "checks/mutable_constants"
|
|
7
8
|
require_relative "checks/ractor_isolation"
|
|
8
9
|
require_relative "checks/runtime_require"
|
|
@@ -13,9 +14,9 @@ module Audition
|
|
|
13
14
|
module Static
|
|
14
15
|
module Checks
|
|
15
16
|
BUILT_IN = [
|
|
16
|
-
DependencyClassState, GlobalVariables,
|
|
17
|
-
RactorIsolation, RuntimeRequire,
|
|
18
|
-
UnshareableReads
|
|
17
|
+
DependencyClassState, GlobalVariables, InstanceMemoization,
|
|
18
|
+
MutableConstants, RactorIsolation, RuntimeRequire,
|
|
19
|
+
UnsafeCalls, UnshareableReads
|
|
19
20
|
].freeze
|
|
20
21
|
|
|
21
22
|
# Expression-level checks, run per file. Class variables and
|
|
@@ -35,11 +35,25 @@ module Audition
|
|
|
35
35
|
"per-subclass values compute in the inherited hook " \
|
|
36
36
|
"(guard on subclass.name for anonymous classes). For " \
|
|
37
37
|
"collections, rebuild and refreeze on write, " \
|
|
38
|
-
"copy-on-write: self.list = " \
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
38
|
+
"copy-on-write: self.list = (list + [item]).freeze " \
|
|
39
|
+
"when every element is shareable, self.list = " \
|
|
40
|
+
"Ractor.make_shareable(list + [item]) when the " \
|
|
41
|
+
"additions may be unfrozen, since a plain freeze is " \
|
|
42
|
+
"shallow; never mutate in place. As a last resort use " \
|
|
43
|
+
"Ractor.store_if_absent for per-Ractor state, or read " \
|
|
44
|
+
"the ivar first and proxy the write to the main Ractor."
|
|
45
|
+
PROXIED_WHY =
|
|
46
|
+
"The read is the lock-free fast path and the write runs " \
|
|
47
|
+
"on the main Ractor through on_main, so no non-main " \
|
|
48
|
+
"Ractor ever writes class state. Every worker waiting " \
|
|
49
|
+
"on main serializes there, and a worker's next read " \
|
|
50
|
+
"raises Ractor::IsolationError unless the memoized " \
|
|
51
|
+
"value is shareable."
|
|
52
|
+
PROXIED_FIX =
|
|
53
|
+
"Keep the hatch rare: delete the memo or warm it at " \
|
|
54
|
+
"boot where possible, and make the computed value " \
|
|
55
|
+
"deeply frozen inside the block (.freeze or " \
|
|
56
|
+
"Ractor.make_shareable) so workers can read it."
|
|
43
57
|
FROZEN_MEMO_WHY =
|
|
44
58
|
"Every write memoizes a shareable (frozen) value, so " \
|
|
45
59
|
"non-main Ractors can read it once it has been " \
|
|
@@ -760,6 +774,7 @@ module Audition
|
|
|
760
774
|
finding_at(
|
|
761
775
|
defn,
|
|
762
776
|
check: "class-level-state",
|
|
777
|
+
subject: "#{owner}/#{variable}",
|
|
763
778
|
severity: :info,
|
|
764
779
|
message: "frozen memoization #{variable} on " \
|
|
765
780
|
"#{owner}; warm it on the main Ractor",
|
|
@@ -770,16 +785,29 @@ module Audition
|
|
|
770
785
|
finding_at(
|
|
771
786
|
defn,
|
|
772
787
|
check: "class-level-state",
|
|
788
|
+
subject: "#{owner}/#{variable}",
|
|
773
789
|
severity: :warning,
|
|
774
790
|
message: "best-effort frozen state #{variable} " \
|
|
775
791
|
"on #{owner}",
|
|
776
792
|
why: BEST_EFFORT_WHY,
|
|
777
793
|
fix: BEST_EFFORT_FIX
|
|
778
794
|
)
|
|
795
|
+
when :proxied, :proxied_frozen
|
|
796
|
+
finding_at(
|
|
797
|
+
defn,
|
|
798
|
+
check: "class-level-state",
|
|
799
|
+
subject: "#{owner}/#{variable}",
|
|
800
|
+
severity: (verdict == :proxied) ? :warning : :info,
|
|
801
|
+
message: "memoization #{variable} on #{owner} " \
|
|
802
|
+
"proxied to the main Ractor",
|
|
803
|
+
why: PROXIED_WHY,
|
|
804
|
+
fix: PROXIED_FIX
|
|
805
|
+
)
|
|
779
806
|
else
|
|
780
807
|
finding_at(
|
|
781
808
|
defn,
|
|
782
809
|
check: "class-level-state",
|
|
810
|
+
subject: "#{owner}/#{variable}",
|
|
783
811
|
message: "class-level instance variable " \
|
|
784
812
|
"#{variable} on #{owner}",
|
|
785
813
|
why: STATE_WHY,
|
|
@@ -817,7 +845,8 @@ module Audition
|
|
|
817
845
|
fix: STATE_FIX,
|
|
818
846
|
path: path,
|
|
819
847
|
line: line,
|
|
820
|
-
source: source_line(path, line)
|
|
848
|
+
source: source_line(path, line),
|
|
849
|
+
subject: "#{owner}/@#{ivar}"
|
|
821
850
|
)
|
|
822
851
|
end
|
|
823
852
|
end
|
|
@@ -855,7 +884,8 @@ module Audition
|
|
|
855
884
|
fix: STATE_FIX,
|
|
856
885
|
path: path,
|
|
857
886
|
line: line,
|
|
858
|
-
source: source_line(path, line)
|
|
887
|
+
source: source_line(path, line),
|
|
888
|
+
subject: "#{owner}/@#{name}"
|
|
859
889
|
)
|
|
860
890
|
end
|
|
861
891
|
end
|
|
@@ -920,7 +950,8 @@ module Audition
|
|
|
920
950
|
fix: STATE_FIX,
|
|
921
951
|
path: path,
|
|
922
952
|
line: line,
|
|
923
|
-
source: source_line(path, line)
|
|
953
|
+
source: source_line(path, line),
|
|
954
|
+
subject: "#{owner}/#{ivar}"
|
|
924
955
|
)
|
|
925
956
|
end
|
|
926
957
|
end
|
|
@@ -958,7 +989,8 @@ module Audition
|
|
|
958
989
|
fix: STATE_FIX,
|
|
959
990
|
path: path,
|
|
960
991
|
line: line,
|
|
961
|
-
source: source_line(path, line)
|
|
992
|
+
source: source_line(path, line),
|
|
993
|
+
subject: "#{owner}/#{ivar}"
|
|
962
994
|
)
|
|
963
995
|
end
|
|
964
996
|
end
|
|
@@ -1214,7 +1246,7 @@ module Audition
|
|
|
1214
1246
|
end
|
|
1215
1247
|
|
|
1216
1248
|
def finding_at(defn, check:, message:, why:, fix:,
|
|
1217
|
-
severity: :error)
|
|
1249
|
+
severity: :error, subject: nil)
|
|
1218
1250
|
path = path_from_uri(defn.location.uri)
|
|
1219
1251
|
line = defn.location.start_line + 1
|
|
1220
1252
|
Finding.new(
|
|
@@ -1225,7 +1257,8 @@ module Audition
|
|
|
1225
1257
|
fix: fix,
|
|
1226
1258
|
path: path,
|
|
1227
1259
|
line: line,
|
|
1228
|
-
source: source_line(path, line)
|
|
1260
|
+
source: source_line(path, line),
|
|
1261
|
+
subject: subject
|
|
1229
1262
|
)
|
|
1230
1263
|
end
|
|
1231
1264
|
|
|
@@ -1308,7 +1341,10 @@ module Audition
|
|
|
1308
1341
|
|
|
1309
1342
|
# Cross-file merge keeps the weakest promise: any dirty file
|
|
1310
1343
|
# taints the group, and best-effort beats fully frozen.
|
|
1311
|
-
VERDICT_RANK = {
|
|
1344
|
+
VERDICT_RANK = {
|
|
1345
|
+
dirty: 0, best_effort: 1, proxied: 2, proxied_frozen: 3,
|
|
1346
|
+
frozen: 4
|
|
1347
|
+
}.freeze
|
|
1312
1348
|
|
|
1313
1349
|
def weaker_verdict(existing, verdict)
|
|
1314
1350
|
return verdict unless existing
|
|
@@ -1321,6 +1357,9 @@ module Audition
|
|
|
1321
1357
|
return :dirty if ops.any? { |op| op[:kind] == :other }
|
|
1322
1358
|
|
|
1323
1359
|
memos = Rewriters::Memoization.memo_sites(ops)
|
|
1360
|
+
if memos.any? && memos.all? { |m| m[:op][:proxied] }
|
|
1361
|
+
return proxied_verdict(ops, memos, classifier)
|
|
1362
|
+
end
|
|
1324
1363
|
if memos.any?
|
|
1325
1364
|
return group_frozen?(ops, classifier) ? :frozen : :dirty
|
|
1326
1365
|
end
|
|
@@ -1340,6 +1379,22 @@ module Audition
|
|
|
1340
1379
|
(all_safe && wrapped) ? :best_effort : :dirty
|
|
1341
1380
|
end
|
|
1342
1381
|
|
|
1382
|
+
# The read-then-proxy hatch, `@x || on_main(self) { @x ||= v }`:
|
|
1383
|
+
# every write is a memo site inside the block, so the main
|
|
1384
|
+
# Ractor performs it by construction, and the value decides
|
|
1385
|
+
# between a note and a warning. A plain write elsewhere
|
|
1386
|
+
# (a reset) is a worker-side write again.
|
|
1387
|
+
def proxied_verdict(ops, memos, classifier)
|
|
1388
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
1389
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
1390
|
+
return :dirty unless (writes - memo_ops).empty?
|
|
1391
|
+
|
|
1392
|
+
frozen = memos.all? do |memo|
|
|
1393
|
+
frozen_memo_value?(memo[:op][:node].value, classifier)
|
|
1394
|
+
end
|
|
1395
|
+
frozen ? :proxied_frozen : :proxied
|
|
1396
|
+
end
|
|
1397
|
+
|
|
1343
1398
|
# Matches the emitted setter recipe:
|
|
1344
1399
|
# (Ractor.make_shareable(value) rescue value)
|
|
1345
1400
|
def best_effort_value?(node)
|