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
|
@@ -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
|
|
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,
|
|
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
|
-
#
|
|
466
|
-
#
|
|
467
|
-
#
|
|
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
|
-
|
|
479
|
-
|
|
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.
|
|
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)
|
data/lib/audition/finding.rb
CHANGED
|
@@ -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
|
data/lib/audition/rewriters.rb
CHANGED
|
@@ -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
|