ractor-wrapper 0.3.0 → 0.5.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.
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  ##
4
- # See ruby-doc.org for info on Ractors.
4
+ # See https://docs.ruby-lang.org/en/4.0/language/ractor_md.html for info on
5
+ # Ractors.
5
6
  #
6
7
  class Ractor
7
8
  ##
@@ -98,7 +99,7 @@ class Ractor
98
99
  #
99
100
  # # Create a wrapper around the database. A SQLite3::Database object
100
101
  # # cannot be moved between Ractors, so we configure the wrapper to run
101
- # # in the current Ractor. You can also configure it to run multiple
102
+ # # in the current Ractor. We can also configure it to run multiple
102
103
  # # worker threads because the database object itself is thread-safe.
103
104
  # wrapper = Ractor::Wrapper.new(db, use_current_ractor: true, threads: 2)
104
105
  #
@@ -111,17 +112,16 @@ class Ractor
111
112
  # rows = wrapper.stub.execute("select * from numbers")
112
113
  #
113
114
  # # Here, we start two Ractors, and pass the stub to each one. The
114
- # # wrapper's two worker threads will handle the requests in the order
115
- # # received.
116
- # r1 = Ractor.new(wrapper.stub) do |db_stub|
115
+ # # wrapper's worker threads will handle the requests concurrently.
116
+ # r1 = Ractor.new(wrapper.stub) do |stub|
117
117
  # 5.times do
118
- # rows = db_stub.execute("select * from numbers")
118
+ # stub.execute("select * from numbers")
119
119
  # end
120
120
  # :ok
121
121
  # end
122
- # r2 = Ractor.new(wrapper.stub) do |db_stub|
122
+ # r2 = Ractor.new(wrapper.stub) do |stub|
123
123
  # 5.times do
124
- # rows = db_stub.execute("select * from numbers")
124
+ # stub.execute("select * from numbers")
125
125
  # end
126
126
  # :ok
127
127
  # end
@@ -138,7 +138,7 @@ class Ractor
138
138
  # # When running a wrapper with :use_current_ractor, you do not need to
139
139
  # # recover the object, because it was never moved. The recover_object
140
140
  # # method is not available.
141
- # # db2 = wrapper.recover_object # <= raises Ractor::Error
141
+ # # db2 = wrapper.recover_object # <= raises Ractor::Wrapper::Error
142
142
  #
143
143
  # ## Features
144
144
  #
@@ -149,6 +149,8 @@ class Ractor
149
149
  # * Can be configured per method whether to copy or move arguments and
150
150
  # return values.
151
151
  # * Blocks can be run in the calling Ractor or in the object Ractor.
152
+ # * Blocks running in the calling Ractor can re-enter the wrapper, calling
153
+ # other methods on it without deadlocking.
152
154
  # * Raises exceptions thrown by the method.
153
155
  # * Can serialize method calls for non-thread-safe objects, or run methods
154
156
  # concurrently in multiple worker threads for thread-safe objects.
@@ -170,8 +172,30 @@ class Ractor
170
172
  # later unless they are configured to run "in place". In particular,
171
173
  # using blocks as a syntax to define callbacks can generally not be done
172
174
  # through a wrapper.
175
+ # * Re-entrant calls from a block are not safe if the wrapped method
176
+ # invoked the block from a nested Fiber (such as inside an Enumerator)
177
+ # or from a spawned Thread. Such re-entrant calls may deadlock.
173
178
  #
174
179
  class Wrapper
180
+ ##
181
+ # Base class for errors raised by {Ractor::Wrapper}.
182
+ #
183
+ class Error < ::Ractor::Error; end
184
+
185
+ ##
186
+ # Raised when a {Ractor::Wrapper} server has crashed unexpectedly. May
187
+ # also be raised in the calling Ractor when an in-flight method call is
188
+ # suspended at a block-yield point and the server (or its worker thread)
189
+ # crashes before the block result can be delivered.
190
+ #
191
+ class CrashedError < Error; end
192
+
193
+ ##
194
+ # Raised when calling a method on a {Ractor::Wrapper} whose server has
195
+ # stopped and is no longer accepting calls.
196
+ #
197
+ class StoppedError < Error; end
198
+
175
199
  ##
176
200
  # A stub that forwards calls to a wrapper.
177
201
  #
@@ -206,234 +230,355 @@ class Ractor
206
230
  end
207
231
 
208
232
  ##
209
- # Settings for a method call. Specifies how a method's arguments and
210
- # return value are communicated (i.e. copy or move semantics.)
233
+ # Configuration for a {Ractor::Wrapper}. An instance of this class is
234
+ # yielded by {Ractor::Wrapper#initialize} if a block is provided. Any
235
+ # settings made to the Configuration before the block returns take
236
+ # effect when the Wrapper is constructed.
211
237
  #
212
- class MethodSettings
213
- # @private
214
- def initialize(move_data: false,
215
- move_arguments: nil,
216
- move_results: nil,
217
- move_block_arguments: nil,
218
- move_block_results: nil,
219
- execute_blocks_in_place: nil)
220
- @move_arguments = interpret_setting(move_arguments, move_data)
221
- @move_results = interpret_setting(move_results, move_data)
222
- @move_block_arguments = interpret_setting(move_block_arguments, move_data)
223
- @move_block_results = interpret_setting(move_block_results, move_data)
224
- @execute_blocks_in_place = interpret_setting(execute_blocks_in_place, false)
225
- freeze
238
+ class Configuration
239
+ ##
240
+ # Set the name of the wrapper. This is shown in logging and is also
241
+ # used as the name of the wrapping Ractor.
242
+ #
243
+ # @param value [String, nil]
244
+ #
245
+ def name=(value)
246
+ @name = value ? value.to_s.freeze : nil
226
247
  end
227
248
 
228
249
  ##
229
- # @return [Boolean] Whether to move arguments
250
+ # Enable or disable internal debug logging.
230
251
  #
231
- def move_arguments?
232
- @move_arguments
252
+ # @param value [Boolean]
253
+ #
254
+ def enable_logging=(value)
255
+ @enable_logging = value ? true : false
233
256
  end
234
257
 
235
258
  ##
236
- # @return [Boolean] Whether to move return values
259
+ # Set the number of worker threads. If the underlying object is
260
+ # thread-safe, a value of 2 or more allows concurrent calls. Leave at
261
+ # the default of 0 to handle calls sequentially without worker threads.
262
+ #
263
+ # The number of worker threads only needs to reflect the desired
264
+ # concurrency of independent calls. It does not need to be sized to the
265
+ # depth of re-entrant block calls, because suspended methods do not
266
+ # occupy a worker thread while waiting for a block to complete.
237
267
  #
238
- def move_results?
239
- @move_results
268
+ # @param value [Integer]
269
+ #
270
+ def threads=(value)
271
+ value = value.to_i
272
+ value = 0 if value.negative?
273
+ @threads = value
240
274
  end
241
275
 
242
276
  ##
243
- # @return [Boolean] Whether to move arguments to a block
277
+ # If set to true, the wrapper server runs as Thread(s) inside the
278
+ # current Ractor rather than spawning a new isolated Ractor. Use this
279
+ # for objects that cannot be moved between Ractors.
280
+ #
281
+ # @param value [Boolean]
244
282
  #
245
- def move_block_arguments?
246
- @move_block_arguments
283
+ def use_current_ractor=(value)
284
+ @use_current_ractor = value ? true : false
247
285
  end
248
286
 
249
287
  ##
250
- # @return [Boolean] Whether to move block results
288
+ # Configure how argument and return values are communicated for the given
289
+ # method.
290
+ #
291
+ # In general, the following values are recognized for the data-moving
292
+ # settings:
293
+ #
294
+ # * `:copy` - Method arguments or return values that are not shareable,
295
+ # are *deep copied* when communicated between the caller and the object.
296
+ # * `:move` - Method arguments or return values that are not shareable,
297
+ # are *moved* when communicated between the caller and the object. This
298
+ # means they are no longer available to the source; that is, the caller
299
+ # can no longer access objects that were moved to method arguments, and
300
+ # the wrapped object can no longer access objects that were used as
301
+ # return values.
302
+ # * `:void` - This option is available for return values and block
303
+ # results. It disables return values for the given method, and is
304
+ # intended to avoid copying or moving objects that are not intended to
305
+ # be return values. The recipient will receive `nil`.
306
+ #
307
+ # The following settings are recognized for the `block_environment`
308
+ # setting:
309
+ #
310
+ # * `:caller` - Blocks are executed in the caller's context. This means
311
+ # the wrapper sends a message back to the caller to execute the block
312
+ # in its original context. This means the block will have access to its
313
+ # lexical scope and any other data available to the calling Ractor.
314
+ # Such blocks may safely re-enter the wrapper to invoke other methods
315
+ # on it, *unless* the wrapped method invoked the block from a nested
316
+ # Fiber (such as inside an Enumerator) or a spawned Thread, in which
317
+ # case re-entrant calls from the block may deadlock. If you need to
318
+ # invoke the block from a nested Fiber or a spawned Thread and the
319
+ # block does not need re-entrancy, prefer the `:wrapped` setting.
320
+ # * `:wrapped` - Blocks are executed directly in the wrapped object's
321
+ # context. This does not require any communication, but it means the
322
+ # block is removed from the caller's environment and does not have
323
+ # access to the caller's lexical scope or Ractor-accessible data.
251
324
  #
252
- def move_block_results?
253
- @move_block_results
325
+ # All settings are optional. If not provided, they will fall back to a
326
+ # default. If you are configuring a particular method, by specifying the
327
+ # `method_name` argument, any unspecified setting will fall back to the
328
+ # method default settings (which you can set by omitting the method name.)
329
+ # If you are configuring the method default settings, by omitting the
330
+ # `method_name` argument, unspecified settings will fall back to `:copy`
331
+ # for the data movement settings, and `:caller` for the
332
+ # `block_environment` setting.
333
+ #
334
+ # @param method_name [Symbol,nil] The name of the method being configured,
335
+ # or `nil` to set defaults for all methods not configured explicitly.
336
+ # @param arguments [:move,:copy] How to communicate method arguments.
337
+ # @param results [:move,:copy,:void] How to communicate method return
338
+ # values.
339
+ # @param block_arguments [:move,:copy] How to communicate block arguments.
340
+ # @param block_results [:move,:copy,:void] How to communicate block
341
+ # result values.
342
+ # @param block_environment [:caller,:wrapped] How to execute blocks, and
343
+ # what scope blocks have access to.
344
+ #
345
+ def configure_method(method_name = nil,
346
+ arguments: nil,
347
+ results: nil,
348
+ block_arguments: nil,
349
+ block_results: nil,
350
+ block_environment: nil)
351
+ method_name = method_name.to_sym unless method_name.nil?
352
+ @method_settings[method_name] =
353
+ MethodSettings.new(arguments: arguments,
354
+ results: results,
355
+ block_arguments: block_arguments,
356
+ block_results: block_results,
357
+ block_environment: block_environment)
358
+ self
254
359
  end
255
360
 
256
361
  ##
257
- # @return [Boolean] Whether to call blocks in-place
362
+ # @private
363
+ # Return the name of the wrapper.
258
364
  #
259
- def execute_blocks_in_place?
260
- @execute_blocks_in_place
365
+ # @return [String, nil]
366
+ #
367
+ attr_reader :name
368
+
369
+ ##
370
+ # @private
371
+ # Return whether logging is enabled.
372
+ #
373
+ # @return [Boolean]
374
+ #
375
+ attr_reader :enable_logging
376
+
377
+ ##
378
+ # @private
379
+ # Return the number of worker threads.
380
+ #
381
+ # @return [Integer]
382
+ #
383
+ attr_reader :threads
384
+
385
+ ##
386
+ # @private
387
+ # Return whether the wrapper runs in the current Ractor.
388
+ #
389
+ # @return [Boolean]
390
+ #
391
+ attr_reader :use_current_ractor
392
+
393
+ ##
394
+ # @private
395
+ # Resolve the method settings by filling in the defaults for all fields
396
+ # not explicitly set, and return the final settings keyed by method name.
397
+ # The `nil` key will contain defaults for method names not explicitly
398
+ # configured. This hash will be frozen and shareable.
399
+ #
400
+ # @return [Hash{(Symbol,nil)=>MethodSettings}]
401
+ #
402
+ def final_method_settings
403
+ fallback = MethodSettings.new(arguments: :copy, results: :copy,
404
+ block_arguments: :copy, block_results: :copy,
405
+ block_environment: :caller)
406
+ defaults = MethodSettings.with_fallback(@method_settings[nil], fallback)
407
+ results = {nil => defaults}
408
+ @method_settings.each do |name, settings|
409
+ next if name.nil?
410
+ results[name] = MethodSettings.with_fallback(settings, defaults)
411
+ end
412
+ results.freeze
261
413
  end
262
414
 
263
- private
415
+ ##
416
+ # @private
417
+ # Create an empty configuration.
418
+ #
419
+ def initialize
420
+ @method_settings = {}
421
+ configure_method(arguments: nil,
422
+ results: nil,
423
+ block_arguments: nil,
424
+ block_results: nil,
425
+ block_environment: nil)
426
+ end
427
+ end
264
428
 
265
- def interpret_setting(setting, default)
266
- if setting.nil?
267
- default ? true : false
268
- else
269
- setting ? true : false
429
+ ##
430
+ # Settings for a method call. Specifies how a method's arguments and
431
+ # return value are communicated (i.e. copy or move semantics.)
432
+ #
433
+ class MethodSettings
434
+ # @private
435
+ def initialize(arguments: nil,
436
+ results: nil,
437
+ block_arguments: nil,
438
+ block_results: nil,
439
+ block_environment: nil)
440
+ unless [nil, :copy, :move].include?(arguments)
441
+ raise ::ArgumentError, "Unknown `arguments`: #{arguments.inspect} (must be :copy or :move)"
442
+ end
443
+ unless [nil, :copy, :move, :void].include?(results)
444
+ raise ::ArgumentError, "Unknown `results`: #{results.inspect} (must be :copy, :move, or :void)"
445
+ end
446
+ unless [nil, :copy, :move].include?(block_arguments)
447
+ raise ::ArgumentError, "Unknown `block_arguments`: #{block_arguments.inspect} (must be :copy or :move)"
448
+ end
449
+ unless [nil, :copy, :move, :void].include?(block_results)
450
+ raise ::ArgumentError, "Unknown `block_results`: #{block_results.inspect} (must be :copy, :move, or :void)"
270
451
  end
452
+ unless [nil, :caller, :wrapped].include?(block_environment)
453
+ raise ::ArgumentError,
454
+ "Unknown `block_environment`: #{block_environment.inspect} (must be :caller or :wrapped)"
455
+ end
456
+ @arguments = arguments
457
+ @results = results
458
+ @block_arguments = block_arguments
459
+ @block_results = block_results
460
+ @block_environment = block_environment
461
+ freeze
462
+ end
463
+
464
+ ##
465
+ # @return [:copy,:move] How to communicate method arguments
466
+ # @return [nil] if not set (will not happen in final settings)
467
+ #
468
+ attr_reader :arguments
469
+
470
+ ##
471
+ # @return [:copy,:move,:void] How to communicate method return values
472
+ # @return [nil] if not set (will not happen in final settings)
473
+ #
474
+ attr_reader :results
475
+
476
+ ##
477
+ # @return [:copy,:move] How to communicate arguments to a block
478
+ # @return [nil] if not set (will not happen in final settings)
479
+ #
480
+ attr_reader :block_arguments
481
+
482
+ ##
483
+ # @return [:copy,:move,:void] How to communicate block results
484
+ # @return [nil] if not set (will not happen in final settings)
485
+ #
486
+ attr_reader :block_results
487
+
488
+ ##
489
+ # @return [:caller,:wrapped] What environment blocks execute in
490
+ # @return [nil] if not set (will not happen in final settings)
491
+ #
492
+ attr_reader :block_environment
493
+
494
+ # @private
495
+ def self.with_fallback(settings, fallback)
496
+ new(
497
+ arguments: settings.arguments || fallback.arguments,
498
+ results: settings.results || fallback.results,
499
+ block_arguments: settings.block_arguments || fallback.block_arguments,
500
+ block_results: settings.block_results || fallback.block_results,
501
+ block_environment: settings.block_environment || fallback.block_environment
502
+ )
271
503
  end
272
504
  end
273
505
 
274
506
  ##
275
507
  # Create a wrapper around the given object.
276
508
  #
277
- # If you pass an optional block, the wrapper itself will be yielded to it,
278
- # at which time you can set additional configuration options. In
279
- # particular, method-specific configuration must be set in this block.
280
- # The configuration is frozen once the object is constructed.
509
+ # If you pass an optional block, a {Ractor::Wrapper::Configuration} object
510
+ # will be yielded to it, allowing additional configuration before the wrapper
511
+ # starts. In particular, per-method configuration must be set in this block.
512
+ # Block-provided settings override keyword arguments.
513
+ #
514
+ # See {Configuration} for more information about the method communication
515
+ # and block settings.
281
516
  #
282
517
  # @param object [Object] The non-shareable object to wrap.
283
518
  # @param use_current_ractor [boolean] If true, the wrapper is run in a
284
519
  # thread in the current Ractor instead of spawning a new Ractor (the
285
520
  # default behavior). This option can be used if the wrapped object
286
- # cannot be moved or must run in the main Ractor.
287
- # @param name [String] A name for this wrapper. Used during logging.
521
+ # cannot be moved or must run in the main Ractor. Can also be set via
522
+ # the configuration block.
523
+ # @param name [String] A name for this wrapper. Used during logging. Can
524
+ # also be set via the configuration block. Defaults to the object_id.
288
525
  # @param threads [Integer] The number of worker threads to run.
289
526
  # Defaults to 0, which causes the wrapper to run sequentially without
290
- # spawning workers.
291
- # @param move_data [boolean] If true, all communication will by default
292
- # move instead of copy arguments and return values. Default is false.
293
- # This setting can be overridden by other `:move_*` settings.
294
- # @param move_arguments [boolean] If true, all arguments will be moved
295
- # instead of copied by default. If not set, uses the `:move_data`
296
- # setting.
297
- # @param move_results [boolean] If true, return values are moved instead of
298
- # copied by default. If not set, uses the `:move_data` setting.
299
- # @param move_block_arguments [boolean] If true, arguments to blocks are
300
- # moved instead of copied by default. If not set, uses the `:move_data`
301
- # setting.
302
- # @param move_block_results [boolean] If true, result values from blocks
303
- # are moved instead of copied by default. If not set, uses the
304
- # `:move_data` setting.
305
- # @param execute_blocks_in_place [boolean] If true, blocks passed to
306
- # methods are made shareable and passed into the wrapper to be executed
307
- # in the wrapped environment. If false (the default), blocks are
308
- # replaced by a proc that passes messages back out to the caller and
309
- # executes the block in the caller's environment.
527
+ # spawning workers. Sized to the desired concurrency of independent
528
+ # calls; does not need to account for re-entrant block calls, since
529
+ # suspended methods do not occupy a worker thread while waiting for a
530
+ # block to complete. Can also be set via the configuration block.
531
+ # @param arguments [:move,:copy] How to communicate method arguments by
532
+ # default. If not specified, defaults to `:copy`.
533
+ # @param results [:move,:copy,:void] How to communicate method return
534
+ # values by default. If not specified, defaults to `:copy`.
535
+ # @param block_arguments [:move,:copy] How to communicate block arguments
536
+ # by default. If not specified, defaults to `:copy`.
537
+ # @param block_results [:move,:copy,:void] How to communicate block result
538
+ # values by default. If not specified, defaults to `:copy`.
539
+ # @param block_environment [:caller,:wrapped] How to execute blocks, and
540
+ # what scope blocks have access to. If not specified, defaults to
541
+ # `:caller`.
310
542
  # @param enable_logging [boolean] Set to true to enable logging. Default
311
- # is false.
543
+ # is false. Can also be set via the configuration block.
544
+ # @yield [config] An optional configuration block.
545
+ # @yieldparam config [Ractor::Wrapper::Configuration]
312
546
  #
313
547
  def initialize(object,
314
548
  use_current_ractor: false,
315
549
  name: nil,
316
550
  threads: 0,
317
- move_data: false,
318
- move_arguments: nil,
319
- move_results: nil,
320
- move_block_arguments: nil,
321
- move_block_results: nil,
322
- execute_blocks_in_place: nil,
551
+ arguments: nil,
552
+ results: nil,
553
+ block_arguments: nil,
554
+ block_results: nil,
555
+ block_environment: nil,
323
556
  enable_logging: false)
324
557
  raise ::Ractor::MovedError, "cannot wrap a moved object" if ::Ractor::MovedObject === object
325
558
 
326
- @method_settings = {}
327
- self.name = name || object_id.to_s
328
- self.enable_logging = enable_logging
329
- self.threads = threads
330
- configure_method(move_data: move_data,
331
- move_arguments: move_arguments,
332
- move_results: move_results,
333
- move_block_arguments: move_block_arguments,
334
- move_block_results: move_block_results,
335
- execute_blocks_in_place: execute_blocks_in_place)
336
- yield self if block_given?
337
- @method_settings.freeze
338
-
339
- if use_current_ractor
559
+ config = Configuration.new
560
+ config.name = name || object_id.to_s
561
+ config.enable_logging = enable_logging
562
+ config.threads = threads
563
+ config.use_current_ractor = use_current_ractor
564
+ config.configure_method(arguments: arguments,
565
+ results: results,
566
+ block_arguments: block_arguments,
567
+ block_results: block_results,
568
+ block_environment: block_environment)
569
+ yield config if block_given?
570
+
571
+ @name = config.name
572
+ @enable_logging = config.enable_logging
573
+ @threads = config.threads
574
+ @method_settings = config.final_method_settings
575
+ @stub = Stub.new(self)
576
+
577
+ if config.use_current_ractor
340
578
  setup_local_server(object)
341
579
  else
342
580
  setup_isolated_server(object)
343
581
  end
344
- @stub = Stub.new(self)
345
-
346
- freeze
347
- end
348
-
349
- ##
350
- # Set the number of threads to run in the wrapper. If the underlying object
351
- # is thread-safe, setting a value of 2 or more allows concurrent calls to
352
- # it. If the underlying object is not thread-safe, you should leave this
353
- # set to its default of 0, which disables worker threads and handles all
354
- # calls sequentially.
355
- #
356
- # This method can be called only during an initialization block.
357
- # All settings are frozen once the wrapper is active.
358
- #
359
- # @param value [Integer]
360
- #
361
- def threads=(value)
362
- value = value.to_i
363
- value = 0 if value.negative?
364
- @threads = value
365
- end
366
-
367
- ##
368
- # Enable or disable internal debug logging.
369
- #
370
- # This method can be called only during an initialization block.
371
- # All settings are frozen once the wrapper is active.
372
- #
373
- # @param value [Boolean]
374
- #
375
- def enable_logging=(value)
376
- @enable_logging = value ? true : false
377
- end
378
-
379
- ##
380
- # Set the name of this wrapper. This is shown in logging, and is also used
381
- # as the name of the wrapping Ractor.
382
- #
383
- # This method can be called only during an initialization block.
384
- # All settings are frozen once the wrapper is active.
385
- #
386
- # @param value [String, nil]
387
- #
388
- def name=(value)
389
- @name = value ? value.to_s.freeze : nil
390
- end
391
-
392
- ##
393
- # Configure the move semantics for the given method (or the default
394
- # settings if no method name is given.) That is, determine whether
395
- # arguments, return values, and/or exceptions are copied or moved when
396
- # communicated with the wrapper. By default, all objects are copied.
397
- #
398
- # This method can be called only during an initialization block.
399
- # All settings are frozen once the wrapper is active.
400
- #
401
- # @param method_name [Symbol, nil] The name of the method being configured,
402
- # or `nil` to set defaults for all methods not configured explicitly.
403
- # @param move_data [boolean] If true, communication for this method will
404
- # move instead of copy arguments and return values. Default is false.
405
- # This setting can be overridden by other `:move_*` settings.
406
- # @param move_arguments [boolean] If true, arguments for this method are
407
- # moved instead of copied. If not set, uses the `:move_data` setting.
408
- # @param move_results [boolean] If true, return values for this method are
409
- # moved instead of copied. If not set, uses the `:move_data` setting.
410
- # @param move_block_arguments [boolean] If true, arguments to blocks passed
411
- # to this method are moved instead of copied. If not set, uses the
412
- # `:move_data` setting.
413
- # @param move_block_results [boolean] If true, result values from blocks
414
- # passed to this method are moved instead of copied. If not set, uses
415
- # the `:move_data` setting.
416
- # @param execute_blocks_in_place [boolean] If true, blocks passed to this
417
- # method are made shareable and passed into the wrapper to be executed
418
- # in the wrapped environment. If false (the default), blocks are
419
- # replaced by a proc that passes messages back out to the caller and
420
- # executes the block in the caller's environment.
421
- #
422
- def configure_method(method_name = nil,
423
- move_data: false,
424
- move_arguments: nil,
425
- move_results: nil,
426
- move_block_arguments: nil,
427
- move_block_results: nil,
428
- execute_blocks_in_place: nil)
429
- method_name = method_name.to_sym unless method_name.nil?
430
- @method_settings[method_name] =
431
- MethodSettings.new(move_data: move_data,
432
- move_arguments: move_arguments,
433
- move_results: move_results,
434
- move_block_arguments: move_block_arguments,
435
- move_block_results: move_block_results,
436
- execute_blocks_in_place: execute_blocks_in_place)
437
582
  end
438
583
 
439
584
  ##
@@ -478,8 +623,7 @@ class Ractor
478
623
  # @return [MethodSettings]
479
624
  #
480
625
  def method_settings(method_name)
481
- method_name = method_name.to_sym
482
- @method_settings[method_name] || @method_settings[nil]
626
+ (method_name && @method_settings[method_name.to_sym]) || @method_settings[nil]
483
627
  end
484
628
 
485
629
  ##
@@ -500,7 +644,7 @@ class Ractor
500
644
  #
501
645
  def call(method_name, *args, **kwargs, &)
502
646
  reply_port = ::Ractor::Port.new
503
- transaction = ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).freeze
647
+ transaction = make_transaction
504
648
  settings = method_settings(method_name)
505
649
  block_arg = make_block_arg(settings, &)
506
650
  message = CallMessage.new(method_name: method_name,
@@ -511,27 +655,33 @@ class Ractor
511
655
  settings: settings,
512
656
  reply_port: reply_port)
513
657
  maybe_log("Sending method", method_name: method_name, transaction: transaction)
514
- @port.send(message, move: settings.move_arguments?)
658
+ begin
659
+ @port.send(message, move: settings.arguments == :move)
660
+ rescue ::Ractor::ClosedError
661
+ raise StoppedError, "Wrapper has stopped"
662
+ end
515
663
  loop do
516
664
  reply_message = reply_port.receive
517
665
  case reply_message
518
- when YieldMessage
666
+ when FiberYieldMessage, BlockingYieldMessage
519
667
  handle_yield(reply_message, transaction, settings, method_name, &)
520
668
  when ReturnMessage
521
669
  maybe_log("Received result", method_name: method_name, transaction: transaction)
522
- reply_port.close
523
670
  return reply_message.value
524
671
  when ExceptionMessage
525
672
  maybe_log("Received exception", method_name: method_name, transaction: transaction)
526
- reply_port.close
527
673
  raise reply_message.exception
528
674
  end
529
675
  end
676
+ ensure
677
+ reply_port.close
530
678
  end
531
679
 
532
680
  ##
533
681
  # Request that the wrapper stop. All currently running calls will complete
534
- # before the wrapper actually terminates. However, any new calls will fail.
682
+ # before the wrapper actually terminates, including calls that are
683
+ # suspended waiting for a re-entrant block to return. New calls submitted
684
+ # after the stop request will fail with {StoppedError}.
535
685
  #
536
686
  # This method is idempotent and can be called multiple times (even from
537
687
  # different ractors).
@@ -550,6 +700,10 @@ class Ractor
550
700
  ##
551
701
  # Blocks until the wrapper has fully stopped.
552
702
  #
703
+ # Unlike `Thread#join` and `Ractor#join`, if a Wrapper crashes, the
704
+ # exception generally does *not* get raised out of `Wrapper#join`. Instead,
705
+ # it just returns self in the same way as normal termination.
706
+ #
553
707
  # @return [self]
554
708
  #
555
709
  def join
@@ -557,13 +711,16 @@ class Ractor
557
711
  @ractor.join
558
712
  else
559
713
  reply_port = ::Ractor::Port.new
560
- @port.send(JoinMessage.new(reply_port))
561
- reply_port.receive
562
- reply_port.close
714
+ begin
715
+ @port.send(JoinMessage.new(reply_port))
716
+ reply_port.receive
717
+ rescue ::Ractor::ClosedError
718
+ # Assume the wrapper has stopped if the port is not sendable
719
+ ensure
720
+ reply_port.close
721
+ end
563
722
  end
564
723
  self
565
- rescue ::Ractor::ClosedError
566
- self
567
724
  end
568
725
 
569
726
  ##
@@ -577,13 +734,17 @@ class Ractor
577
734
  # case, any calls to this method will raise Ractor::Error.
578
735
  #
579
736
  # Only one ractor may call this method; any additional calls will fail with
580
- # a Ractor::Error.
737
+ # a Ractor::Wrapper::Error.
581
738
  #
582
739
  # @return [Object] The original wrapped object
583
740
  #
584
741
  def recover_object
585
- raise ::Ractor::Error, "cannot recover an object from a local wrapper" unless @ractor
586
- @ractor.value
742
+ raise Error, "cannot recover an object from a local wrapper" unless @ractor
743
+ begin
744
+ @ractor.value
745
+ rescue ::Ractor::Error => e
746
+ raise ::Ractor::Wrapper::Error, e.message, cause: e
747
+ end
587
748
  end
588
749
 
589
750
  #### private items below ####
@@ -592,7 +753,7 @@ class Ractor
592
753
  # @private
593
754
  # Message sent to initialize a server.
594
755
  #
595
- InitMessage = ::Data.define(:object, :enable_logging, :threads)
756
+ InitMessage = ::Data.define(:object, :stub)
596
757
 
597
758
  ##
598
759
  # @private
@@ -619,6 +780,12 @@ class Ractor
619
780
  #
620
781
  JoinMessage = ::Data.define(:reply_port)
621
782
 
783
+ ##
784
+ # @private
785
+ # Message sent from a server in response to a join request.
786
+ #
787
+ JoinReplyMessage = ::Data.define
788
+
622
789
  ##
623
790
  # @private
624
791
  # Message sent to report a return value
@@ -633,9 +800,39 @@ class Ractor
633
800
 
634
801
  ##
635
802
  # @private
636
- # Message sent from a server to request a yield block run
803
+ # Message sent from a server to request a yield block run, in the
804
+ # blocking-fallback path. The server allocates a temporary reply_port and
805
+ # blocks waiting for a response on it. Used when the wrapped object yields
806
+ # from a context where Fiber.yield is not safe (e.g., inside a nested
807
+ # fiber such as an Enumerator's generator, or in a spawned thread).
808
+ #
809
+ BlockingYieldMessage = ::Data.define(:args, :kwargs, :reply_port)
810
+
811
+ ##
812
+ # @private
813
+ # Message sent from a server to request a yield block run, in the
814
+ # fiber-suspend path. The server suspends its method-handling fiber and
815
+ # is resumed when a FiberReturnMessage or FiberExceptionMessage tagged
816
+ # with the same fiber_id arrives back on the server's main port.
637
817
  #
638
- YieldMessage = ::Data.define(:args, :kwargs, :reply_port)
818
+ FiberYieldMessage = ::Data.define(:args, :kwargs, :fiber_id)
819
+
820
+ ##
821
+ # @private
822
+ # Message sent from a caller back to a server, carrying the result of a
823
+ # block invoked via the fiber-suspend path. The fiber_id identifies which
824
+ # suspended fiber on the server should be resumed with this value.
825
+ #
826
+ FiberReturnMessage = ::Data.define(:value, :fiber_id)
827
+
828
+ ##
829
+ # @private
830
+ # Message sent from a caller back to a server, carrying an exception
831
+ # raised by a block invoked via the fiber-suspend path. The fiber_id
832
+ # identifies which suspended fiber on the server should be resumed and
833
+ # have this exception raised inside it.
834
+ #
835
+ FiberExceptionMessage = ::Data.define(:exception, :fiber_id)
639
836
 
640
837
  private
641
838
 
@@ -647,8 +844,12 @@ class Ractor
647
844
  maybe_log("Starting local server")
648
845
  @ractor = nil
649
846
  @port = ::Ractor::Port.new
847
+ freeze
848
+ wrapper_id = object_id
650
849
  ::Thread.new do
850
+ ::Thread.current.name = "ractor-wrapper:server:#{wrapper_id}"
651
851
  Server.run_local(object: object,
852
+ stub: @stub,
652
853
  port: @port,
653
854
  name: name,
654
855
  enable_logging: enable_logging?,
@@ -669,14 +870,16 @@ class Ractor
669
870
  threads: threads)
670
871
  end
671
872
  @port = @ractor.default_port
672
- @port.send(object, move: true)
873
+ freeze
874
+ init_message = InitMessage.new(object: object, stub: @stub)
875
+ @port.send(init_message, move: true)
673
876
  end
674
877
 
675
878
  ##
676
879
  # Create a transaction ID, used for logging
677
880
  #
678
881
  def make_transaction
679
- ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).freeze
882
+ ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).rjust(16, "0").freeze
680
883
  end
681
884
 
682
885
  ##
@@ -685,7 +888,7 @@ class Ractor
685
888
  def make_block_arg(settings, &)
686
889
  if !block_given?
687
890
  nil
688
- elsif settings.execute_blocks_in_place?
891
+ elsif settings.block_environment == :wrapped
689
892
  ::Ractor.shareable_proc(&)
690
893
  else
691
894
  :send_block_message
@@ -694,20 +897,26 @@ class Ractor
694
897
 
695
898
  ##
696
899
  # Handle a call to a block directed to run in the caller environment.
900
+ # Dispatches the block result or exception based on which yield-message
901
+ # variant arrived: a FiberYieldMessage routes the response back to the
902
+ # server's main port (so the suspended fiber can be resumed), while a
903
+ # BlockingYieldMessage routes it to the temporary reply_port the server
904
+ # is blocked on.
697
905
  #
698
906
  def handle_yield(message, transaction, settings, method_name)
699
907
  maybe_log("Yielding to block", method_name: method_name, transaction: transaction)
700
908
  begin
701
909
  block_result = yield(*message.args, **message.kwargs)
910
+ block_result = nil if settings.block_results == :void
702
911
  maybe_log("Sending block result", method_name: method_name, transaction: transaction)
703
- message.reply_port.send(ReturnMessage.new(block_result), move: settings.move_block_results?)
912
+ send_block_result(message, block_result, settings)
704
913
  rescue ::Exception => e # rubocop:disable Lint/RescueException
705
914
  maybe_log("Sending block exception", method_name: method_name, transaction: transaction)
706
915
  begin
707
- message.reply_port.send(ExceptionMessage.new(e))
916
+ send_block_exception(message, e)
708
917
  rescue ::StandardError
709
918
  begin
710
- message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect)))
919
+ send_block_exception(message, ::StandardError.new(e.inspect))
711
920
  rescue ::StandardError
712
921
  maybe_log("Failure to send block reply", method_name: method_name, transaction: transaction)
713
922
  end
@@ -715,6 +924,33 @@ class Ractor
715
924
  end
716
925
  end
717
926
 
927
+ ##
928
+ # Send a block return value to the appropriate destination based on the
929
+ # yield-message variant.
930
+ #
931
+ def send_block_result(message, value, settings)
932
+ case message
933
+ when FiberYieldMessage
934
+ @port.send(FiberReturnMessage.new(value: value, fiber_id: message.fiber_id),
935
+ move: settings.block_results == :move)
936
+ when BlockingYieldMessage
937
+ message.reply_port.send(ReturnMessage.new(value), move: settings.block_results == :move)
938
+ end
939
+ end
940
+
941
+ ##
942
+ # Send a block exception to the appropriate destination based on the
943
+ # yield-message variant.
944
+ #
945
+ def send_block_exception(message, exception)
946
+ case message
947
+ when FiberYieldMessage
948
+ @port.send(FiberExceptionMessage.new(exception: exception, fiber_id: message.fiber_id))
949
+ when BlockingYieldMessage
950
+ message.reply_port.send(ExceptionMessage.new(exception))
951
+ end
952
+ end
953
+
718
954
  ##
719
955
  # Prints out a log message
720
956
  #
@@ -741,12 +977,174 @@ class Ractor
741
977
  # * Either sequentially or concurrently using worker threads.
742
978
  #
743
979
  class Server
980
+ ##
981
+ # @private
982
+ #
983
+ # Multi-queue work dispatcher for the threaded server. Routes new
984
+ # `CallMessage`s through a shared queue (any worker may pick them up)
985
+ # and routes fiber resumes (`FiberReturnMessage` / `FiberExceptionMessage`)
986
+ # through per-worker queues so a suspended fiber is always resumed by
987
+ # the same worker thread that started it (Ruby fibers cannot be resumed
988
+ # from a different thread than their last resumer).
989
+ #
990
+ # All public methods are thread-safe. The internal mutex guards the
991
+ # shared queue, all per-worker queues, the fiber→worker map, and the
992
+ # closed/notified state. `dequeue` blocks on a single shared
993
+ # `ConditionVariable`; producers `broadcast` rather than `signal` so
994
+ # workers waiting on per-worker queues are not starved by
995
+ # shared-queue activity.
996
+ #
997
+ class Dispatcher
998
+ CLOSED = [:closed, nil].freeze
999
+ TERMINATE = [:terminate, nil].freeze
1000
+
1001
+ ##
1002
+ # @param num_workers [Integer] number of per-worker queues to allocate.
1003
+ # Workers are addressed by integers in the range `[0, num_workers)`.
1004
+ #
1005
+ def initialize(num_workers)
1006
+ @mutex = ::Mutex.new
1007
+ @cond = ::ConditionVariable.new
1008
+ @shared_queue = []
1009
+ @worker_queues = ::Array.new(num_workers) { [] }
1010
+ @fiber_to_worker = {}
1011
+ @closed = false
1012
+ @crashed = false
1013
+ @closed_notified = ::Array.new(num_workers, false)
1014
+ end
1015
+
1016
+ ##
1017
+ # Push a new `CallMessage` onto the shared queue.
1018
+ # @return [Boolean] `true` normally, `false` if `close` has been called.
1019
+ #
1020
+ def enqueue_call(message)
1021
+ @mutex.synchronize do
1022
+ return false if @closed
1023
+ @shared_queue.push(message)
1024
+ @cond.broadcast
1025
+ true
1026
+ end
1027
+ end
1028
+
1029
+ ##
1030
+ # Push a fiber-resume message (`FiberReturnMessage` /
1031
+ # `FiberExceptionMessage`) onto the queue of the worker that owns the
1032
+ # fiber identified by `message.fiber_id`.
1033
+ # @return [Boolean] `true` if dispatched, `false` if the fiber_id is
1034
+ # not registered (e.g. fiber already finished or was aborted).
1035
+ #
1036
+ def enqueue_fiber_resume(message)
1037
+ @mutex.synchronize do
1038
+ worker_num = @fiber_to_worker[message.fiber_id]
1039
+ return false unless worker_num
1040
+ @worker_queues[worker_num].push(message)
1041
+ @cond.broadcast
1042
+ true
1043
+ end
1044
+ end
1045
+
1046
+ ##
1047
+ # Block until the worker has work. Priority order:
1048
+ #
1049
+ # 1. Per-worker queue (always — these are resumes for fibers this
1050
+ # worker owns; they must be drained even after close).
1051
+ # 2. Shared queue, but only if `accept_calls` is true and the
1052
+ # dispatcher is not yet closed.
1053
+ # 3. The `CLOSED` sentinel (`[:closed, nil]`), returned exactly once
1054
+ # per worker, the first time the worker would otherwise have
1055
+ # blocked after `close` was called. Used to wake the worker so it
1056
+ # can transition to a draining state. Subsequent calls behave
1057
+ # normally and may block again.
1058
+ #
1059
+ # If `crash_close` has been called, returns `TERMINATE`
1060
+ # (`[:terminate, nil]`) immediately whenever the per-worker queue is
1061
+ # empty — signaling the worker to exit even if it has pending fibers.
1062
+ # The per-worker queue is still drained first so any in-flight resumes
1063
+ # complete normally.
1064
+ #
1065
+ # @return [Array(Symbol, Object)] one of `[:resume, msg]`,
1066
+ # `[:call, msg]`, `CLOSED`, or `TERMINATE`.
1067
+ #
1068
+ def dequeue(worker_num, accept_calls:)
1069
+ @mutex.synchronize do
1070
+ loop do
1071
+ if (msg = @worker_queues[worker_num].shift)
1072
+ return [:resume, msg]
1073
+ end
1074
+ return TERMINATE if @crashed
1075
+ if accept_calls && !@closed && (msg = @shared_queue.shift)
1076
+ return [:call, msg]
1077
+ end
1078
+ if @closed && !@closed_notified[worker_num]
1079
+ @closed_notified[worker_num] = true
1080
+ return CLOSED
1081
+ end
1082
+ @cond.wait(@mutex)
1083
+ end
1084
+ end
1085
+ end
1086
+
1087
+ ##
1088
+ # Atomically associate `fiber_id` with `worker_num` so subsequent
1089
+ # `enqueue_fiber_resume` calls land on the right worker queue.
1090
+ #
1091
+ def register_fiber(fiber_id, worker_num)
1092
+ @mutex.synchronize { @fiber_to_worker[fiber_id] = worker_num }
1093
+ end
1094
+
1095
+ ##
1096
+ # Remove the fiber→worker mapping. Idempotent.
1097
+ #
1098
+ def unregister_fiber(fiber_id)
1099
+ @mutex.synchronize { @fiber_to_worker.delete(fiber_id) }
1100
+ end
1101
+
1102
+ ##
1103
+ # Mark closed and wake all blocked workers. Drains the shared queue
1104
+ # and returns its previous contents so the caller can refuse them
1105
+ # (with `StoppedError`) on behalf of their pending callers. Idempotent
1106
+ # — repeated calls return `[]`.
1107
+ # @return [Array] the messages that were in the shared queue.
1108
+ #
1109
+ def close
1110
+ @mutex.synchronize do
1111
+ return [] if @closed
1112
+ @closed = true
1113
+ drained = @shared_queue.dup
1114
+ @shared_queue.clear
1115
+ @cond.broadcast
1116
+ drained
1117
+ end
1118
+ end
1119
+
1120
+ ##
1121
+ # Mark closed AND crashed: future `dequeue` calls return `TERMINATE`
1122
+ # whenever the per-worker queue is empty (rather than blocking),
1123
+ # signaling workers to exit immediately so their `ensure` blocks can
1124
+ # abort any pending fibers. Used on server crash, where no further
1125
+ # fiber-resume messages will arrive. Idempotent (sets crashed even if
1126
+ # already closed). Returns the drained shared queue.
1127
+ # @return [Array] the messages that were in the shared queue.
1128
+ #
1129
+ def crash_close
1130
+ @mutex.synchronize do
1131
+ already_closed = @closed
1132
+ @closed = true
1133
+ @crashed = true
1134
+ drained = already_closed ? [] : @shared_queue.dup
1135
+ @shared_queue.clear
1136
+ @cond.broadcast
1137
+ drained
1138
+ end
1139
+ end
1140
+ end
1141
+
744
1142
  ##
745
1143
  # @private
746
1144
  # Create and run a server hosted in the current Ractor
747
1145
  #
748
- def self.run_local(object:, port:, name:, enable_logging: false, threads: 0)
749
- server = new(isolated: false, object:, port:, name:, enable_logging:, threads:)
1146
+ def self.run_local(object:, stub:, port:, name:, enable_logging: false, threads: 0)
1147
+ server = new(isolated: false, object:, stub:, port:, name:, enable_logging:, threads:)
750
1148
  server.run
751
1149
  end
752
1150
 
@@ -756,19 +1154,27 @@ class Ractor
756
1154
  #
757
1155
  def self.run_isolated(name:, enable_logging: false, threads: 0)
758
1156
  port = ::Ractor.current.default_port
759
- server = new(isolated: true, object: nil, port:, name:, enable_logging:, threads:)
1157
+ server = new(isolated: true, object: nil, stub: nil, port:, name:, enable_logging:, threads:)
760
1158
  server.run
761
1159
  end
762
1160
 
763
1161
  # @private
764
- def initialize(isolated:, object:, port:, name:, enable_logging:, threads:)
1162
+ def initialize(isolated:, object:, stub:, port:, name:, enable_logging:, threads:)
765
1163
  @isolated = isolated
766
1164
  @object = object
1165
+ @stub = stub
767
1166
  @port = port
768
1167
  @name = name
769
1168
  @enable_logging = enable_logging
770
- @threads = threads.positive? ? threads : nil
1169
+ @threads_requested = threads.positive? ? threads : false
771
1170
  @join_requests = []
1171
+ # Sequential mode only: maps fiber_id (Integer) => Fiber for
1172
+ # method-handling fibers that have suspended (via Fiber.yield) waiting
1173
+ # for a block result. Used to route incoming
1174
+ # FiberReturnMessage/FiberExceptionMessage back to the right fiber.
1175
+ # Threaded mode tracks pending fibers per-worker (in `worker_loop`'s
1176
+ # local `pending` hash) and routes via `Dispatcher`.
1177
+ @pending_fibers = {}
772
1178
  end
773
1179
 
774
1180
  ##
@@ -779,14 +1185,16 @@ class Ractor
779
1185
  #
780
1186
  def run
781
1187
  receive_remote_object if @isolated
782
- start_workers if @threads
1188
+ start_workers if @threads_requested
783
1189
  main_loop
784
- stop_workers if @threads
1190
+ stop_workers if @threads_requested
785
1191
  cleanup
786
1192
  @object
787
- rescue ::StandardError => e
788
- maybe_log("Unexpected error: #{e.inspect}")
1193
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1194
+ @crash_exception = e
789
1195
  @object
1196
+ ensure
1197
+ crash_cleanup if @crash_exception
790
1198
  end
791
1199
 
792
1200
  private
@@ -796,19 +1204,27 @@ class Ractor
796
1204
  # separate Ractor.
797
1205
  #
798
1206
  def receive_remote_object
799
- maybe_log("Waiting for remote object")
800
- @object = @port.receive
1207
+ maybe_log("Waiting for initialization")
1208
+ init_message = @port.receive
1209
+ @object = init_message.object
1210
+ @stub = init_message.stub
801
1211
  end
802
1212
 
803
1213
  ##
804
- # Start the worker threads. Each thread picks up methods to run from a
805
- # shared queue. Called only if worker threading is enabled.
1214
+ # Start the worker threads. Each thread picks up work via the
1215
+ # `Dispatcher`, which routes new `CallMessage`s through a shared queue
1216
+ # and routes fiber-resume messages to the specific worker that owns the
1217
+ # suspended fiber. Called only if worker threading is enabled.
806
1218
  #
807
1219
  def start_workers
808
- @queue = ::Queue.new
809
- maybe_log("Spawning #{@threads} worker threads")
810
- (1..@threads).map do |worker_num|
811
- ::Thread.new { worker_thread(worker_num) }
1220
+ maybe_log("Spawning #{@threads_requested} worker threads")
1221
+ @dispatcher = Dispatcher.new(@threads_requested)
1222
+ @active_workers = {}
1223
+ (0...@threads_requested).each do |worker_num|
1224
+ @active_workers[worker_num] = ::Thread.new do
1225
+ ::Thread.current.name = "ractor-wrapper:#{@name}:worker:#{worker_num}"
1226
+ worker_loop(worker_num)
1227
+ end
812
1228
  end
813
1229
  end
814
1230
 
@@ -816,8 +1232,17 @@ class Ractor
816
1232
  # This is the main loop, listening on the inbox and handling messages for
817
1233
  # normal operation:
818
1234
  #
819
- # * If it receives a CallMessage, it either runs the method (when in
820
- # sequential mode) or adds it to the job queue (when in worker mode).
1235
+ # * If it receives a CallMessage, it either runs the method in a
1236
+ # fiber (sequential mode) or hands it to the `Dispatcher`'s shared
1237
+ # queue (threaded mode). In both modes the method body executes
1238
+ # inside a `Fiber` so it can suspend (via `Fiber.yield`) when its
1239
+ # caller-side block needs to make a re-entrant call back into this
1240
+ # wrapper.
1241
+ # * If it receives a FiberReturnMessage or FiberExceptionMessage, it
1242
+ # resumes the suspended fiber. In sequential mode the fiber lives
1243
+ # in `@pending_fibers` and is resumed inline. In threaded mode the
1244
+ # `Dispatcher` routes the message to the per-worker queue of the
1245
+ # fiber's owning worker.
821
1246
  # * If it receives a StopMessage, it exits the main loop and proceeds
822
1247
  # to the termination logic.
823
1248
  # * If it receives a JoinMessage, it adds it to the list of join ports
@@ -832,36 +1257,128 @@ class Ractor
832
1257
  maybe_log("Waiting for message in running phase")
833
1258
  message = @port.receive
834
1259
  case message
835
- when CallMessage
836
- maybe_log("Received CallMessage", call_message: message)
837
- if @threads
838
- @queue.enq(message)
839
- else
840
- handle_method(message)
841
- end
1260
+ when CallMessage then dispatch_call(message)
1261
+ when FiberReturnMessage, FiberExceptionMessage then dispatch_fiber_resume(message)
1262
+ when JoinMessage then @join_requests << message.reply_port
842
1263
  when WorkerStoppedMessage
843
1264
  maybe_log("Received unexpected WorkerStoppedMessage")
844
- @threads -= 1 if @threads
1265
+ @active_workers.delete(message.worker_num) if @threads_requested
845
1266
  break
846
1267
  when StopMessage
847
1268
  maybe_log("Received stop")
1269
+ drain_pending_fibers unless @threads_requested
848
1270
  break
1271
+ end
1272
+ end
1273
+ end
1274
+
1275
+ ##
1276
+ # Dispatch a `CallMessage` received by the main loop. In sequential
1277
+ # mode the method is started inline as a new fiber; in threaded mode
1278
+ # it is handed to the dispatcher's shared queue for any worker to pick
1279
+ # up.
1280
+ #
1281
+ def dispatch_call(message)
1282
+ maybe_log("Received CallMessage", call_message: message)
1283
+ if @threads_requested
1284
+ @dispatcher.enqueue_call(message)
1285
+ else
1286
+ start_method_fiber(message)
1287
+ end
1288
+ end
1289
+
1290
+ ##
1291
+ # Route a fiber-resume message (`FiberReturnMessage` /
1292
+ # `FiberExceptionMessage`) to its owning fiber. In sequential mode this
1293
+ # resumes the fiber inline; in threaded mode the dispatcher routes it to
1294
+ # the per-worker queue of the worker that started the fiber. Logs and
1295
+ # discards if the fiber is no longer registered (likely already finished
1296
+ # or aborted by a crashed worker).
1297
+ #
1298
+ def dispatch_fiber_resume(message)
1299
+ maybe_log("Routing fiber resume", fiber_id: message.fiber_id)
1300
+ if @threads_requested
1301
+ return if @dispatcher.enqueue_fiber_resume(message)
1302
+ maybe_log("Discarding orphan fiber resume", fiber_id: message.fiber_id)
1303
+ else
1304
+ resume_method_fiber(message)
1305
+ end
1306
+ end
1307
+
1308
+ ##
1309
+ # Sequential-mode stopping phase. After receiving a StopMessage, continue
1310
+ # accepting fiber-result messages (and join requests) so that any
1311
+ # currently-suspended method-handling fiber can complete. New
1312
+ # `CallMessage`s are refused with `StoppedError`. Returns once
1313
+ # `@pending_fibers` is empty.
1314
+ #
1315
+ def drain_pending_fibers
1316
+ until @pending_fibers.empty?
1317
+ maybe_log("Waiting for pending fibers to complete")
1318
+ message = @port.receive
1319
+ case message
1320
+ when CallMessage
1321
+ refuse_method(message)
1322
+ when FiberReturnMessage, FiberExceptionMessage
1323
+ resume_method_fiber(message)
1324
+ when StopMessage
1325
+ maybe_log("Stop received when already stopping")
849
1326
  when JoinMessage
850
1327
  maybe_log("Received and queueing join request")
851
1328
  @join_requests << message.reply_port
1329
+ else
1330
+ maybe_log("Unexpected message when draining pending fibers: #{message.class.name}")
852
1331
  end
853
1332
  end
854
1333
  end
855
1334
 
856
1335
  ##
857
- # This signals workers to stop by closing the queue, and then waits for
858
- # all workers to report in that they have stopped. It is called only if
859
- # worker threading is enabled.
1336
+ # Spawn a fiber to handle a CallMessage in sequential mode. If the
1337
+ # method-handling fiber suspends via Fiber.yield (because its caller-side
1338
+ # block re-entered this wrapper), the fiber is registered in
1339
+ # `@pending_fibers` so that the matching block-return message can later
1340
+ # resume it.
1341
+ #
1342
+ def start_method_fiber(message)
1343
+ fiber = ::Fiber.new { handle_method(message) }
1344
+ fiber_id = fiber.object_id
1345
+ @pending_fibers[fiber_id] = fiber
1346
+ maybe_log("Starting method fiber", call_message: message, fiber_id: fiber_id)
1347
+ fiber.resume
1348
+ @pending_fibers.delete(fiber_id) unless fiber.alive?
1349
+ end
1350
+
1351
+ ##
1352
+ # Resume a previously-suspended method-handling fiber, delivering the
1353
+ # block-result message as the return value of its Fiber.yield call.
1354
+ # Silently ignores unknown fiber_ids (the fiber may have been aborted).
1355
+ #
1356
+ def resume_method_fiber(message)
1357
+ fiber = @pending_fibers[message.fiber_id]
1358
+ return unless fiber
1359
+ maybe_log("Resuming method fiber", fiber_id: message.fiber_id)
1360
+ fiber.resume(message)
1361
+ @pending_fibers.delete(message.fiber_id) unless fiber.alive?
1362
+ end
1363
+
1364
+ ##
1365
+ # This signals workers to stop by closing the dispatcher, and then
1366
+ # waits for all workers to report in that they have stopped. It is
1367
+ # called only if worker threading is enabled.
1368
+ #
1369
+ # Closing the dispatcher drains any never-dispatched `CallMessage`s
1370
+ # from its shared queue; those are refused immediately so the
1371
+ # corresponding callers do not block forever. The dispatcher also
1372
+ # delivers a one-shot closed signal to each worker so they can
1373
+ # transition to a draining state.
860
1374
  #
861
1375
  # Responds to messages to indicate the wrapper is stopping and no longer
862
1376
  # accepting new method requests:
863
1377
  #
864
1378
  # * If it receives a CallMessage, it sends back a refusal exception.
1379
+ # * If it receives a FiberReturnMessage or FiberExceptionMessage, it
1380
+ # forwards it to the dispatcher so the owning worker can resume its
1381
+ # suspended fiber and complete the in-flight call.
865
1382
  # * If it receives a StopMessage, it does nothing (i.e. the stop
866
1383
  # operation is idempotent).
867
1384
  # * If it receives a JoinMessage, it adds it to the list of join ports
@@ -875,16 +1392,19 @@ class Ractor
875
1392
  # stopped.
876
1393
  #
877
1394
  def stop_workers
878
- @queue.close
879
- while @threads.positive?
1395
+ drained = @dispatcher.close
1396
+ drained.each { |message| refuse_method(message) }
1397
+ until @active_workers.empty?
880
1398
  maybe_log("Waiting for message in stopping phase")
881
1399
  message = @port.receive
882
1400
  case message
883
1401
  when CallMessage
884
1402
  refuse_method(message)
1403
+ when FiberReturnMessage, FiberExceptionMessage
1404
+ dispatch_fiber_resume(message)
885
1405
  when WorkerStoppedMessage
886
1406
  maybe_log("Acknowledged WorkerStoppedMessage: #{message.worker_num}")
887
- @threads -= 1
1407
+ @active_workers.delete(message.worker_num)
888
1408
  when StopMessage
889
1409
  maybe_log("Stop received when already stopping")
890
1410
  when JoinMessage
@@ -901,8 +1421,6 @@ class Ractor
901
1421
  def cleanup
902
1422
  maybe_log("Closing inbox")
903
1423
  @port.close
904
- maybe_log("Responding to join requests")
905
- @join_requests.each { |port| send_join_reply(port) }
906
1424
  maybe_log("Draining inbox")
907
1425
  loop do
908
1426
  message = begin
@@ -924,6 +1442,111 @@ class Ractor
924
1442
  send_join_reply(message.reply_port)
925
1443
  end
926
1444
  end
1445
+ maybe_log("Responding to join requests")
1446
+ @join_requests.each { |port| send_join_reply(port) }
1447
+ end
1448
+
1449
+ ##
1450
+ # Called from the ensure block in run when an unexpected exception
1451
+ # terminated the server. Drains pending requests that are not otherwise
1452
+ # being handled, responding to all pending callers and join requesters,
1453
+ # and also joins any worker threads.
1454
+ #
1455
+ def crash_cleanup
1456
+ maybe_log("Running crash cleanup after: #{@crash_exception.message} (#{@crash_exception.class})")
1457
+ error = CrashedError.new("Server crashed: #{@crash_exception.message} (#{@crash_exception.class})")
1458
+ # `@dispatcher` should not be nil in threaded mode, but we're
1459
+ # checking anyway just in case a crash happened during setup
1460
+ drain_dispatcher_after_crash(@dispatcher, error) if @threads_requested && @dispatcher
1461
+ abort_pending_fibers(@pending_fibers, error) unless @threads_requested
1462
+ drain_inbox_after_crash(@port, error)
1463
+ # `@active_workers` should not be nil in threaded mode, but we're
1464
+ # checking anyway just in case a crash happened during setup
1465
+ join_workers_after_crash(@active_workers) if @threads_requested && @active_workers
1466
+ @join_requests.each { |port| send_join_reply(port) }
1467
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1468
+ maybe_log("Suppressed exception during crash_cleanup: #{e.message} (#{e.class})")
1469
+ end
1470
+
1471
+ ##
1472
+ # After a crash, raises +error+ inside each suspended method-handling
1473
+ # fiber. The exception emerges from the fiber's `Fiber.yield` call and is
1474
+ # caught by `handle_method`'s rescue chain, which sends an
1475
+ # `ExceptionMessage` to the fiber's reply_port so the caller observes a
1476
+ # `CrashedError`.
1477
+ #
1478
+ def abort_pending_fibers(pending_fibers, error)
1479
+ pending_fibers.each_pair do |fiber_id, fiber|
1480
+ maybe_log("Aborting suspended fiber", fiber_id: fiber_id)
1481
+ fiber.raise(error)
1482
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1483
+ maybe_log("Suppressed exception during abort_pending_fibers: #{e.message} (#{e.class})",
1484
+ fiber_id: fiber_id)
1485
+ end
1486
+ pending_fibers.clear
1487
+ end
1488
+
1489
+ ##
1490
+ # Closes the dispatcher after a crash, then sends an error response
1491
+ # to the callers of any `CallMessage`s that were still queued in the
1492
+ # shared queue (and therefore had not yet been dispatched to a worker).
1493
+ # Workers themselves clean up their own in-flight fibers via their
1494
+ # ensure blocks.
1495
+ #
1496
+ def drain_dispatcher_after_crash(dispatcher, error)
1497
+ dispatcher.crash_close.each do |message|
1498
+ message.reply_port.send(ExceptionMessage.new(error))
1499
+ rescue ::Ractor::Error
1500
+ maybe_log("Failed to send crash error to queued caller", call_message: message)
1501
+ end
1502
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1503
+ maybe_log("Suppressed exception during drain_dispatcher_after_crash: " \
1504
+ "#{e.message} (#{e.class})")
1505
+ end
1506
+
1507
+ ##
1508
+ # Drains any remaining inbox messages after a crash, sending errors to
1509
+ # pending callers and responding to any join requests.
1510
+ #
1511
+ def drain_inbox_after_crash(port, error)
1512
+ begin
1513
+ port.close
1514
+ rescue ::Ractor::Error
1515
+ # Port was already closed (maybe because it was the cause of the crash)
1516
+ end
1517
+ loop do
1518
+ message = begin
1519
+ port.receive
1520
+ rescue ::Ractor::Error
1521
+ nil
1522
+ end
1523
+ break if message.nil?
1524
+ case message
1525
+ when CallMessage
1526
+ begin
1527
+ message.reply_port.send(ExceptionMessage.new(error))
1528
+ rescue ::Ractor::Error
1529
+ maybe_log("Failed to send crash error to caller", call_message: message)
1530
+ end
1531
+ when JoinMessage
1532
+ send_join_reply(message.reply_port)
1533
+ when WorkerStoppedMessage, StopMessage, FiberReturnMessage, FiberExceptionMessage
1534
+ # Ignore
1535
+ end
1536
+ end
1537
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1538
+ maybe_log("Suppressed exception during drain_inbox_after_crash: #{e.message} (#{e.class})")
1539
+ end
1540
+
1541
+ ##
1542
+ # Wait until all workers have stopped after a crash
1543
+ #
1544
+ def join_workers_after_crash(workers)
1545
+ workers.each_value do |thread|
1546
+ thread.join
1547
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1548
+ maybe_log("Suppressed exception during join_workers_after_crash: #{e.message} (#{e.class})")
1549
+ end
927
1550
  end
928
1551
 
929
1552
  ##
@@ -934,23 +1557,101 @@ class Ractor
934
1557
  # is closed, the worker will send an acknowledgement message and then
935
1558
  # terminate.
936
1559
  #
937
- def worker_thread(worker_num)
1560
+ def worker_loop(worker_num)
938
1561
  maybe_log("Worker starting", worker_num: worker_num)
1562
+ pending = {}
1563
+ crash_exception = nil
1564
+ begin
1565
+ run_worker_dispatch_loop(worker_num, pending)
1566
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1567
+ crash_exception = e
1568
+ raise
1569
+ ensure
1570
+ cleanup_worker(worker_num, pending, crash_exception)
1571
+ end
1572
+ end
1573
+
1574
+ ##
1575
+ # The dispatch loop body for a worker thread. Loops on
1576
+ # `@dispatcher.dequeue` and routes each result to the appropriate
1577
+ # handler. Exits when the dispatcher signals termination, or when a
1578
+ # graceful close has been observed and the local pending hash is empty.
1579
+ #
1580
+ def run_worker_dispatch_loop(worker_num, pending)
1581
+ stopping = false
939
1582
  loop do
940
- maybe_log("Waiting for job", worker_num: worker_num)
941
- message = @queue.deq
942
- break if message.nil?
943
- handle_method(message, worker_num: worker_num)
1583
+ maybe_log("Waiting for work", worker_num: worker_num)
1584
+ kind, message = @dispatcher.dequeue(worker_num, accept_calls: !stopping)
1585
+ case kind
1586
+ when :call then start_worker_fiber(message, pending, worker_num)
1587
+ when :resume then resume_worker_fiber(message, pending)
1588
+ when :closed then stopping = true
1589
+ when :terminate then return
1590
+ end
1591
+ return if stopping && pending.empty?
944
1592
  end
945
- ensure
1593
+ end
1594
+
1595
+ ##
1596
+ # Worker-thread cleanup: aborts any pending fibers (so their callers
1597
+ # observe `CrashedError`) and reports the worker's stop to the main
1598
+ # loop. Always runs in the worker's ensure block — both for normal exit
1599
+ # and for crash exit.
1600
+ #
1601
+ def cleanup_worker(worker_num, pending, crash_exception = nil)
946
1602
  maybe_log("Worker stopping", worker_num: worker_num)
1603
+ if pending && !pending.empty?
1604
+ message =
1605
+ if crash_exception
1606
+ "Worker #{worker_num} crashed: #{crash_exception.message} (#{crash_exception.class})"
1607
+ else
1608
+ "Worker #{worker_num} terminated"
1609
+ end
1610
+ error = CrashedError.new(message)
1611
+ pending.each_key { |fiber_id| @dispatcher.unregister_fiber(fiber_id) }
1612
+ abort_pending_fibers(pending, error)
1613
+ end
947
1614
  begin
948
1615
  @port.send(WorkerStoppedMessage.new(worker_num))
949
1616
  rescue ::Ractor::ClosedError
950
- maybe_log("Orphaned worker thread", worker_num: worker_num)
1617
+ maybe_log("Worker unable to report stop, possibly due to server crash", worker_num: worker_num)
951
1618
  end
952
1619
  end
953
1620
 
1621
+ ##
1622
+ # Start a fiber for a new `CallMessage` on this worker. Registers the
1623
+ # fiber with the dispatcher so future fiber-resume messages route here.
1624
+ # If the fiber completes synchronously (no `Fiber.yield`), it is
1625
+ # immediately removed from the local `pending` hash and unregistered.
1626
+ #
1627
+ def start_worker_fiber(message, pending, worker_num)
1628
+ fiber = ::Fiber.new { handle_method(message, worker_num: worker_num) }
1629
+ fiber_id = fiber.object_id
1630
+ pending[fiber_id] = fiber
1631
+ @dispatcher.register_fiber(fiber_id, worker_num)
1632
+ maybe_log("Starting worker fiber", call_message: message, worker_num: worker_num, fiber_id: fiber_id)
1633
+ fiber.resume
1634
+ return if fiber.alive?
1635
+ pending.delete(fiber_id)
1636
+ @dispatcher.unregister_fiber(fiber_id)
1637
+ end
1638
+
1639
+ ##
1640
+ # Resume a previously-suspended fiber with a fiber-result message. If
1641
+ # the fiber is no longer alive (e.g. aborted), the message is silently
1642
+ # discarded. On completion the fiber is removed from `pending` and
1643
+ # unregistered from the dispatcher.
1644
+ #
1645
+ def resume_worker_fiber(message, pending)
1646
+ fiber = pending[message.fiber_id]
1647
+ return unless fiber
1648
+ maybe_log("Resuming worker fiber", fiber_id: message.fiber_id)
1649
+ fiber.resume(message)
1650
+ return if fiber.alive?
1651
+ pending.delete(message.fiber_id)
1652
+ @dispatcher.unregister_fiber(message.fiber_id)
1653
+ end
1654
+
954
1655
  ##
955
1656
  # This is called to handle a method call request.
956
1657
  # It calls the method on the wrapped object, and then sends back a
@@ -963,20 +1664,20 @@ class Ractor
963
1664
  def handle_method(message, worker_num: nil)
964
1665
  block = make_block(message)
965
1666
  maybe_log("Running method", worker_num: worker_num, call_message: message)
1667
+ result = @object.__send__(message.method_name, *message.args, **message.kwargs, &block)
1668
+ result = @stub if result.equal?(@object)
1669
+ result = nil if message.settings.results == :void
1670
+ maybe_log("Sending return value", worker_num: worker_num, call_message: message)
1671
+ message.reply_port.send(ReturnMessage.new(result), move: message.settings.results == :move)
1672
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1673
+ maybe_log("Sending exception", worker_num: worker_num, call_message: message)
966
1674
  begin
967
- result = @object.__send__(message.method_name, *message.args, **message.kwargs, &block)
968
- maybe_log("Sending return value", worker_num: worker_num, call_message: message)
969
- message.reply_port.send(ReturnMessage.new(result), move: message.settings.move_results?)
970
- rescue ::Exception => e # rubocop:disable Lint/RescueException
971
- maybe_log("Sending exception", worker_num: worker_num, call_message: message)
1675
+ message.reply_port.send(ExceptionMessage.new(e))
1676
+ rescue ::Exception # rubocop:disable Lint/RescueException
972
1677
  begin
973
- message.reply_port.send(ExceptionMessage.new(e))
974
- rescue ::StandardError
975
- begin
976
- message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect)))
977
- rescue ::StandardError
978
- maybe_log("Failure to send method response", worker_num: worker_num, call_message: message)
979
- end
1678
+ message.reply_port.send(ExceptionMessage.new(::RuntimeError.new(e.inspect)))
1679
+ rescue ::Exception # rubocop:disable Lint/RescueException
1680
+ maybe_log("Failure to send method response", worker_num: worker_num, call_message: message)
980
1681
  end
981
1682
  end
982
1683
  end
@@ -991,23 +1692,77 @@ class Ractor
991
1692
  # with the block arguments, to run the block in the caller's
992
1693
  # environment
993
1694
  #
1695
+ # The returned proc uses the fiber-suspend path (Fiber.yield) so the
1696
+ # server can continue processing other messages, including re-entrant
1697
+ # calls from inside the block. In threaded mode, fiber resumes are
1698
+ # routed back to the same worker that started the fiber via the
1699
+ # `Dispatcher`'s per-worker queues (Ruby fibers cannot migrate between
1700
+ # threads).
1701
+ #
1702
+ # Hybrid fallback: if the proc is invoked from a different fiber than
1703
+ # the method-handling fiber (e.g. from inside an Enumerator generator
1704
+ # or a spawned fiber), the fiber path would call Fiber.yield on the
1705
+ # wrong fiber. In that case the proc falls back to the blocking path.
1706
+ #
994
1707
  def make_block(message)
995
1708
  return message.block_arg unless message.block_arg == :send_block_message
1709
+ expected_fiber = ::Fiber.current
996
1710
  proc do |*args, **kwargs|
997
- reply_port = ::Ractor::Port.new
998
- yield_message = YieldMessage.new(args: args, kwargs: kwargs, reply_port: reply_port)
999
- message.reply_port.send(yield_message, move: message.settings.move_block_arguments?)
1000
- reply_message = reply_port.receive
1001
- reply_port.close
1002
- case reply_message
1003
- when ExceptionMessage
1004
- raise reply_message.exception
1005
- when ReturnMessage
1006
- reply_message.value
1711
+ args.map! { |arg| arg.equal?(@object) ? @stub : arg }
1712
+ kwargs.transform_values! { |arg| arg.equal?(@object) ? @stub : arg }
1713
+ if ::Fiber.current.equal?(expected_fiber)
1714
+ fiber_yield_block(message, args, kwargs)
1715
+ else
1716
+ blocking_yield_block(message, args, kwargs)
1007
1717
  end
1008
1718
  end
1009
1719
  end
1010
1720
 
1721
+ ##
1722
+ # Yield to a caller-side block via the fiber-suspend path. The current
1723
+ # fiber's id is sent in the FiberYieldMessage so the caller knows which
1724
+ # FiberReturnMessage/FiberExceptionMessage to send back. The fiber then
1725
+ # suspends; main_loop will resume it with the reply message when one
1726
+ # arrives on @port.
1727
+ #
1728
+ def fiber_yield_block(message, args, kwargs)
1729
+ fiber_id = ::Fiber.current.object_id
1730
+ yield_message = FiberYieldMessage.new(args: args, kwargs: kwargs, fiber_id: fiber_id)
1731
+ maybe_log("Yielding to caller-side block", call_message: message, fiber_id: fiber_id)
1732
+ message.reply_port.send(yield_message, move: message.settings.block_arguments == :move)
1733
+ reply = ::Fiber.yield
1734
+ maybe_log("Resumed after block reply", call_message: message, fiber_id: fiber_id)
1735
+ case reply
1736
+ when FiberExceptionMessage
1737
+ raise reply.exception
1738
+ when FiberReturnMessage
1739
+ reply.value
1740
+ end
1741
+ end
1742
+
1743
+ ##
1744
+ # Yield to a caller-side block via the blocking-fallback path: allocate
1745
+ # a temporary reply_port and block waiting for a response on it. Used
1746
+ # when the fiber-suspend path is not available (nested-fiber and
1747
+ # spawned-thread invocations).
1748
+ #
1749
+ def blocking_yield_block(message, args, kwargs)
1750
+ reply_port = ::Ractor::Port.new
1751
+ reply_message = begin
1752
+ yield_message = BlockingYieldMessage.new(args: args, kwargs: kwargs, reply_port: reply_port)
1753
+ message.reply_port.send(yield_message, move: message.settings.block_arguments == :move)
1754
+ reply_port.receive
1755
+ ensure
1756
+ reply_port.close
1757
+ end
1758
+ case reply_message
1759
+ when ExceptionMessage
1760
+ raise reply_message.exception
1761
+ when ReturnMessage
1762
+ reply_message.value
1763
+ end
1764
+ end
1765
+
1011
1766
  ##
1012
1767
  # This is called from the main Ractor thread to report to a caller that
1013
1768
  # the wrapper cannot handle a requested method call, likely because the
@@ -1016,7 +1771,7 @@ class Ractor
1016
1771
  def refuse_method(message)
1017
1772
  maybe_log("Refusing method call", call_message: message)
1018
1773
  begin
1019
- error = ::Ractor::ClosedError.new("Wrapper is shutting down")
1774
+ error = StoppedError.new("Wrapper is shutting down")
1020
1775
  message.reply_port.send(ExceptionMessage.new(error))
1021
1776
  rescue ::Ractor::Error
1022
1777
  maybe_log("Failed to send refusal message", call_message: message)
@@ -1027,7 +1782,7 @@ class Ractor
1027
1782
  # This attempts to send a signal that a wrapper join has completed.
1028
1783
  #
1029
1784
  def send_join_reply(port)
1030
- port.send(nil)
1785
+ port.send(JoinReplyMessage.new.freeze)
1031
1786
  rescue ::Ractor::ClosedError
1032
1787
  maybe_log("Join reply port is closed")
1033
1788
  end
@@ -1035,17 +1790,21 @@ class Ractor
1035
1790
  ##
1036
1791
  # Print out a log message
1037
1792
  #
1038
- def maybe_log(str, call_message: nil, worker_num: nil, transaction: nil, method_name: nil)
1793
+ def maybe_log(str, call_message: nil, worker_num: nil, fiber_id: nil,
1794
+ transaction: nil, method_name: nil)
1039
1795
  return unless @enable_logging
1040
1796
  transaction ||= call_message&.transaction
1041
1797
  method_name ||= call_message&.method_name
1042
- metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper/#{@name}"]
1043
- metadata << "Worker/#{worker_num}" if worker_num
1044
- metadata << "Transaction/#{transaction}" if transaction
1045
- metadata << "Method/#{method_name}" if method_name
1798
+ metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper:#{@name}"]
1799
+ metadata << "Worker:#{worker_num}" if worker_num
1800
+ metadata << "Fiber:#{fiber_id}" if fiber_id
1801
+ metadata << "Transaction:#{transaction}" if transaction
1802
+ metadata << "Method:#{method_name}" if method_name
1046
1803
  metadata = metadata.join(" ")
1047
1804
  $stderr.puts("[#{metadata}] #{str}")
1048
1805
  $stderr.flush
1806
+ rescue ::StandardError
1807
+ # Swallow any errors during logging
1049
1808
  end
1050
1809
  end
1051
1810
  end