ractorize 0.0.7 → 0.0.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02d57de76c96c80c8fa5d7a49dcc54a12e9c7f849ff8951faba91806ff5dadbb
4
- data.tar.gz: e6ae43cf484790673a4010505622b7255a09b7a5d04f358a2b5a8bec0ab6b917
3
+ metadata.gz: 52f6214e369ed8e100b4af65d8cebeeadbfc5ec288b5c93d896f3feb277944fb
4
+ data.tar.gz: 463beced042a7da641a824450a00447ba359e942a4a42a1e99723250611a808d
5
5
  SHA512:
6
- metadata.gz: 5034e676239f3e6ccd3ea1aa83a0923c0b439800127fd1a3444c08ce93de706f48b05f87e68d697cddb6612d42d7ca057b1b29adef5954ca5b51575cd1320050
7
- data.tar.gz: 461059bac8aeea154ec67ed22ed530ac7f8921902bef688d7a5d21927e67adaaab582e2bc528b7a556b4c1fb733a37b9a9202bf261eb6d048125761d516a720f
6
+ metadata.gz: 1e79f4cd57dc32ef2f113620fabc60df2b0811f1e68b7975e3c3109fc210fa2ef24c8b439b3e0664b8619401ee07191ce66a7de7dafc6c606c05426c6fcee088
7
+ data.tar.gz: 2927ab40a74c984c46110b3ace84158621dddceb2d45b5125d42f4dcdc9aadd846f364a712329d48054fb645e918abd64d760a6ee21026309c7414211b600647
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [0.0.8] - 2026-06-24
2
+
3
+ - Add GarbageCollector to close abandoned RactorizedObject ractors and Thunk ractors
4
+ - Switch thunks to work off of Ractor instead of Ractor::Port due to port/ractor leaks
5
+ - Block on #hash and delegate to super in #==/#!=
6
+ - Significant test suite improvements:
7
+ - Remove awkward RACTORIZE_PROC tests and instead use the shmactor gem to get to 100% branch coverage
8
+ - Parallelize the build and run a shmactor build to get full coverage of ractor procs
9
+ - Check for leaked ractors/ports after test suite and fail the build on any memory leaks
10
+
1
11
  ## [0.0.7] - 2026-06-06
2
12
 
3
13
  - Closed Ractor::Ports sometimes give IOError instead of Ractor::ClosedError so handle both
@@ -0,0 +1,2 @@
1
+ # We do it this way to allow easily swapping out with Shmactor
2
+ BaseRactor ||= Ractor # rubocop:disable Lint/OrAssignmentToConstant
@@ -0,0 +1,35 @@
1
+ module Ractorize
2
+ module GarbageCollection
3
+ class Tracker
4
+ attr_accessor :ractorized_object_id_to_ractor,
5
+ :thunk_id_to_ractor
6
+
7
+ def initialize
8
+ self.ractorized_object_id_to_ractor = ObjectSpace::WeakMap.new
9
+ self.thunk_id_to_ractor = ObjectSpace::WeakMap.new
10
+ end
11
+
12
+ def track_ractorized_object(ractorized_object)
13
+ ractorized_object_id_to_ractor[ractorized_object.__object_id__] = ractorized_object.__ractor__
14
+ end
15
+
16
+ def cleanup_after_ractorized_object(ractorized_object_id)
17
+ ractor = ractorized_object_id_to_ractor.delete(ractorized_object_id)
18
+ ractor&.<<(:__close__)
19
+ rescue Ractor::ClosedError
20
+ # do nothing
21
+ end
22
+
23
+ def track_thunk(thunk_id, ractor)
24
+ thunk_id_to_ractor[thunk_id] = ractor
25
+ end
26
+
27
+ def cleanup_after_thunk(thunk_id)
28
+ ractor = thunk_id_to_ractor.delete(thunk_id)
29
+ ractor&.<<(:__close__)
30
+ rescue Ractor::ClosedError
31
+ # do nothing
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,81 @@
1
+ module Ractorize
2
+ module GarbageCollection
3
+ class TrackingRactor < BaseRactor; end
4
+
5
+ class << self
6
+ def track_ractorized_object(ractorized_object)
7
+ # We have to define the finalizer here, not in the tracker, because it's not frozen yet
8
+ ObjectSpace.define_finalizer(ractorized_object, &finalize_proc)
9
+ Object.instance_method(:freeze).bind_call(ractorized_object)
10
+
11
+ begin
12
+ TRACKING_RACTOR << [:track_ractorized_object, ractorized_object].freeze
13
+ rescue TrackingRactor::ClosedError
14
+ # do nothing
15
+ end
16
+ end
17
+
18
+ def track_thunk(thunk)
19
+ # We have to define the finalizer here, not in the tracker, because it's not frozen yet
20
+ ObjectSpace.define_finalizer(thunk, &finalize_thunk_proc)
21
+
22
+ begin
23
+ TRACKING_RACTOR << [:track_thunk, thunk.__object_id__, thunk.__thunk_ractor__].freeze
24
+ rescue TrackingRactor::ClosedError
25
+ # do nothing
26
+ end
27
+ end
28
+
29
+ def cleanup_after_ractorized_object(ractorized_object_id)
30
+ TRACKING_RACTOR << [:cleanup_after_ractorized_object, ractorized_object_id].freeze
31
+ rescue Ractor::ClosedError
32
+ # do nothing
33
+ end
34
+
35
+ def cleanup_after_thunk(thunk_id)
36
+ TRACKING_RACTOR << [:cleanup_after_thunk, thunk_id].freeze
37
+ rescue Ractor::ClosedError
38
+ # do nothing
39
+ end
40
+
41
+ private
42
+
43
+ def finalize_proc
44
+ proc do |ractorized_object_id|
45
+ ::Ractorize::GarbageCollection.cleanup_after_ractorized_object(ractorized_object_id)
46
+ end
47
+ end
48
+
49
+ def finalize_thunk_proc
50
+ proc do |thunk_id|
51
+ ::Ractorize::GarbageCollection.cleanup_after_thunk(thunk_id)
52
+ end
53
+ end
54
+ end
55
+
56
+ TRACKING_RACTOR = TrackingRactor.new do
57
+ tracker = Tracker.new
58
+
59
+ loop do
60
+ # SimpleCov branch coverage doesn't like that we aren't testing not matching anything
61
+ # but this does result in an error unlike case/when so no point in checking that.
62
+ # :nocov:
63
+ case receive
64
+ # :nocov:
65
+ in :track_ractorized_object, ractorized_object
66
+ tracker.track_ractorized_object(ractorized_object)
67
+ in :cleanup_after_ractorized_object, ractorized_object_id
68
+ tracker.cleanup_after_ractorized_object(ractorized_object_id)
69
+ in :track_thunk, thunk_id, thunk_ractor
70
+ tracker.track_thunk(thunk_id, thunk_ractor)
71
+ in :cleanup_after_thunk, thunk_id
72
+ tracker.cleanup_after_thunk(thunk_id)
73
+ end
74
+ rescue TrackingRactor::ClosedError
75
+ # do nothing
76
+ end
77
+ end
78
+
79
+ private_constant :TRACKING_RACTOR
80
+ end
81
+ end
@@ -3,22 +3,34 @@ require_relative "thunk"
3
3
 
4
4
  module Ractorize
5
5
  class RactorizedObject < BasicObject
6
+ class RactorizedRactor < ::BaseRactor; end
7
+
8
+ class << self
9
+ def method_should_use_thunk?(method_symbol)
10
+ method_symbol != :== && method_symbol != :! && method_symbol != :!= &&
11
+ method_symbol != :inspect && method_symbol != :to_s &&
12
+ !method_symbol.end_with?("?") && method_symbol != :hash
13
+ end
14
+ end
15
+
16
+ attr_reader :__object_id__, :__ractor__
17
+
6
18
  def initialize(mode, *args, **opts, &block)
7
- @ractor = ::Ractor.new(name: "#{args.first}<#{args.first.object_id}>", &RACTOR_PROC)
19
+ @__ractor__ = RactorizedRactor.new(name: "#{args.first}<#{args.first.object_id}>", &RACTOR_PROC)
8
20
 
9
21
  case mode
10
22
  when :object
11
- @ractor << :object
23
+ @__ractor__ << :object
12
24
 
13
25
  outside_object = args.first
14
26
 
15
27
  @__target_class__ = outside_object.class
16
28
 
17
29
  if ::Ractor.shareable?(outside_object)
18
- @ractor << outside_object
30
+ @__ractor__ << outside_object
19
31
  else
20
32
  ::Ractorize.resolve_all_thunks(outside_object)
21
- @ractor.send(outside_object, move: true)
33
+ @__ractor__.send(outside_object, move: true)
22
34
  end
23
35
  when :class
24
36
  klass, *args = args
@@ -28,29 +40,29 @@ module Ractorize
28
40
  to_move = ::Ractorize.prepare_args(@__target_class__, args, opts)
29
41
 
30
42
  if to_move&.any?
31
- @ractor << :class_arg_by_arg
32
- @ractor << klass
43
+ @__ractor__ << :class_arg_by_arg
44
+ @__ractor__ << klass
33
45
 
34
46
  args.each do |arg|
35
- @ractor << :arg
36
- @ractor.send(arg, move: to_move.include?(arg))
47
+ @__ractor__ << :arg
48
+ @__ractor__.send(arg, move: to_move.include?(arg))
37
49
  end
38
50
 
39
51
  opts.each_pair do |name, value|
40
- @ractor << :kwarg
41
- @ractor << name
42
- @ractor.send(value, move: to_move.include?(value))
52
+ @__ractor__ << :kwarg
53
+ @__ractor__ << name
54
+ @__ractor__.send(value, move: to_move.include?(value))
43
55
  end
44
56
 
45
57
  if block
46
- @ractor << :block
47
- @ractor << block
58
+ @__ractor__ << :block
59
+ @__ractor__ << block
48
60
  end
49
61
 
50
- @ractor << :done
62
+ @__ractor__ << :done
51
63
  else
52
- @ractor << :class
53
- @ractor << [klass, args.freeze, opts.dup.freeze, block].freeze
64
+ @__ractor__ << :class
65
+ @__ractor__ << [klass, args.freeze, opts.dup.freeze, block].freeze
54
66
  end
55
67
  else
56
68
  # :nocov:
@@ -58,30 +70,35 @@ module Ractorize
58
70
  # :nocov:
59
71
  end
60
72
 
61
- ::Object.instance_method(:freeze).bind(self).call
73
+ @__object_id__ = ::Object.instance_method(:object_id).bind_call(self)
74
+
75
+ ::Ractorize::GarbageCollection.track_ractorized_object(self)
62
76
  end
63
77
 
64
78
  def __close__ = method_missing(:__close__)
65
79
 
66
80
  def __join__
67
81
  __close__
68
- @ractor.join
82
+ @__ractor__.join
69
83
  self
70
84
  end
71
85
 
72
86
  def method_missing(method_name, *args, **opts, &block)
73
- if @ractor.default_port.closed?
87
+ if @__ractor__.default_port.closed?
74
88
  ::Kernel.raise ::Ractor::ClosedError,
75
89
  "You already closed this Ractorized instance of #{@__target_class__}!\n" \
76
90
  "No more methods can be sent to it but you sent #{method_name}"
77
91
  end
78
92
 
79
- return_port = ::Ractor::Port.new
93
+ return_port = ::Ractorize::RactorizedObject::RactorizedRactor::Port.new
94
+ thunk_ractor = if !block && RactorizedObject.method_should_use_thunk?(method_name)
95
+ ::Ractorize::Thunk::ThunkRactor.new
96
+ end
80
97
 
81
98
  to_move = ::Ractorize.prepare_args(@__target_class__, args, opts)
82
99
 
83
100
  if to_move&.any?
84
- @ractor << [:__invoke_arg_by_arg__, [].freeze, {}.freeze, return_port, !!block]
101
+ @__ractor__ << [:__invoke_arg_by_arg__, [].freeze, {}.freeze, return_port, thunk_ractor, !!block]
85
102
 
86
103
  args_port = return_port.receive
87
104
  args_port << method_name
@@ -99,7 +116,7 @@ module Ractorize
99
116
 
100
117
  args_port << :done
101
118
  else
102
- @ractor << [method_name, args.dup.freeze, opts.dup.freeze, return_port, !!block].freeze
119
+ @__ractor__ << [method_name, args.dup.freeze, opts.dup.freeze, return_port, thunk_ractor, !!block].freeze
103
120
  end
104
121
 
105
122
  if block
@@ -121,28 +138,38 @@ module Ractorize
121
138
  # TODO: yielded_block likely won't work when actually used
122
139
  # so we should probably instead just raise an exception
123
140
  # TODO: handle break and also raise in the block
124
- block_result = block.call(*yielded_args.freeze, **yielded_opts.freeze, &yielded_block)
125
-
126
- block_result = block_result.__value__ while ::Ractorize::Thunk === block_result
127
-
128
- block_result_port << [:normal, block_result].freeze
141
+ begin
142
+ broke = true
143
+ block_result = block.call(*yielded_args.freeze, **yielded_opts.freeze, &yielded_block)
144
+ broke = false
145
+ ensure
146
+ block_result = block_result.__value__ while ::Ractorize::Thunk === block_result
147
+
148
+ block_result_port << if broke
149
+ # TODO: handle error situation
150
+ :break
151
+ else
152
+ [:normal, block_result].freeze
153
+ end
154
+ end
129
155
  end
130
156
  end
131
157
 
132
158
  value
133
159
  # Let's assume the user would rather block on all predicate methods than
134
160
  # incorrectly get a non-truthy value (thunk is always truthy even if it evaluates as nil/false)
135
- elsif method_name == :== || method_name == :! || method_name == :!= ||
136
- method_name == :inspect || method_name == :to_s || method_name.end_with?("?")
161
+ elsif thunk_ractor
162
+ return_port.close
163
+ Thunk.new(thunk_ractor)
164
+ else
137
165
  value = return_port.receive
166
+ return_port.close
138
167
 
139
168
  # :nocov:
140
169
  ::Kernel.raise ::Ractorize::Thunk::EscapingRactorError if ::Ractorize::Thunk === value
141
170
  # :nocov:
142
171
 
143
172
  value
144
- else
145
- Thunk.new(return_port)
146
173
  end
147
174
  end
148
175
 
@@ -159,11 +186,10 @@ module Ractorize
159
186
  method_missing(:respond_to?, method_name, include_all)
160
187
  end
161
188
 
162
- def ==(other) = method_missing(:==, other)
163
- def !=(other) = method_missing(:==, other)
189
+ def ==(other) = method_missing(:==, other) || super
190
+ def !=(other) = method_missing(:==, other) || super
164
191
  def ! = method_missing(:!)
165
- def equal?(other) = method_missing(:equal?, other)
166
-
192
+ # def equal?(other) = method_missing(:equal?, other) || super
167
193
  def to_s = inspect
168
194
 
169
195
  def inspect
@@ -0,0 +1,23 @@
1
+ require_relative "../base_ractor"
2
+
3
+ module Ractorize
4
+ class Thunk < BasicObject
5
+ class ThunkRactor < ::BaseRactor
6
+ class << self
7
+ def new
8
+ super do
9
+ # SimpleCov seems to want us to handle the case where nothing matches but that would be an error
10
+ # :nocov:
11
+ case receive
12
+ # :nocov:
13
+ in :__close__
14
+ # do nothing
15
+ in :success, value
16
+ value
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -2,14 +2,18 @@ module Ractorize
2
2
  class Thunk < BasicObject
3
3
  class EscapingRactorError < ::StandardError; end
4
4
 
5
- attr_accessor :__return_value_port__, :__ractor__
5
+ attr_accessor :__thunk_ractor__, :__object_id__
6
6
 
7
- def initialize(return_value_port)
8
- self.__ractor__ = ::Ractor.current
9
- self.__return_value_port__ = return_value_port
7
+ def initialize(return_value_portlike)
8
+ self.__thunk_ractor__ = return_value_portlike
9
+ self.__object_id__ = ::Object.instance_method(:object_id).bind_call(self)
10
+ ::Ractorize::GarbageCollection.track_thunk(self)
10
11
  end
11
12
 
12
13
  def initialize_clone(...)
14
+ # :nocov:
15
+ raise "CAREFUL! THUNK CLONED!!"
16
+ # :nocov:
13
17
  # is this actually necessary?? Seems so?
14
18
  end
15
19
 
@@ -24,24 +28,11 @@ module Ractorize
24
28
  def __value__
25
29
  return @__value__ if defined?(@__value__)
26
30
 
27
- value = if ::Ractor.current == __ractor__
28
- __return_value_port__.receive
29
- else
30
- # :nocov:
31
- ::Kernel.raise EscapingRactorError,
32
- "Somehow this thunk was passed between ractors but wasn't resolved first."
33
- # :nocov:
34
- end
35
-
36
- # :nocov:
37
- ::Kernel.raise EscapingRactorError if ::Ractorize::Thunk === value
38
- # :nocov:
39
-
40
- @__value__ = value
41
-
42
- ::Object.instance_method(:freeze).bind(self).call
31
+ @__value__ = __thunk_ractor__.join.value
32
+ self.__thunk_ractor__ = nil
33
+ ::Object.instance_method(:freeze).bind_call(self)
43
34
 
44
- value
35
+ @__value__
45
36
  end
46
37
 
47
38
  def ! = !__value__
data/src/ractorize.rb CHANGED
@@ -64,6 +64,7 @@ module Ractorize
64
64
  # Not sure why that is but we need to handle that case.
65
65
  def resolve_all_thunks(structure)
66
66
  each_thunk(structure, &:__value__)
67
+ nil
67
68
  end
68
69
 
69
70
  def to_move(target_class, args)
@@ -147,28 +148,8 @@ module Ractorize
147
148
  to_move(target_class, args)
148
149
  end
149
150
 
150
- def each_thunk(structure, seen = Set.new, &block)
151
- return block.call(structure) if Thunk === structure
152
- return if seen.include?(structure)
153
-
154
- seen << structure
155
-
156
- case structure
157
- when Array
158
- structure.each { each_thunk(it, seen, &block) }
159
- when Hash
160
- each_thunk(structure.keys, seen, &block)
161
- each_thunk(structure.values, seen, &block)
162
- when Struct
163
- each_thunk(structure.values, seen, &block)
164
- else
165
- ivarsget = ::Object.instance_method(:instance_variables)
166
- iget = ::Object.instance_method(:instance_variable_get)
167
-
168
- ivarsget.bind(structure).call.each do |var|
169
- each_thunk(iget.bind(structure).call(var), seen, &block)
170
- end
171
- end
151
+ def each_thunk(structure, seen = Set.new, &)
152
+ each_instance_of(Thunk, structure, seen, 0, &)
172
153
  end
173
154
 
174
155
  def extract_args(port_like)
@@ -200,11 +181,43 @@ module Ractorize
200
181
 
201
182
  [args, opts, block]
202
183
  end
184
+
185
+ private
186
+
187
+ def each_instance_of(klass, structure, seen = Set.new, depth = 0, &block)
188
+ depth += 1
189
+ if klass === structure
190
+ block.call(structure)
191
+ end
192
+ return if seen.include?(structure)
193
+
194
+ seen << structure
195
+
196
+ case structure
197
+ when Array
198
+ structure.each { each_instance_of(klass, it, seen, depth, &block) }
199
+ when Hash
200
+ each_instance_of(klass, structure.keys, seen, depth, &block)
201
+ each_instance_of(klass, structure.values, seen, depth, &block)
202
+ when Struct
203
+ each_instance_of(klass, structure.values, seen, depth, &block)
204
+ else
205
+ ivarsget = ::Object.instance_method(:instance_variables)
206
+ iget = ::Object.instance_method(:instance_variable_get)
207
+
208
+ ivarsget.bind(structure).call.each do |var|
209
+ value = iget.bind(structure).call(var)
210
+ each_instance_of(klass, value, seen, depth, &block)
211
+ end
212
+ end
213
+
214
+ nil
215
+ end
203
216
  end
204
217
 
205
218
  # Putting this in a constant so we can get test coverage on it since not sure how to get coverage
206
219
  # on something inside a ractor.
207
- RACTOR_PROC = proc do
220
+ RACTOR_PROC = Ractor.shareable_proc do
208
221
  mode = receive
209
222
 
210
223
  object = case mode
@@ -230,16 +243,25 @@ module Ractorize
230
243
  end
231
244
 
232
245
  loop do
233
- method_name, method_args, opts, return_port, block_given = receive
246
+ # rubocop:disable Lint/UselessAssignment
247
+ value = method_name = method_args = opts = return_port = thunk_ractor = block_given = nil
248
+ # rubocop:enable Lint/UselessAssignment
249
+ method_name, method_args, opts, return_port, thunk_ractor, block_given = receive
234
250
 
235
251
  case method_name
236
252
  when :__close__
237
- return_port.<<(object, move: true)
253
+ begin
254
+ thunk_ractor&.send([:success, object].freeze, move: true)
255
+ rescue RactorizedRactor::ClosedError
256
+ # do nothing
257
+ end
258
+
259
+ object = nil
238
260
  close
239
261
  break
240
262
  else
241
263
  if method_name == :__invoke_arg_by_arg__
242
- args_port = Ractor::Port.new
264
+ args_port = Ractorize::RactorizedObject::RactorizedRactor::Port.new
243
265
  return_port << args_port
244
266
 
245
267
  method_name = args_port.receive
@@ -247,7 +269,7 @@ module Ractorize
247
269
  end
248
270
 
249
271
  if block_given
250
- block_result_port = Ractor::Port.new
272
+ block_result_port = Ractorize::RactorizedObject::RactorizedRactor::Port.new
251
273
 
252
274
  value = object.__send__(method_name, *method_args, **opts) do |*args, **opts, &b|
253
275
  Ractorize.prepare_args(target_class, args, opts, skip_move: true)
@@ -260,7 +282,8 @@ module Ractorize
260
282
  when :normal
261
283
  return_value
262
284
  when :break
263
- break return_value
285
+ # TODO: handle error situation
286
+ break
264
287
  else
265
288
  # :nocov:
266
289
  raise "Not sure how to handle outcome_type #{outcome_type}"
@@ -274,7 +297,11 @@ module Ractorize
274
297
  value = value.__value__ while Ractorize::Thunk === value
275
298
 
276
299
  begin
277
- return_port.send(value)
300
+ if thunk_ractor
301
+ thunk_ractor.send([:success, value].freeze)
302
+ else
303
+ return_port << value
304
+ end
278
305
  rescue IOError => e
279
306
  # Unclear why this sometimes manifests as this error instead of ClosedError but
280
307
  # need to handle them both.
@@ -288,9 +315,12 @@ module Ractorize
288
315
  end
289
316
  end
290
317
  end
318
+
319
+ nil
291
320
  end
292
321
 
293
- object
322
+ nil
323
+ # object
294
324
  rescue => e
295
325
  # :nocov:
296
326
  puts
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ractorize
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miles Georgi
@@ -21,9 +21,13 @@ files:
21
21
  - README.md
22
22
  - lib/ractorize.rb
23
23
  - src/ractorize.rb
24
+ - src/ractorize/base_ractor.rb
25
+ - src/ractorize/garbage_collection.rb
26
+ - src/ractorize/garbage_collection/tracker.rb
24
27
  - src/ractorize/ractorized_class.rb
25
28
  - src/ractorize/ractorized_object.rb
26
29
  - src/ractorize/thunk.rb
30
+ - src/ractorize/thunk/thunk_ractor.rb
27
31
  homepage: https://github.com/ractor-shack/ractorize
28
32
  licenses:
29
33
  - MPL-2.0