audition 0.2.4 → 0.4.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.
@@ -7,14 +7,19 @@ module Audition
7
7
  # Classifies a Prism expression node by Ractor shareability:
8
8
  # :shareable proven deeply shareable
9
9
  # :mutable_string unfrozen String literal
10
- # :mutable_container Array/Hash literal or constructor
11
- # :mutable_call unfrozen String or Regexp returned by
12
- # a call (`.tr`, `format`, `Regexp.new`)
10
+ # :mutable_container Array/Hash/Set literal or constructor
11
+ # :mutable_call unfrozen String, Regexp, or sentinel
12
+ # Object returned by a call (`.tr`,
13
+ # `format`, `Regexp.new`, `Object.new`)
13
14
  # :shallow_freeze frozen container with mutable elements
14
15
  # :sync_primitive Mutex/Queue/... constructor
15
16
  # :proc lambda or proc
16
17
  # :default_proc Hash.new with a block; the block
17
18
  # survives .freeze and stays unshareable
19
+ # :instance_new unfrozen instance of an arbitrary class
20
+ # :opaque_call method call with an unprovable return
21
+ # :shallow_opaque frozen at the top, but what it holds
22
+ # is an unprovable call result
18
23
  # :unknown cannot tell statically
19
24
  class LiteralClassifier
20
25
  SYNC_PRIMITIVES = %w[
@@ -23,11 +28,20 @@ module Audition
23
28
  Thread::ConditionVariable
24
29
  ].freeze
25
30
  SHAREABLE_FACTORIES = %w[Struct Class Module].freeze
31
+ # Sentinels: `NOT_GIVEN = Object.new` raises when read from
32
+ # a worker until frozen, and a frozen bare Object is
33
+ # shareable (verified on Ruby 4.0.6). BasicObject has no
34
+ # #freeze.
35
+ SENTINEL_FACTORIES = %w[Object BasicObject].freeze
36
+ # Concurrent::Map defines no #freeze, so make_shareable
37
+ # raises NoMethodError on it; a constant holding one can
38
+ # never be shared (verified on 4.0.6 with concurrent-ruby).
39
+ UNFREEZABLE_COLLECTIONS = %w[Concurrent::Map].freeze
26
40
 
27
41
  # Calls returning a fresh, unfrozen String or Regexp;
28
42
  # `# frozen_string_literal: true` covers literals only.
29
- # Rails hit both shapes (`.tr` and `Regexp.new`) in
30
- # constants during its ractorization. These names belong
43
+ # Both shapes (`.tr` and `Regexp.new`) turn up in
44
+ # constants in the wild. These names belong
31
45
  # to String alone in core, so any receiver qualifies.
32
46
  STRING_ONLY_METHODS = %i[
33
47
  tr tr_s gsub sub squeeze strip lstrip rstrip chomp chop
@@ -41,6 +55,18 @@ module Audition
41
55
  ].freeze
42
56
  FORMATTERS = %i[format sprintf].freeze
43
57
  REGEXP_FACTORIES = %i[new union compile].freeze
58
+ # File methods returning a fresh path String.
59
+ FILE_PATH_METHODS = %i[
60
+ expand_path join dirname basename absolute_path realpath
61
+ ].freeze
62
+
63
+ # Calls returning a shareable primitive on any receiver.
64
+ SHAREABLE_RETURNS = %i[
65
+ to_i to_int to_f to_r to_c to_sym size length count
66
+ bytesize ord hash
67
+ ].freeze
68
+ # Sorbet's inline casts, which return their value argument.
69
+ SORBET_CASTS = %i[let cast must].freeze
44
70
 
45
71
  # @param frozen_string_literal [Boolean] whether the file has
46
72
  # the frozen_string_literal magic comment
@@ -51,6 +77,7 @@ module Audition
51
77
  # @param node [Prism::Node] an expression node
52
78
  # @return [Symbol] classification, see class docs
53
79
  def classify(node)
80
+ node = begin_value(node)
54
81
  case node
55
82
  when Prism::IntegerNode, Prism::FloatNode,
56
83
  Prism::RationalNode, Prism::ImaginaryNode,
@@ -84,6 +111,29 @@ module Audition
84
111
  end
85
112
  end
86
113
 
114
+ # The value an expression hands back once begin blocks
115
+ # and inline casts are peeled off.
116
+ def unwrap(node)
117
+ loop do
118
+ inner = begin_value(node)
119
+ inner = cast_value(inner) || inner
120
+ break node if inner.equal?(node)
121
+
122
+ node = inner
123
+ end
124
+ end
125
+
126
+ # A begin block's value is its last statement. A rescue,
127
+ # else, or ensure clause can supply a different one, so
128
+ # only the plain form resolves.
129
+ def begin_value(node)
130
+ return node unless node.is_a?(Prism::BeginNode) &&
131
+ node.rescue_clause.nil? && node.else_clause.nil? &&
132
+ node.ensure_clause.nil?
133
+
134
+ node.statements&.body&.last || node
135
+ end
136
+
87
137
  # `::Mutex` and `Mutex` are the same constant for matching
88
138
  # purposes; the leading colons are stripped.
89
139
  def const_name(node)
@@ -95,6 +145,34 @@ module Audition
95
145
  end
96
146
  end
97
147
 
148
+ # The value inside a Sorbet inline cast, or nil. The cast
149
+ # returns its argument, so fixes and type names belong on
150
+ # the value, not the cast.
151
+ def cast_value(node)
152
+ return nil unless node.is_a?(Prism::CallNode)
153
+ return nil unless const_name(node.receiver) == "T" &&
154
+ SORBET_CASTS.include?(node.name)
155
+
156
+ node.arguments&.arguments&.first
157
+ end
158
+
159
+ # What `value.freeze` would classify as, for the check to
160
+ # choose a plain `.freeze` over a deep wrap: :shareable
161
+ # when every element is provably shareable, :shallow_freeze
162
+ # when one is provably mutable, :unknown otherwise.
163
+ def frozen_kind(value)
164
+ case value
165
+ when Prism::ArrayNode, Prism::HashNode,
166
+ Prism::KeywordHashNode
167
+ deep_classify(value.elements)
168
+ when Prism::CallNode
169
+ elements = set_elements(value)
170
+ elements ? deep_classify(elements) : :unknown
171
+ else
172
+ :unknown
173
+ end
174
+ end
175
+
98
176
  private
99
177
 
100
178
  # Adjacent literals ("a" "b") parse as interpolation but
@@ -121,18 +199,28 @@ module Audition
121
199
  classify_freeze(node, receiver)
122
200
  when :new
123
201
  name = const_name(receiver)
124
- return :sync_primitive if SYNC_PRIMITIVES.include?(name)
202
+ return :sync_primitive if SYNC_PRIMITIVES.include?(name) ||
203
+ UNFREEZABLE_COLLECTIONS.include?(name)
125
204
  return :shareable if SHAREABLE_FACTORIES.include?(name)
126
205
  return :proc if name == "Proc" && node.block
127
206
  # Hash.new retains its block as the default proc;
128
207
  # Array.new only uses its block to build elements.
129
208
  return :default_proc if name == "Hash" && node.block
209
+ return set_kind(node) if name == "Set"
210
+ if SENTINEL_FACTORIES.include?(name)
211
+ bare = node.arguments.nil? && node.block.nil?
212
+ return bare ? :mutable_call : :unknown
213
+ end
130
214
 
131
215
  if %w[Hash Array].include?(name)
132
216
  return :mutable_container
133
217
  end
134
218
 
135
- :unknown
219
+ name ? :instance_new : :opaque_call
220
+ when :to_set
221
+ set_kind(node)
222
+ when :[]
223
+ index_kind(node)
136
224
  when :define
137
225
  (const_name(receiver) == "Data") ? :shareable : :unknown
138
226
  when :make_shareable
@@ -140,14 +228,46 @@ module Audition
140
228
  when :lambda, :proc
141
229
  (receiver.nil? && node.block) ? :proc : :unknown
142
230
  else
143
- :unknown
231
+ opaque_kind(node)
144
232
  end
145
233
  end
146
234
 
235
+ # Catch-all for unrecognized calls: shareable returns pass,
236
+ # predicates stay silent, everything else is opaque.
237
+ def opaque_kind(node)
238
+ return :shareable if SHAREABLE_RETURNS.include?(node.name)
239
+ return :unknown if node.name.end_with?("?")
240
+
241
+ if (value = cast_value(node))
242
+ return classify(value)
243
+ end
244
+
245
+ :opaque_call
246
+ end
247
+
248
+ # Indexing into a constant, another call's result, or a
249
+ # fresh instance returns a value of unprovable shareability.
250
+ # Sorbet type constructors and ENV, whose values are frozen
251
+ # strings, stay silent.
252
+ def index_kind(node)
253
+ owner = const_name(node.receiver)
254
+ return set_kind(node) if owner == "Set"
255
+ if owner.nil?
256
+ receiver_kind = classify(node.receiver)
257
+ return :opaque_call if %i[opaque_call instance_new]
258
+ .include?(receiver_kind)
259
+ return :unknown
260
+ end
261
+ return :unknown if owner == "ENV" ||
262
+ owner == "T" || owner.start_with?("T::")
263
+
264
+ :opaque_call
265
+ end
266
+
147
267
  def classify_freeze(node, receiver)
148
268
  return :unknown unless node.arguments.nil? && receiver
149
269
 
150
- case receiver
270
+ case (receiver = begin_value(receiver))
151
271
  when Prism::StringNode
152
272
  :shareable
153
273
  when Prism::ArrayNode, Prism::HashNode
@@ -158,6 +278,8 @@ module Audition
158
278
  case classify(receiver)
159
279
  when :default_proc then :default_proc
160
280
  when :mutable_call then :shareable
281
+ when :mutable_container then frozen_kind(receiver)
282
+ when :instance_new, :opaque_call then :shallow_opaque
161
283
  else :unknown
162
284
  end
163
285
  else
@@ -178,6 +300,7 @@ module Audition
178
300
  owner = const_name(receiver)
179
301
  (name == :new && owner == "String") ||
180
302
  (owner == "Kernel" && FORMATTERS.include?(name)) ||
303
+ (owner == "File" && FILE_PATH_METHODS.include?(name)) ||
181
304
  STRING_ONLY_METHODS.include?(name)
182
305
  else
183
306
  false
@@ -194,7 +317,11 @@ module Audition
194
317
  # keeps a frozen Hash of Mutexes). The classification
195
318
  # propagates so no freeze or wrap is ever suggested.
196
319
  def container_kind(node)
197
- sync = node.elements.any? do |element|
320
+ elements_kind(node.elements)
321
+ end
322
+
323
+ def elements_kind(elements)
324
+ sync = elements.any? do |element|
198
325
  element_children(element).any? do |child|
199
326
  classify(child) == :sync_primitive
200
327
  end
@@ -202,6 +329,37 @@ module Audition
202
329
  sync ? :sync_primitive : :mutable_container
203
330
  end
204
331
 
332
+ # A Set built from literals is a container of them:
333
+ # `Set.new([...])`, `Set[...]`, `%w[...].to_set`. A Set
334
+ # built from an opaque source or a mapping block is still
335
+ # a fresh unfrozen Set, mutable no matter its contents.
336
+ def set_kind(node)
337
+ elements = set_elements(node)
338
+ elements ? elements_kind(elements) : :mutable_container
339
+ end
340
+
341
+ def set_elements(node)
342
+ return nil if node.block
343
+
344
+ case node.name
345
+ when :new, :[]
346
+ return nil unless const_name(node.receiver) == "Set"
347
+
348
+ args = node.arguments&.arguments || []
349
+ return args if node.name == :[]
350
+ return [] if args.empty?
351
+ return nil unless args.size == 1
352
+
353
+ args[0].is_a?(Prism::ArrayNode) ? args[0].elements : nil
354
+ when :to_set
355
+ receiver = node.receiver
356
+ return nil unless node.arguments.nil? &&
357
+ receiver.is_a?(Prism::ArrayNode)
358
+
359
+ receiver.elements
360
+ end
361
+ end
362
+
205
363
  def element_children(element)
206
364
  case element
207
365
  when Prism::AssocNode then [element.key, element.value]
@@ -237,20 +395,33 @@ module Audition
237
395
  body && body.size == 1 && body[0]
238
396
  end
239
397
 
240
- # Fold element classifications: everything provably shareable
241
- # gives :shareable; anything provably mutable gives
242
- # :shallow_freeze; a sync primitive poisons the whole
243
- # container; anything unknowable gives :unknown (stay silent
244
- # rather than guess).
398
+ # Fold element classifications by the strongest evidence:
399
+ # a sync primitive poisons the whole container; a provably
400
+ # mutable element makes it :shallow_freeze; an opaque call
401
+ # result makes it :shallow_opaque. A bare constant read is
402
+ # commonly a class or another frozen constant, so on its
403
+ # own it keeps the container silent (:unknown), but it
404
+ # cannot excuse a bad element elsewhere.
405
+ VERDICT_RANK = {
406
+ shareable: 0, unknown: 1, shallow_opaque: 2,
407
+ shallow_freeze: 3
408
+ }.freeze
409
+
245
410
  def deep_classify(elements)
246
411
  verdict = :shareable
247
412
  elements.each do |element|
248
413
  element_children(element).each do |child|
249
- case classify(child)
250
- when :shareable then nil
251
- when :sync_primitive then return :sync_primitive
252
- when :unknown then return :unknown
253
- else verdict = :shallow_freeze
414
+ kind =
415
+ case classify(child)
416
+ when :shareable then :shareable
417
+ when :sync_primitive then return :sync_primitive
418
+ when :unknown then :unknown
419
+ when :instance_new, :opaque_call, :shallow_opaque
420
+ :shallow_opaque
421
+ else :shallow_freeze
422
+ end
423
+ if VERDICT_RANK[kind] > VERDICT_RANK[verdict]
424
+ verdict = kind
254
425
  end
255
426
  end
256
427
  end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ # Compiled extensions are invisible to the Ruby scanner, but the
6
+ # one fact that decides their Ractor behavior is visible from
7
+ # outside. Ruby marks every method an extension defines while
8
+ # Init_* runs without rb_ext_ractor_safe(true), and calling any
9
+ # of them from a non-main Ractor raises Ractor::UnsafeError. The
10
+ # declaration is a libruby import, so a compiled file names the
11
+ # symbol or does not, whatever language produced it; sources
12
+ # spell it as rb_ext_ractor_safe(true) or the
13
+ # RB_EXT_RACTOR_SAFE(true) macro (C, Rust via rb-sys, Zig).
14
+ #
15
+ # Compiled files win when present: they are what `require`
16
+ # loads. Sources are consulted only for a checkout that has not
17
+ # been built yet.
18
+ class NativeExtensions
19
+ CHECK = "native-extension"
20
+ SYMBOL = "rb_ext_ractor_safe"
21
+ DECLARATION = /rb_ext_ractor_safe\s*\(\s*true\s*\)/i
22
+ INIT = /\bInit_\w+\s*\(/
23
+ SOURCES = "*.{c,cc,cpp,cxx,m,mm,h,hpp,rs,zig}"
24
+ BUILD_FILES = %w[Cargo.toml build.zig extconf.rb].freeze
25
+ # Harness crates and test trees are not compiled into the
26
+ # extension; a fuzz target that links the VM itself may call
27
+ # rb_ext_ractor_safe without the extension doing so.
28
+ HARNESS_DIRS = %w[fuzz benches tests examples test spec].freeze
29
+
30
+ SILENT_WHY =
31
+ "Ruby marks every method an extension defines while " \
32
+ "Init_* runs without rb_ext_ractor_safe(true), and calling " \
33
+ "any of them from a non-main Ractor raises " \
34
+ "Ractor::UnsafeError (\"ractor unsafe method called from " \
35
+ "not main ractor\"), whether the extension is C, Rust, or " \
36
+ "Zig (verified on Ruby 4.0.6). "
37
+ # Tails appended to SILENT_WHY at finding time; a constant
38
+ # built with + would hold an unfrozen String.
39
+ COMPILED_TAIL =
40
+ "The declaration is a libruby import, so a compiled file " \
41
+ "that never names the symbol cannot have made it."
42
+ SOURCE_TAIL =
43
+ "These sources never call it, so the compiled extension " \
44
+ "will raise the same way."
45
+ SILENT_FIX =
46
+ "Audit the native code for process-global mutable state " \
47
+ "(statics, caches, VALUEs held outside Ruby objects), then " \
48
+ "call RB_EXT_RACTOR_SAFE(true) first thing in Init_* " \
49
+ "(rb_sys::rb_ext_ractor_safe(true) from Rust). Until then, " \
50
+ "keep every call into this extension on the main Ractor."
51
+ DECLARED_WHY =
52
+ "rb_ext_ractor_safe(true) is the maintainer's assertion " \
53
+ "that the extension keeps no process-global mutable state; " \
54
+ "Ruby does not verify it and neither can Audition. Its " \
55
+ "methods run from any Ractor, in parallel, on the strength " \
56
+ "of that assertion alone."
57
+ DECLARED_FIX =
58
+ "Exercise real calls from a non-main Ractor (a script " \
59
+ "probe, or the gem's test suite under Ractor.new) before " \
60
+ "relying on it."
61
+
62
+ # @param target [Target]
63
+ # @param compiled_files [Array<String>] compiled extension
64
+ # files to inspect (defaults to the target's own list; the
65
+ # CLI passes the config-filtered subset)
66
+ # @return [Array<Finding>]
67
+ def analyze(target, compiled_files: target.compiled_files)
68
+ if compiled_files.any?
69
+ return compiled_files.filter_map { |p| compiled_finding(p) }
70
+ end
71
+
72
+ extension_dirs(target.root).map do |dir|
73
+ source_finding(dir, target.root)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def compiled_finding(path)
80
+ name = File.basename(path)
81
+ if File.binread(path).include?(SYMBOL)
82
+ finding(:info, path, nil,
83
+ "compiled extension #{name} declares Ractor safety " \
84
+ "(imports #{SYMBOL})", DECLARED_WHY, DECLARED_FIX)
85
+ else
86
+ finding(:warning, path, nil,
87
+ "compiled extension #{name} does not declare Ractor " \
88
+ "safety", SILENT_WHY + COMPILED_TAIL, SILENT_FIX)
89
+ end
90
+ rescue SystemCallError
91
+ nil
92
+ end
93
+
94
+ # One finding per extension directory, anchored
95
+ # at the declaration when there is one, else at Init_* or the
96
+ # build file, where the declaration belongs.
97
+ def source_finding(dir, root)
98
+ sources = sources_under(dir)
99
+ label = if dir == root
100
+ File.basename(root)
101
+ else
102
+ dir.delete_prefix("#{root}/")
103
+ end
104
+ path, line = locate(sources, DECLARATION)
105
+ if path
106
+ return finding(:info, path, line,
107
+ "extension sources under #{label} declare Ractor " \
108
+ "safety (#{SYMBOL})", DECLARED_WHY, DECLARED_FIX)
109
+ end
110
+
111
+ path, line = locate(sources, INIT)
112
+ path ||= build_file(dir)
113
+ finding(:warning, path, line,
114
+ "extension sources under #{label} do not declare Ractor " \
115
+ "safety", SILENT_WHY + SOURCE_TAIL, SILENT_FIX)
116
+ end
117
+
118
+ # Directories holding native sources, found by the build file
119
+ # that compiles them rather than by convention: a gem is free
120
+ # to put its extension anywhere. A manifest above another one
121
+ # is dropped, so a workspace does not report the sources of
122
+ # the crates under it a second time.
123
+ def extension_dirs(root)
124
+ dirs = build_dirs(root).select { |dir| sources_under(dir).any? }
125
+ dirs.reject do |dir|
126
+ dirs.any? { |other| other.start_with?("#{dir}/") }
127
+ end
128
+ end
129
+
130
+ def build_dirs(root)
131
+ pattern = "{#{BUILD_FILES.join(",")}}"
132
+ Dir[File.join(root, "**", pattern)].filter_map do |path|
133
+ relative = path.delete_prefix("#{root}/").split("/")
134
+ next if relative[0..-2].any? { |part| skipped?(part) }
135
+
136
+ File.dirname(path)
137
+ end.uniq.sort
138
+ end
139
+
140
+ def sources_under(dir)
141
+ Dir[File.join(dir, "**", SOURCES)].sort.reject do |path|
142
+ path.delete_prefix("#{dir}/").split("/")[0..-2]
143
+ .any? { |part| skipped?(part) }
144
+ end
145
+ end
146
+
147
+ def skipped?(part)
148
+ Target::EXCLUDED_DIRS.include?(part) ||
149
+ Target::BUILD_DIRS.include?(part) ||
150
+ HARNESS_DIRS.include?(part) || part.start_with?(".")
151
+ end
152
+
153
+ def locate(sources, pattern)
154
+ sources.each do |path|
155
+ File.foreach(path, mode: "rb").with_index(1) do |text, n|
156
+ return [path, n] if text.match?(pattern)
157
+ end
158
+ end
159
+ nil
160
+ end
161
+
162
+ def build_file(dir)
163
+ BUILD_FILES.map { |name| File.join(dir, name) }
164
+ .find { |path| File.file?(path) } || dir
165
+ end
166
+
167
+ def finding(severity, path, line, message, why, fix)
168
+ Finding.new(
169
+ check: CHECK, severity: severity, message: message,
170
+ why: why, fix: fix, path: path, line: line
171
+ )
172
+ end
173
+ end
174
+ end
175
+ end
@@ -152,6 +152,76 @@ module Audition
152
152
  # shaped like `# key: value`, which sweeps up documentation
153
153
  # (`# I18n.t: 'date.formats.short'`); inserting after those
154
154
  # would land a magic comment mid-file.
155
+ # Constants frozen by a bare `NAME.freeze` statement at the
156
+ # same lexical level as a file, class, or module body: the
157
+ # build-then-freeze shape. A freeze inside a method does
158
+ # not count; nothing guarantees it runs before a Ractor
159
+ # reads the constant.
160
+ # @return [Array<String>]
161
+ def frozen_constants
162
+ @frozen_constants ||= begin
163
+ names = []
164
+ bodies = [root.statements]
165
+ until bodies.empty?
166
+ statements = bodies.shift
167
+ next unless statements.is_a?(Prism::StatementsNode)
168
+
169
+ statements.body.each do |node|
170
+ case node
171
+ when Prism::ModuleNode, Prism::ClassNode,
172
+ Prism::SingletonClassNode
173
+ bodies << node.body
174
+ when Prism::CallNode
175
+ next unless node.name == :freeze &&
176
+ node.arguments.nil? && node.block.nil?
177
+
178
+ name = constant_receiver(node.receiver)
179
+ names << name if name
180
+ end
181
+ end
182
+ end
183
+ names.uniq
184
+ end
185
+ end
186
+
187
+ # Calls that give a constant's object singleton behavior;
188
+ # freezing the object first makes them raise FrozenError
189
+ # (`NULL = Object.new; def NULL.to_s = "null"`).
190
+ CONST_CUSTOMIZERS = %i[
191
+ extend define_singleton_method instance_eval instance_exec
192
+ singleton_class instance_variable_set
193
+ ].freeze
194
+
195
+ # @return [Array<String>] names of constants that receive a
196
+ # singleton method definition or a customizing call
197
+ def customized_constants
198
+ @customized_constants ||= begin
199
+ names = []
200
+ queue = [root]
201
+ until queue.empty?
202
+ node = queue.shift
203
+ queue.concat(node.child_nodes.compact)
204
+ receiver =
205
+ if node.is_a?(Prism::DefNode)
206
+ node.receiver
207
+ elsif node.is_a?(Prism::CallNode) &&
208
+ CONST_CUSTOMIZERS.include?(node.name)
209
+ node.receiver
210
+ end
211
+ name = constant_receiver(receiver)
212
+ names << name if name
213
+ end
214
+ names.uniq
215
+ end
216
+ end
217
+
218
+ def constant_receiver(node)
219
+ case node
220
+ when Prism::ConstantReadNode then node.name.to_s
221
+ when Prism::ConstantPathNode then node.location.slice
222
+ end
223
+ end
224
+
155
225
  MAGIC_KEYS = %w[
156
226
  encoding coding frozen_string_literal
157
227
  shareable_constant_value warn_indent
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Audition
6
+ module Static
7
+ # How a scan is divided between Ractor workers.
8
+ module WorkSplit
9
+ # Ractors run on a fixed pool of native threads sized by
10
+ # RUBY_MAX_CPU, which only the environment can set. Spawning
11
+ # past it buys no parallelism and costs a Ractor each.
12
+ RACTOR_CPU_DEFAULT = 8
13
+
14
+ module_function
15
+
16
+ # One worker per core the Ractor pool can actually run. The
17
+ # main Ractor only waits while workers scan, so no core is
18
+ # held back for it.
19
+ #
20
+ # @return [Integer]
21
+ def workers
22
+ Etc.nprocessors.clamp(1, ractor_cpu_limit)
23
+ end
24
+
25
+ def ractor_cpu_limit
26
+ limit = ENV["RUBY_MAX_CPU"].to_i
27
+ limit.positive? ? limit : RACTOR_CPU_DEFAULT
28
+ end
29
+
30
+ # Longest-processing-time-first: deal the heaviest item onto
31
+ # the lightest worker. Consecutive files are neighbors in the
32
+ # tree and so alike in size, so contiguous slices come out
33
+ # lopsided: one worker can draw a slice weighing several times
34
+ # the mean and still be running once the rest have finished.
35
+ # Greedy is enough here: LPT finishes within 4/3 of an
36
+ # optimal split.
37
+ #
38
+ # @param weighted [Array<Array>] `[item, weight]` pairs
39
+ # @param count [Integer] worker count
40
+ # @return [Array<Array>] one chunk of items per busy worker
41
+ def chunks(weighted, count)
42
+ chunks = Array.new(count) { [] }
43
+ loads = Array.new(count, 0)
44
+ weighted.sort_by { |item, weight| [-weight, item] }
45
+ .each do |item, weight|
46
+ lightest = loads.index(loads.min)
47
+ chunks[lightest] << item
48
+ loads[lightest] += weight
49
+ end
50
+ chunks.reject(&:empty?)
51
+ end
52
+ end
53
+ end
54
+ end