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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +290 -0
- data/exe/audition +6 -0
- data/lib/audition/baseline.rb +66 -0
- data/lib/audition/bundle_sweep.rb +110 -0
- data/lib/audition/cli.rb +415 -0
- data/lib/audition/config.rb +67 -0
- data/lib/audition/directives.rb +56 -0
- data/lib/audition/dynamic/harness.rb +423 -0
- data/lib/audition/dynamic/prober.rb +362 -0
- data/lib/audition/finding.rb +94 -0
- data/lib/audition/fixer.rb +141 -0
- data/lib/audition/report.rb +344 -0
- data/lib/audition/rewriters.rb +459 -0
- data/lib/audition/static/analyzer.rb +96 -0
- data/lib/audition/static/checks/base.rb +160 -0
- data/lib/audition/static/checks/global_variables.rb +67 -0
- data/lib/audition/static/checks/mutable_constants.rb +145 -0
- data/lib/audition/static/checks/ractor_isolation.rb +105 -0
- data/lib/audition/static/checks/runtime_require.rb +126 -0
- data/lib/audition/static/checks/unsafe_calls.rb +166 -0
- data/lib/audition/static/checks.rb +46 -0
- data/lib/audition/static/graph_audit.rb +143 -0
- data/lib/audition/static/literal_classifier.rb +145 -0
- data/lib/audition/static/source_file.rb +109 -0
- data/lib/audition/target.rb +172 -0
- data/lib/audition/version.rb +5 -0
- data/lib/audition.rb +37 -0
- metadata +143 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
module Checks
|
|
6
|
+
# Non-main Ractors cannot touch global variables: reads and
|
|
7
|
+
# writes raise Ractor::IsolationError. The exceptions below are
|
|
8
|
+
# ractor-local or frame-local and verified safe on Ruby 4.0.
|
|
9
|
+
# Regexp capture globals ($1, $&, $`, $', $+) are separate
|
|
10
|
+
# Prism node types and never flagged.
|
|
11
|
+
class GlobalVariables < Base
|
|
12
|
+
SAFE_READS = %w[
|
|
13
|
+
$stdin $stdout $stderr $! $? $~ $_ $DEBUG $VERBOSE $/ $$
|
|
14
|
+
].freeze
|
|
15
|
+
SAFE_WRITES = %w[$DEBUG $VERBOSE].freeze
|
|
16
|
+
LOAD_PATH_GLOBALS = %w[$LOAD_PATH $: $LOADED_FEATURES $"].freeze
|
|
17
|
+
|
|
18
|
+
explain :access,
|
|
19
|
+
severity: :error,
|
|
20
|
+
message: "%{access} global variable %{name}",
|
|
21
|
+
why: "Non-main Ractors cannot access global " \
|
|
22
|
+
"variables; this raises Ractor::IsolationError " \
|
|
23
|
+
"the moment the line executes in a Ractor " \
|
|
24
|
+
"(verified on Ruby 4.0).",
|
|
25
|
+
fix: "Pass the value into the Ractor explicitly " \
|
|
26
|
+
"(Ractor.new(value) { |v| ... }) or over a " \
|
|
27
|
+
"Ractor::Port; for per-Ractor state use " \
|
|
28
|
+
"Ractor.current[:key]; do one-time process " \
|
|
29
|
+
"setup on the main Ractor before spawning."
|
|
30
|
+
|
|
31
|
+
explain :load_path,
|
|
32
|
+
severity: :error,
|
|
33
|
+
message: "%{access} global variable %{name}",
|
|
34
|
+
why: "The load path ($LOAD_PATH/$LOADED_FEATURES) " \
|
|
35
|
+
"is main-Ractor-only global state; touching " \
|
|
36
|
+
"it from a non-main Ractor raises " \
|
|
37
|
+
"Ractor::IsolationError.",
|
|
38
|
+
fix: "Adjust the load path on the main Ractor " \
|
|
39
|
+
"during boot, before any Ractors are spawned."
|
|
40
|
+
|
|
41
|
+
on :global_variable_read_node do |node|
|
|
42
|
+
unless SAFE_READS.include?(node.name.to_s)
|
|
43
|
+
gvar(node, "read of")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
on :global_variable_write_node,
|
|
48
|
+
:global_variable_operator_write_node,
|
|
49
|
+
:global_variable_or_write_node,
|
|
50
|
+
:global_variable_and_write_node,
|
|
51
|
+
:global_variable_target_node do |node|
|
|
52
|
+
unless SAFE_WRITES.include?(node.name.to_s)
|
|
53
|
+
gvar(node, "write to")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def gvar(node, access)
|
|
60
|
+
name = node.name.to_s
|
|
61
|
+
key = LOAD_PATH_GLOBALS.include?(name) ? :load_path : :access
|
|
62
|
+
flag(node, key, access: access, name: name)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
module Checks
|
|
6
|
+
# Reading a constant whose value is not *deeply* shareable from
|
|
7
|
+
# a non-main Ractor raises Ractor::IsolationError.
|
|
8
|
+
# Classification is deliberately conservative: values we cannot
|
|
9
|
+
# prove mutable statically (method calls, constant refs,
|
|
10
|
+
# splats) are left to the dynamic probe.
|
|
11
|
+
class MutableConstants < Base
|
|
12
|
+
CONSTANT_WHY =
|
|
13
|
+
"Reading a constant that holds a non-shareable object " \
|
|
14
|
+
"from a non-main Ractor raises Ractor::IsolationError " \
|
|
15
|
+
"(\"can not access non-shareable objects in constant\")."
|
|
16
|
+
|
|
17
|
+
explain :mutable_string,
|
|
18
|
+
severity: :error,
|
|
19
|
+
message: "constant %{name} holds an unfrozen " \
|
|
20
|
+
"String literal",
|
|
21
|
+
why: CONSTANT_WHY,
|
|
22
|
+
fix: "Add `# frozen_string_literal: true` to the " \
|
|
23
|
+
"file, or append `.freeze`."
|
|
24
|
+
|
|
25
|
+
explain :mutable_container,
|
|
26
|
+
severity: :error,
|
|
27
|
+
message: "constant %{name} holds a mutable %{type} " \
|
|
28
|
+
"literal",
|
|
29
|
+
why: CONSTANT_WHY,
|
|
30
|
+
fix: "Make it deeply shareable: " \
|
|
31
|
+
"`# shareable_constant_value: literal`, or " \
|
|
32
|
+
"wrap in Ractor.make_shareable(...). A bare " \
|
|
33
|
+
"`.freeze` is not enough when elements are " \
|
|
34
|
+
"themselves mutable."
|
|
35
|
+
|
|
36
|
+
explain :shallow_freeze,
|
|
37
|
+
severity: :error,
|
|
38
|
+
message: "constant %{name} is frozen only at the " \
|
|
39
|
+
"top level",
|
|
40
|
+
why: "Ractor shareability is deep: the outer " \
|
|
41
|
+
"object is frozen but its elements are not, " \
|
|
42
|
+
"so a non-main Ractor still raises " \
|
|
43
|
+
"Ractor::IsolationError reading it (verified: " \
|
|
44
|
+
"[[1], [2]].freeze raises).",
|
|
45
|
+
fix: "Use Ractor.make_shareable(...) for a deep " \
|
|
46
|
+
"freeze, or `# shareable_constant_value: " \
|
|
47
|
+
"literal`."
|
|
48
|
+
|
|
49
|
+
explain :sync_primitive,
|
|
50
|
+
severity: :error,
|
|
51
|
+
message: "constant %{name} holds a %{klass}; sync " \
|
|
52
|
+
"primitives are deliberately unshareable",
|
|
53
|
+
why: "Mutex/Queue/ConditionVariable coordinate " \
|
|
54
|
+
"threads inside one Ractor and can never be " \
|
|
55
|
+
"shared across Ractors; any non-main Ractor " \
|
|
56
|
+
"touching this constant raises " \
|
|
57
|
+
"Ractor::IsolationError.",
|
|
58
|
+
fix: "Use Ractor::Port for cross-Ractor " \
|
|
59
|
+
"coordination, keep a per-Ractor primitive " \
|
|
60
|
+
"via Ractor.store_if_absent, or use " \
|
|
61
|
+
"Ractor-safe structures (ractor_safe, ratomic " \
|
|
62
|
+
"gems)."
|
|
63
|
+
|
|
64
|
+
explain :proc_constant,
|
|
65
|
+
severity: :error,
|
|
66
|
+
message: "constant %{name} holds a Proc",
|
|
67
|
+
why: "Procs capture their creation environment and " \
|
|
68
|
+
"are not shareable; a non-main Ractor reading " \
|
|
69
|
+
"this constant raises Ractor::IsolationError.",
|
|
70
|
+
fix: "Wrap in Ractor.make_shareable(->(...) { ... }); " \
|
|
71
|
+
"it isolates self-contained lambdas; or " \
|
|
72
|
+
"promote the logic to a method."
|
|
73
|
+
|
|
74
|
+
on :constant_write_node, :constant_or_write_node do |node|
|
|
75
|
+
examine(node.name.to_s, node, node.value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
on :constant_path_write_node,
|
|
79
|
+
:constant_path_or_write_node do |node|
|
|
80
|
+
examine(node.target.location.slice, node, node.value)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def examine(name, node, value)
|
|
86
|
+
return if file.shareable_constants?
|
|
87
|
+
|
|
88
|
+
case classifier.classify(value)
|
|
89
|
+
when :mutable_string
|
|
90
|
+
flag(node, :mutable_string, name: name,
|
|
91
|
+
autofix: append_freeze(value))
|
|
92
|
+
when :mutable_container
|
|
93
|
+
type = value.is_a?(Prism::HashNode) ? "Hash" : "Array"
|
|
94
|
+
flag(node, :mutable_container, name: name, type: type,
|
|
95
|
+
autofix: wrap_make_shareable(value))
|
|
96
|
+
when :shallow_freeze
|
|
97
|
+
flag(node, :shallow_freeze, name: name,
|
|
98
|
+
autofix: replace_with_make_shareable(value))
|
|
99
|
+
when :sync_primitive
|
|
100
|
+
flag(node, :sync_primitive, name: name,
|
|
101
|
+
klass: classifier.const_name(value.receiver))
|
|
102
|
+
when :proc
|
|
103
|
+
flag(node, :proc_constant, name: name,
|
|
104
|
+
autofix: wrap_make_shareable(value))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def classifier
|
|
109
|
+
@classifier ||= LiteralClassifier.new(
|
|
110
|
+
frozen_string_literal: file.frozen_string_literal?
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def append_freeze(value)
|
|
115
|
+
offset = value.location.end_offset
|
|
116
|
+
Autofix.new(
|
|
117
|
+
start_offset: offset,
|
|
118
|
+
end_offset: offset,
|
|
119
|
+
replacement: ".freeze"
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def wrap_make_shareable(value)
|
|
124
|
+
source = value.location.slice
|
|
125
|
+
Autofix.new(
|
|
126
|
+
start_offset: value.location.start_offset,
|
|
127
|
+
end_offset: value.location.end_offset,
|
|
128
|
+
replacement: "Ractor.make_shareable(#{source})"
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Replaces the whole `<literal>.freeze` call with a deep
|
|
133
|
+
# wrap of the literal.
|
|
134
|
+
def replace_with_make_shareable(call)
|
|
135
|
+
source = call.receiver.location.slice
|
|
136
|
+
Autofix.new(
|
|
137
|
+
start_offset: call.location.start_offset,
|
|
138
|
+
end_offset: call.location.end_offset,
|
|
139
|
+
replacement: "Ractor.make_shareable(#{source})"
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
module Checks
|
|
6
|
+
# Ractor.new verifies block isolation at creation time: a
|
|
7
|
+
# block that touches locals from the enclosing scope raises
|
|
8
|
+
# ArgumentError before the Ractor ever runs. Prism records the
|
|
9
|
+
# resolution depth of every local reference, so captures are
|
|
10
|
+
# detectable exactly: a reference whose depth reaches past the
|
|
11
|
+
# Ractor block's own scope is an outer capture.
|
|
12
|
+
class RactorIsolation < Base
|
|
13
|
+
explain :outer_capture,
|
|
14
|
+
severity: :error,
|
|
15
|
+
message: "Ractor.new block captures outer local " \
|
|
16
|
+
"variable(s) %{names}",
|
|
17
|
+
why: "Block isolation is checked when Ractor.new " \
|
|
18
|
+
"runs: touching locals of the enclosing scope " \
|
|
19
|
+
"raises ArgumentError (\"can not isolate a " \
|
|
20
|
+
"Proc because it accesses outer variables\").",
|
|
21
|
+
fix: "Pass the values in as arguments, " \
|
|
22
|
+
"Ractor.new(x) { |x| ... }, or send them " \
|
|
23
|
+
"through a Ractor::Port."
|
|
24
|
+
|
|
25
|
+
def visit_call_node(node)
|
|
26
|
+
examine(node)
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def examine(node)
|
|
33
|
+
return unless node.name == :new
|
|
34
|
+
return unless ractor_receiver?(node.receiver)
|
|
35
|
+
|
|
36
|
+
block = node.block
|
|
37
|
+
return unless block.is_a?(Prism::BlockNode)
|
|
38
|
+
return unless block.body
|
|
39
|
+
|
|
40
|
+
names = CaptureScanner.scan(block.body)
|
|
41
|
+
return if names.empty?
|
|
42
|
+
|
|
43
|
+
flag(node, :outer_capture, names: names.join(", "))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ractor_receiver?(receiver)
|
|
47
|
+
receiver.is_a?(Prism::ConstantReadNode) &&
|
|
48
|
+
receiver.name == :Ractor
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Walks the Ractor block's body tracking how many block
|
|
52
|
+
# scopes deep we are; a local reference with depth greater
|
|
53
|
+
# than that resolves outside the Ractor block.
|
|
54
|
+
class CaptureScanner < Prism::Visitor
|
|
55
|
+
def self.scan(body)
|
|
56
|
+
scanner = new
|
|
57
|
+
scanner.visit(body)
|
|
58
|
+
scanner.names.uniq
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_reader :names
|
|
62
|
+
|
|
63
|
+
def initialize
|
|
64
|
+
@names = []
|
|
65
|
+
@level = 0
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def visit_block_node(node)
|
|
70
|
+
@level += 1
|
|
71
|
+
super
|
|
72
|
+
ensure
|
|
73
|
+
@level -= 1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def visit_lambda_node(node)
|
|
77
|
+
@level += 1
|
|
78
|
+
super
|
|
79
|
+
ensure
|
|
80
|
+
@level -= 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Method definitions open fresh scopes; nothing inside
|
|
84
|
+
# them can capture the surrounding locals.
|
|
85
|
+
def visit_def_node(node)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
%i[
|
|
89
|
+
visit_local_variable_read_node
|
|
90
|
+
visit_local_variable_write_node
|
|
91
|
+
visit_local_variable_operator_write_node
|
|
92
|
+
visit_local_variable_or_write_node
|
|
93
|
+
visit_local_variable_and_write_node
|
|
94
|
+
visit_local_variable_target_node
|
|
95
|
+
].each do |method|
|
|
96
|
+
define_method(method) do |node|
|
|
97
|
+
@names << node.name.to_s if node.depth > @level
|
|
98
|
+
super(node)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
module Checks
|
|
6
|
+
# `require` inside a non-main Ractor works on Ruby 4.0 by
|
|
7
|
+
# proxying the call to the main Ractor; but that serializes
|
|
8
|
+
# every Ractor through a single lock, and it means load-time
|
|
9
|
+
# side effects run mid-request. `autoload` is the same hazard
|
|
10
|
+
# in disguise: the require fires whenever the constant happens
|
|
11
|
+
# to be resolved first, possibly inside a Ractor.
|
|
12
|
+
class RuntimeRequire < Base
|
|
13
|
+
REQUIRE_METHODS = %i[require require_relative load].freeze
|
|
14
|
+
|
|
15
|
+
explain :runtime_require,
|
|
16
|
+
severity: :warning,
|
|
17
|
+
message: "%{method} at runtime (inside a method " \
|
|
18
|
+
"body)",
|
|
19
|
+
why: "On Ruby 4.0, require inside a non-main " \
|
|
20
|
+
"Ractor is proxied to the main Ractor: it " \
|
|
21
|
+
"works, but all Ractors serialize on it and " \
|
|
22
|
+
"load-time side effects run at an arbitrary " \
|
|
23
|
+
"point.",
|
|
24
|
+
fix: "Require eagerly at boot, before Ractors are " \
|
|
25
|
+
"spawned."
|
|
26
|
+
|
|
27
|
+
explain :autoload,
|
|
28
|
+
severity: :warning,
|
|
29
|
+
message: "autoload registers a deferred require",
|
|
30
|
+
why: "The require fires when the constant is first " \
|
|
31
|
+
"resolved, which may happen inside a non-main " \
|
|
32
|
+
"Ractor, serializing Ractors through the " \
|
|
33
|
+
"main-Ractor require proxy.",
|
|
34
|
+
fix: "Require eagerly at boot, or resolve the " \
|
|
35
|
+
"constant once on the main Ractor before " \
|
|
36
|
+
"spawning."
|
|
37
|
+
|
|
38
|
+
on :call_node do |node|
|
|
39
|
+
if node.receiver.nil?
|
|
40
|
+
if REQUIRE_METHODS.include?(node.name) && inside_def?
|
|
41
|
+
unless hoisted_already?(node)
|
|
42
|
+
flag(node, :runtime_require, method: node.name,
|
|
43
|
+
autofix: hoist_autofix(node))
|
|
44
|
+
end
|
|
45
|
+
elsif node.name == :autoload
|
|
46
|
+
flag(node, :autoload,
|
|
47
|
+
autofix: require_conversion_autofix(node))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def visit_def_node(node)
|
|
53
|
+
@def_depth = def_depth + 1
|
|
54
|
+
super
|
|
55
|
+
ensure
|
|
56
|
+
@def_depth -= 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def def_depth = @def_depth ||= 0
|
|
62
|
+
|
|
63
|
+
def inside_def? = def_depth.positive?
|
|
64
|
+
|
|
65
|
+
def literal_feature(node)
|
|
66
|
+
return nil unless node.name == :require ||
|
|
67
|
+
node.name == :require_relative
|
|
68
|
+
|
|
69
|
+
arg = node.arguments&.arguments&.first
|
|
70
|
+
arg if arg.is_a?(Prism::StringNode) &&
|
|
71
|
+
node.arguments.arguments.size == 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A method-body require whose feature is already required at
|
|
75
|
+
# the top of the same file is a fast no-op; not worth a
|
|
76
|
+
# finding.
|
|
77
|
+
def hoisted_already?(node)
|
|
78
|
+
feature = literal_feature(node)
|
|
79
|
+
!feature.nil? &&
|
|
80
|
+
file.top_level_requires.include?(feature.unescaped)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Requiring is idempotent, so inserting a boot-time
|
|
84
|
+
# duplicate preserves semantics exactly while removing the
|
|
85
|
+
# runtime serialization. `load` is excluded: it re-executes
|
|
86
|
+
# on every call.
|
|
87
|
+
def hoist_autofix(node)
|
|
88
|
+
feature = literal_feature(node)
|
|
89
|
+
return nil unless feature
|
|
90
|
+
|
|
91
|
+
insertion = file.boot_insertion
|
|
92
|
+
offset = insertion[:offset]
|
|
93
|
+
statement = "#{node.name} #{feature.location.slice}"
|
|
94
|
+
if insertion[:after_require]
|
|
95
|
+
Autofix.new(start_offset: offset, end_offset: offset,
|
|
96
|
+
replacement: "#{statement}\n")
|
|
97
|
+
else
|
|
98
|
+
offset += 1 if newline_at?(offset)
|
|
99
|
+
Autofix.new(start_offset: offset, end_offset: offset,
|
|
100
|
+
replacement: "#{statement}\n\n")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def newline_at?(offset)
|
|
105
|
+
file.source.byteslice(offset, 1) == "\n"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Eager require is exactly the trade a Ractor deployment
|
|
109
|
+
# wants, but it changes load timing: unsafe tier.
|
|
110
|
+
def require_conversion_autofix(node)
|
|
111
|
+
args = node.arguments&.arguments
|
|
112
|
+
return nil unless args&.size == 2 &&
|
|
113
|
+
args[0].is_a?(Prism::SymbolNode) &&
|
|
114
|
+
args[1].is_a?(Prism::StringNode)
|
|
115
|
+
|
|
116
|
+
Autofix.new(
|
|
117
|
+
start_offset: node.location.start_offset,
|
|
118
|
+
end_offset: node.location.end_offset,
|
|
119
|
+
replacement: "require #{args[1].location.slice}",
|
|
120
|
+
safety: :unsafe
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
module Checks
|
|
6
|
+
# Knowledge base of APIs that are hostile (or noteworthy)
|
|
7
|
+
# under Ractors, expressed as catalog entries plus call-site
|
|
8
|
+
# matching rules. Severities reflect behavior verified on
|
|
9
|
+
# Ruby 4.0; several 3.x-era errors (ENV, trap,
|
|
10
|
+
# ObjectSpace.each_object) now work and are only
|
|
11
|
+
# informational.
|
|
12
|
+
class UnsafeCalls < Base
|
|
13
|
+
explain :ractor_yield_removed,
|
|
14
|
+
severity: :error,
|
|
15
|
+
message: "Ractor.%{method} was removed in Ruby 4.0",
|
|
16
|
+
why: "The yield/take rendezvous API was replaced " \
|
|
17
|
+
"by Ractor::Port in Ruby 4.0; calling it " \
|
|
18
|
+
"raises NoMethodError.",
|
|
19
|
+
fix: "Create a Ractor::Port, pass it into the " \
|
|
20
|
+
"Ractor, and use port.send/port.receive; " \
|
|
21
|
+
"collect results with Ractor#value."
|
|
22
|
+
|
|
23
|
+
explain :rails_class_state_macro,
|
|
24
|
+
severity: :error,
|
|
25
|
+
message: "%{method} stores state on the class " \
|
|
26
|
+
"object",
|
|
27
|
+
why: "These ActiveSupport macros are backed by " \
|
|
28
|
+
"class-level instance variables or class " \
|
|
29
|
+
"variables; both raise Ractor::IsolationError " \
|
|
30
|
+
"when written (and class variables even when " \
|
|
31
|
+
"read) from a non-main Ractor.",
|
|
32
|
+
fix: "Compute the value at boot and store it in a " \
|
|
33
|
+
"deeply frozen constant, or keep per-Ractor " \
|
|
34
|
+
"state via Ractor.current[:key] / " \
|
|
35
|
+
"Ractor.store_if_absent."
|
|
36
|
+
|
|
37
|
+
explain :objectspace_id2ref,
|
|
38
|
+
severity: :warning,
|
|
39
|
+
message: "ObjectSpace._id2ref cannot resolve " \
|
|
40
|
+
"objects across Ractors",
|
|
41
|
+
why: "Object IDs are only meaningful within the " \
|
|
42
|
+
"Ractor that created the object; _id2ref is " \
|
|
43
|
+
"also deprecated since Ruby 3.4.",
|
|
44
|
+
fix: "Pass objects (or their data) through " \
|
|
45
|
+
"Ractor::Port messages instead of smuggling " \
|
|
46
|
+
"object IDs."
|
|
47
|
+
|
|
48
|
+
explain :at_exit,
|
|
49
|
+
severity: :info,
|
|
50
|
+
message: "at_exit registers a process-global hook",
|
|
51
|
+
why: "Hooks run on the main Ractor at process " \
|
|
52
|
+
"exit; registering them from short-lived " \
|
|
53
|
+
"Ractors accumulates process-global state.",
|
|
54
|
+
fix: "Register exit hooks once, from the main " \
|
|
55
|
+
"Ractor, at boot."
|
|
56
|
+
|
|
57
|
+
explain :signal_trap,
|
|
58
|
+
severity: :info,
|
|
59
|
+
message: "%{receiver}trap installs a process-global " \
|
|
60
|
+
"signal handler",
|
|
61
|
+
why: "Works on Ruby 4.0 even from a Ractor, but " \
|
|
62
|
+
"the handler is process-wide; Ractors " \
|
|
63
|
+
"installing competing handlers race.",
|
|
64
|
+
fix: "Install signal handlers once, on the main " \
|
|
65
|
+
"Ractor."
|
|
66
|
+
|
|
67
|
+
explain :env_write,
|
|
68
|
+
severity: :info,
|
|
69
|
+
message: "ENV mutation is process-global",
|
|
70
|
+
why: "Works on Ruby 4.0 (ENV access from Ractors " \
|
|
71
|
+
"no longer raises), but the environment is " \
|
|
72
|
+
"shared mutable state; concurrent Ractors " \
|
|
73
|
+
"race on it.",
|
|
74
|
+
fix: "Read configuration into frozen constants at " \
|
|
75
|
+
"boot instead of mutating ENV at runtime."
|
|
76
|
+
|
|
77
|
+
explain :fork,
|
|
78
|
+
severity: :warning,
|
|
79
|
+
message: "%{method} from a multi-Ractor process",
|
|
80
|
+
why: "fork only reproduces the calling thread; " \
|
|
81
|
+
"other Ractors' threads vanish in the child, " \
|
|
82
|
+
"leaving their state inconsistent.",
|
|
83
|
+
fix: "Fork before spawning Ractors, or use " \
|
|
84
|
+
"spawn/exec."
|
|
85
|
+
|
|
86
|
+
explain :singleton_include,
|
|
87
|
+
severity: :warning,
|
|
88
|
+
message: "include Singleton memoizes the instance " \
|
|
89
|
+
"on the class",
|
|
90
|
+
why: "Singleton stores its instance in a " \
|
|
91
|
+
"class-level instance variable; the first " \
|
|
92
|
+
"access from a non-main Ractor raises " \
|
|
93
|
+
"Ractor::IsolationError when it tries to " \
|
|
94
|
+
"write it.",
|
|
95
|
+
fix: "Eagerly call .instance on the main Ractor " \
|
|
96
|
+
"at boot (freezing the instance if possible), " \
|
|
97
|
+
"or use Ractor.store_if_absent."
|
|
98
|
+
|
|
99
|
+
RULES = Ractor.make_shareable([
|
|
100
|
+
{key: :ractor_yield_removed, receiver: "Ractor",
|
|
101
|
+
methods: %i[yield take]},
|
|
102
|
+
{key: :rails_class_state_macro, receiver: nil,
|
|
103
|
+
methods: %i[class_attribute cattr_accessor cattr_reader
|
|
104
|
+
cattr_writer mattr_accessor mattr_reader
|
|
105
|
+
mattr_writer thread_mattr_accessor]},
|
|
106
|
+
{key: :objectspace_id2ref, receiver: "ObjectSpace",
|
|
107
|
+
methods: %i[_id2ref]},
|
|
108
|
+
{key: :at_exit, receiver: nil, methods: %i[at_exit]},
|
|
109
|
+
{key: :signal_trap, receiver: "Signal",
|
|
110
|
+
methods: %i[trap]},
|
|
111
|
+
{key: :signal_trap, receiver: nil, methods: %i[trap]},
|
|
112
|
+
{key: :env_write, receiver: "ENV",
|
|
113
|
+
methods: %i[[]= store delete update clear replace]},
|
|
114
|
+
{key: :fork, receiver: nil, methods: %i[fork]},
|
|
115
|
+
{key: :fork, receiver: "Process",
|
|
116
|
+
methods: %i[fork daemon]}
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
on :call_node do |node|
|
|
120
|
+
apply_rules(node)
|
|
121
|
+
flag_singleton_include(node)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def apply_rules(node)
|
|
127
|
+
rule = RULES.find do |r|
|
|
128
|
+
r[:methods].include?(node.name) &&
|
|
129
|
+
receiver_matches?(r[:receiver], node.receiver)
|
|
130
|
+
end
|
|
131
|
+
return unless rule
|
|
132
|
+
|
|
133
|
+
receiver = rule[:receiver] ? "#{rule[:receiver]}." : ""
|
|
134
|
+
flag(node, rule[:key],
|
|
135
|
+
method: "#{receiver}#{node.name}",
|
|
136
|
+
receiver: receiver)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def receiver_matches?(expected, receiver)
|
|
140
|
+
return receiver.nil? if expected.nil?
|
|
141
|
+
|
|
142
|
+
case receiver
|
|
143
|
+
when Prism::ConstantReadNode
|
|
144
|
+
receiver.name.to_s == expected
|
|
145
|
+
when Prism::ConstantPathNode
|
|
146
|
+
receiver.location.slice == expected
|
|
147
|
+
else
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def flag_singleton_include(node)
|
|
153
|
+
return unless node.name == :include && node.receiver.nil?
|
|
154
|
+
|
|
155
|
+
args = node.arguments&.arguments or return
|
|
156
|
+
singleton = args.any? do |a|
|
|
157
|
+
a.is_a?(Prism::ConstantReadNode) && a.name == :Singleton
|
|
158
|
+
end
|
|
159
|
+
return unless singleton
|
|
160
|
+
|
|
161
|
+
flag(node, :singleton_include)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "checks/base"
|
|
4
|
+
require_relative "checks/global_variables"
|
|
5
|
+
require_relative "checks/mutable_constants"
|
|
6
|
+
require_relative "checks/ractor_isolation"
|
|
7
|
+
require_relative "checks/runtime_require"
|
|
8
|
+
require_relative "checks/unsafe_calls"
|
|
9
|
+
|
|
10
|
+
module Audition
|
|
11
|
+
module Static
|
|
12
|
+
module Checks
|
|
13
|
+
BUILT_IN = [
|
|
14
|
+
GlobalVariables, MutableConstants, RactorIsolation,
|
|
15
|
+
RuntimeRequire, UnsafeCalls
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# Expression-level checks, run per file. Class variables and
|
|
19
|
+
# class-level instance variable state are covered semantically
|
|
20
|
+
# by the rubydex graph audit, not by per-file visitors.
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<Class>] built-in plus registered checks
|
|
23
|
+
def self.all
|
|
24
|
+
BUILT_IN + registered
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Extension point: gems can subclass {Base} and register here.
|
|
28
|
+
#
|
|
29
|
+
# @param check [Class] a {Base} subclass
|
|
30
|
+
# @return [void]
|
|
31
|
+
def self.register(check)
|
|
32
|
+
registered << check
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param check [Class] a previously registered check
|
|
36
|
+
# @return [void]
|
|
37
|
+
def self.deregister(check)
|
|
38
|
+
registered.delete(check)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.registered
|
|
42
|
+
@registered ||= [] # audition:disable class-level-state
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|