ractor-wrapper 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  #
@@ -172,6 +172,22 @@ class Ractor
172
172
  # through a wrapper.
173
173
  #
174
174
  class Wrapper
175
+ ##
176
+ # Base class for errors raised by {Ractor::Wrapper}.
177
+ #
178
+ class Error < ::Ractor::Error; end
179
+
180
+ ##
181
+ # Raised when a {Ractor::Wrapper} server has crashed unexpectedly.
182
+ #
183
+ class CrashedError < Error; end
184
+
185
+ ##
186
+ # Raised when calling a method on a {Ractor::Wrapper} whose server has
187
+ # stopped and is no longer accepting calls.
188
+ #
189
+ class StoppedError < Error; end
190
+
175
191
  ##
176
192
  # A stub that forwards calls to a wrapper.
177
193
  #
@@ -206,234 +222,341 @@ class Ractor
206
222
  end
207
223
 
208
224
  ##
209
- # Settings for a method call. Specifies how a method's arguments and
210
- # return value are communicated (i.e. copy or move semantics.)
225
+ # Configuration for a {Ractor::Wrapper}. An instance of this class is
226
+ # yielded by {Ractor::Wrapper#initialize} if a block is provided. Any
227
+ # settings made to the Configuration before the block returns take
228
+ # effect when the Wrapper is constructed.
211
229
  #
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
230
+ class Configuration
231
+ ##
232
+ # Set the name of the wrapper. This is shown in logging and is also
233
+ # used as the name of the wrapping Ractor.
234
+ #
235
+ # @param value [String, nil]
236
+ #
237
+ def name=(value)
238
+ @name = value ? value.to_s.freeze : nil
226
239
  end
227
240
 
228
241
  ##
229
- # @return [Boolean] Whether to move arguments
242
+ # Enable or disable internal debug logging.
230
243
  #
231
- def move_arguments?
232
- @move_arguments
244
+ # @param value [Boolean]
245
+ #
246
+ def enable_logging=(value)
247
+ @enable_logging = value ? true : false
233
248
  end
234
249
 
235
250
  ##
236
- # @return [Boolean] Whether to move return values
251
+ # Set the number of worker threads. If the underlying object is
252
+ # thread-safe, a value of 2 or more allows concurrent calls. Leave at
253
+ # the default of 0 to handle calls sequentially without worker threads.
254
+ #
255
+ # @param value [Integer]
237
256
  #
238
- def move_results?
239
- @move_results
257
+ def threads=(value)
258
+ value = value.to_i
259
+ value = 0 if value.negative?
260
+ @threads = value
240
261
  end
241
262
 
242
263
  ##
243
- # @return [Boolean] Whether to move arguments to a block
264
+ # If set to true, the wrapper server runs as Thread(s) inside the
265
+ # current Ractor rather than spawning a new isolated Ractor. Use this
266
+ # for objects that cannot be moved between Ractors.
267
+ #
268
+ # @param value [Boolean]
244
269
  #
245
- def move_block_arguments?
246
- @move_block_arguments
270
+ def use_current_ractor=(value)
271
+ @use_current_ractor = value ? true : false
247
272
  end
248
273
 
249
274
  ##
250
- # @return [Boolean] Whether to move block results
275
+ # Configure how argument and return values are communicated for the given
276
+ # method.
277
+ #
278
+ # In general, the following values are recognized for the data-moving
279
+ # settings:
280
+ #
281
+ # * `:copy` - Method arguments or return values that are not shareable,
282
+ # are *deep copied* when communicated between the caller and the object.
283
+ # * `:move` - Method arguments or return values that are not shareable,
284
+ # are *moved* when communicated between the caller and the object. This
285
+ # means they are no longer available to the source; that is, the caller
286
+ # can no longer access objects that were moved to method arguments, and
287
+ # the wrapped object can no longer access objects that were used as
288
+ # return values.
289
+ # * `:void` - This option is available for return values and block
290
+ # results. It disables return values for the given method, and is
291
+ # intended to avoid copying or moving objects that are not intended to
292
+ # be return values. The recipient will receive `nil`.
293
+ #
294
+ # The following settings are recognized for the `block_environment`
295
+ # setting:
251
296
  #
252
- def move_block_results?
253
- @move_block_results
297
+ # * `:caller` - Blocks are executed in the caller's context. This means
298
+ # the wrapper sends a message back to the caller to execute the block
299
+ # in its original context. This means the block will have access to its
300
+ # lexical scope and any other data available to the calling Ractor.
301
+ # * `:wrapped` - Blocks are executed directly in the wrapped object's
302
+ # context. This does not require any communication, but it means the
303
+ # block is removed from the caller's environment and does not have
304
+ # access to the caller's lexical scope or Ractor-accessible data.
305
+ #
306
+ # All settings are optional. If not provided, they will fall back to a
307
+ # default. If you are configuring a particular method, by specifying the
308
+ # `method_name` argument, any unspecified setting will fall back to the
309
+ # method default settings (which you can set by omitting the method name.)
310
+ # If you are configuring the method default settings, by omitting the
311
+ # `method_name` argument, unspecified settings will fall back to `:copy`
312
+ # for the data movement settings, and `:caller` for the
313
+ # `block_environment` setting.
314
+ #
315
+ # @param method_name [Symbol,nil] The name of the method being configured,
316
+ # or `nil` to set defaults for all methods not configured explicitly.
317
+ # @param arguments [:move,:copy] How to communicate method arguments.
318
+ # @param results [:move,:copy,:void] How to communicate method return
319
+ # values.
320
+ # @param block_arguments [:move,:copy] How to communicate block arguments.
321
+ # @param block_results [:move,:copy,:void] How to communicate block
322
+ # result values.
323
+ # @param block_environment [:caller,:wrapped] How to execute blocks, and
324
+ # what scope blocks have access to.
325
+ #
326
+ def configure_method(method_name = nil,
327
+ arguments: nil,
328
+ results: nil,
329
+ block_arguments: nil,
330
+ block_results: nil,
331
+ block_environment: nil)
332
+ method_name = method_name.to_sym unless method_name.nil?
333
+ @method_settings[method_name] =
334
+ MethodSettings.new(arguments: arguments,
335
+ results: results,
336
+ block_arguments: block_arguments,
337
+ block_results: block_results,
338
+ block_environment: block_environment)
339
+ self
254
340
  end
255
341
 
256
342
  ##
257
- # @return [Boolean] Whether to call blocks in-place
343
+ # @private
344
+ # Return the name of the wrapper.
345
+ #
346
+ # @return [String, nil]
347
+ #
348
+ attr_reader :name
349
+
350
+ ##
351
+ # @private
352
+ # Return whether logging is enabled.
353
+ #
354
+ # @return [Boolean]
355
+ #
356
+ attr_reader :enable_logging
357
+
358
+ ##
359
+ # @private
360
+ # Return the number of worker threads.
361
+ #
362
+ # @return [Integer]
363
+ #
364
+ attr_reader :threads
365
+
366
+ ##
367
+ # @private
368
+ # Return whether the wrapper runs in the current Ractor.
369
+ #
370
+ # @return [Boolean]
371
+ #
372
+ attr_reader :use_current_ractor
373
+
374
+ ##
375
+ # @private
376
+ # Resolve the method settings by filling in the defaults for all fields
377
+ # not explicitly set, and return the final settings keyed by method name.
378
+ # The `nil` key will contain defaults for method names not explicitly
379
+ # configured. This hash will be frozen and shareable.
258
380
  #
259
- def execute_blocks_in_place?
260
- @execute_blocks_in_place
381
+ # @return [Hash{(Symbol,nil)=>MethodSettings}]
382
+ #
383
+ def final_method_settings
384
+ fallback = MethodSettings.new(arguments: :copy, results: :copy,
385
+ block_arguments: :copy, block_results: :copy,
386
+ block_environment: :caller)
387
+ defaults = MethodSettings.with_fallback(@method_settings[nil], fallback)
388
+ results = {nil => defaults}
389
+ @method_settings.each do |name, settings|
390
+ next if name.nil?
391
+ results[name] = MethodSettings.with_fallback(settings, defaults)
392
+ end
393
+ results.freeze
261
394
  end
262
395
 
263
- private
396
+ ##
397
+ # @private
398
+ # Create an empty configuration.
399
+ #
400
+ def initialize
401
+ @method_settings = {}
402
+ configure_method(arguments: nil,
403
+ results: nil,
404
+ block_arguments: nil,
405
+ block_results: nil,
406
+ block_environment: nil)
407
+ end
408
+ end
264
409
 
265
- def interpret_setting(setting, default)
266
- if setting.nil?
267
- default ? true : false
268
- else
269
- setting ? true : false
410
+ ##
411
+ # Settings for a method call. Specifies how a method's arguments and
412
+ # return value are communicated (i.e. copy or move semantics.)
413
+ #
414
+ class MethodSettings
415
+ # @private
416
+ def initialize(arguments: nil,
417
+ results: nil,
418
+ block_arguments: nil,
419
+ block_results: nil,
420
+ block_environment: nil)
421
+ unless [nil, :copy, :move].include?(arguments)
422
+ raise ::ArgumentError, "Unknown `arguments`: #{arguments.inspect} (must be :copy or :move)"
423
+ end
424
+ unless [nil, :copy, :move, :void].include?(results)
425
+ raise ::ArgumentError, "Unknown `results`: #{results.inspect} (must be :copy, :move, or :void)"
426
+ end
427
+ unless [nil, :copy, :move].include?(block_arguments)
428
+ raise ::ArgumentError, "Unknown `block_arguments`: #{block_arguments.inspect} (must be :copy or :move)"
429
+ end
430
+ unless [nil, :copy, :move, :void].include?(block_results)
431
+ raise ::ArgumentError, "Unknown `block_results`: #{block_results.inspect} (must be :copy, :move, or :void)"
432
+ end
433
+ unless [nil, :caller, :wrapped].include?(block_environment)
434
+ raise ::ArgumentError,
435
+ "Unknown `block_environment`: #{block_environment.inspect} (must be :caller or :wrapped)"
270
436
  end
437
+ @arguments = arguments
438
+ @results = results
439
+ @block_arguments = block_arguments
440
+ @block_results = block_results
441
+ @block_environment = block_environment
442
+ freeze
443
+ end
444
+
445
+ ##
446
+ # @return [:copy,:move] How to communicate method arguments
447
+ # @return [nil] if not set (will not happen in final settings)
448
+ #
449
+ attr_reader :arguments
450
+
451
+ ##
452
+ # @return [:copy,:move,:void] How to communicate method return values
453
+ # @return [nil] if not set (will not happen in final settings)
454
+ #
455
+ attr_reader :results
456
+
457
+ ##
458
+ # @return [:copy,:move] How to communicate arguments to a block
459
+ # @return [nil] if not set (will not happen in final settings)
460
+ #
461
+ attr_reader :block_arguments
462
+
463
+ ##
464
+ # @return [:copy,:move,:void] How to communicate block results
465
+ # @return [nil] if not set (will not happen in final settings)
466
+ #
467
+ attr_reader :block_results
468
+
469
+ ##
470
+ # @return [:caller,:wrapped] What environment blocks execute in
471
+ # @return [nil] if not set (will not happen in final settings)
472
+ #
473
+ attr_reader :block_environment
474
+
475
+ # @private
476
+ def self.with_fallback(settings, fallback)
477
+ new(
478
+ arguments: settings.arguments || fallback.arguments,
479
+ results: settings.results || fallback.results,
480
+ block_arguments: settings.block_arguments || fallback.block_arguments,
481
+ block_results: settings.block_results || fallback.block_results,
482
+ block_environment: settings.block_environment || fallback.block_environment
483
+ )
271
484
  end
272
485
  end
273
486
 
274
487
  ##
275
488
  # Create a wrapper around the given object.
276
489
  #
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.
490
+ # If you pass an optional block, a {Ractor::Wrapper::Configuration} object
491
+ # will be yielded to it, allowing additional configuration before the wrapper
492
+ # starts. In particular, per-method configuration must be set in this block.
493
+ # Block-provided settings override keyword arguments.
494
+ #
495
+ # See {Configuration} for more information about the method communication
496
+ # and block settings.
281
497
  #
282
498
  # @param object [Object] The non-shareable object to wrap.
283
499
  # @param use_current_ractor [boolean] If true, the wrapper is run in a
284
500
  # thread in the current Ractor instead of spawning a new Ractor (the
285
501
  # 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.
502
+ # cannot be moved or must run in the main Ractor. Can also be set via
503
+ # the configuration block.
504
+ # @param name [String] A name for this wrapper. Used during logging. Can
505
+ # also be set via the configuration block. Defaults to the object_id.
288
506
  # @param threads [Integer] The number of worker threads to run.
289
507
  # 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.
508
+ # spawning workers. Can also be set via the configuration block.
509
+ # @param arguments [:move,:copy] How to communicate method arguments by
510
+ # default. If not specified, defaults to `:copy`.
511
+ # @param results [:move,:copy,:void] How to communicate method return
512
+ # values by default. If not specified, defaults to `:copy`.
513
+ # @param block_arguments [:move,:copy] How to communicate block arguments
514
+ # by default. If not specified, defaults to `:copy`.
515
+ # @param block_results [:move,:copy,:void] How to communicate block result
516
+ # values by default. If not specified, defaults to `:copy`.
517
+ # @param block_environment [:caller,:wrapped] How to execute blocks, and
518
+ # what scope blocks have access to. If not specified, defaults to
519
+ # `:caller`.
310
520
  # @param enable_logging [boolean] Set to true to enable logging. Default
311
- # is false.
521
+ # is false. Can also be set via the configuration block.
522
+ # @yield [config] An optional configuration block.
523
+ # @yieldparam config [Ractor::Wrapper::Configuration]
312
524
  #
313
525
  def initialize(object,
314
526
  use_current_ractor: false,
315
527
  name: nil,
316
528
  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,
529
+ arguments: nil,
530
+ results: nil,
531
+ block_arguments: nil,
532
+ block_results: nil,
533
+ block_environment: nil,
323
534
  enable_logging: false)
324
535
  raise ::Ractor::MovedError, "cannot wrap a moved object" if ::Ractor::MovedObject === object
325
536
 
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
537
+ config = Configuration.new
538
+ config.name = name || object_id.to_s
539
+ config.enable_logging = enable_logging
540
+ config.threads = threads
541
+ config.use_current_ractor = use_current_ractor
542
+ config.configure_method(arguments: arguments,
543
+ results: results,
544
+ block_arguments: block_arguments,
545
+ block_results: block_results,
546
+ block_environment: block_environment)
547
+ yield config if block_given?
548
+
549
+ @name = config.name
550
+ @enable_logging = config.enable_logging
551
+ @threads = config.threads
552
+ @method_settings = config.final_method_settings
553
+ @stub = Stub.new(self)
554
+
555
+ if config.use_current_ractor
340
556
  setup_local_server(object)
341
557
  else
342
558
  setup_isolated_server(object)
343
559
  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
560
  end
438
561
 
439
562
  ##
@@ -478,8 +601,7 @@ class Ractor
478
601
  # @return [MethodSettings]
479
602
  #
480
603
  def method_settings(method_name)
481
- method_name = method_name.to_sym
482
- @method_settings[method_name] || @method_settings[nil]
604
+ (method_name && @method_settings[method_name.to_sym]) || @method_settings[nil]
483
605
  end
484
606
 
485
607
  ##
@@ -500,7 +622,7 @@ class Ractor
500
622
  #
501
623
  def call(method_name, *args, **kwargs, &)
502
624
  reply_port = ::Ractor::Port.new
503
- transaction = ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).freeze
625
+ transaction = make_transaction
504
626
  settings = method_settings(method_name)
505
627
  block_arg = make_block_arg(settings, &)
506
628
  message = CallMessage.new(method_name: method_name,
@@ -511,7 +633,11 @@ class Ractor
511
633
  settings: settings,
512
634
  reply_port: reply_port)
513
635
  maybe_log("Sending method", method_name: method_name, transaction: transaction)
514
- @port.send(message, move: settings.move_arguments?)
636
+ begin
637
+ @port.send(message, move: settings.arguments == :move)
638
+ rescue ::Ractor::ClosedError
639
+ raise StoppedError, "Wrapper has stopped"
640
+ end
515
641
  loop do
516
642
  reply_message = reply_port.receive
517
643
  case reply_message
@@ -519,14 +645,14 @@ class Ractor
519
645
  handle_yield(reply_message, transaction, settings, method_name, &)
520
646
  when ReturnMessage
521
647
  maybe_log("Received result", method_name: method_name, transaction: transaction)
522
- reply_port.close
523
648
  return reply_message.value
524
649
  when ExceptionMessage
525
650
  maybe_log("Received exception", method_name: method_name, transaction: transaction)
526
- reply_port.close
527
651
  raise reply_message.exception
528
652
  end
529
653
  end
654
+ ensure
655
+ reply_port.close
530
656
  end
531
657
 
532
658
  ##
@@ -550,6 +676,10 @@ class Ractor
550
676
  ##
551
677
  # Blocks until the wrapper has fully stopped.
552
678
  #
679
+ # Unlike `Thread#join` and `Ractor#join`, if a Wrapper crashes, the
680
+ # exception generally does *not* get raised out of `Wrapper#join`. Instead,
681
+ # it just returns self in the same way as normal termination.
682
+ #
553
683
  # @return [self]
554
684
  #
555
685
  def join
@@ -557,13 +687,16 @@ class Ractor
557
687
  @ractor.join
558
688
  else
559
689
  reply_port = ::Ractor::Port.new
560
- @port.send(JoinMessage.new(reply_port))
561
- reply_port.receive
562
- reply_port.close
690
+ begin
691
+ @port.send(JoinMessage.new(reply_port))
692
+ reply_port.receive
693
+ rescue ::Ractor::ClosedError
694
+ # Assume the wrapper has stopped if the port is not sendable
695
+ ensure
696
+ reply_port.close
697
+ end
563
698
  end
564
699
  self
565
- rescue ::Ractor::ClosedError
566
- self
567
700
  end
568
701
 
569
702
  ##
@@ -577,13 +710,17 @@ class Ractor
577
710
  # case, any calls to this method will raise Ractor::Error.
578
711
  #
579
712
  # Only one ractor may call this method; any additional calls will fail with
580
- # a Ractor::Error.
713
+ # a Ractor::Wrapper::Error.
581
714
  #
582
715
  # @return [Object] The original wrapped object
583
716
  #
584
717
  def recover_object
585
- raise ::Ractor::Error, "cannot recover an object from a local wrapper" unless @ractor
586
- @ractor.value
718
+ raise Error, "cannot recover an object from a local wrapper" unless @ractor
719
+ begin
720
+ @ractor.value
721
+ rescue ::Ractor::Error => e
722
+ raise ::Ractor::Wrapper::Error, e.message, cause: e
723
+ end
587
724
  end
588
725
 
589
726
  #### private items below ####
@@ -592,7 +729,7 @@ class Ractor
592
729
  # @private
593
730
  # Message sent to initialize a server.
594
731
  #
595
- InitMessage = ::Data.define(:object, :enable_logging, :threads)
732
+ InitMessage = ::Data.define(:object, :stub)
596
733
 
597
734
  ##
598
735
  # @private
@@ -619,6 +756,12 @@ class Ractor
619
756
  #
620
757
  JoinMessage = ::Data.define(:reply_port)
621
758
 
759
+ ##
760
+ # @private
761
+ # Message sent from a server in response to a join request.
762
+ #
763
+ JoinReplyMessage = ::Data.define
764
+
622
765
  ##
623
766
  # @private
624
767
  # Message sent to report a return value
@@ -647,8 +790,12 @@ class Ractor
647
790
  maybe_log("Starting local server")
648
791
  @ractor = nil
649
792
  @port = ::Ractor::Port.new
793
+ freeze
794
+ wrapper_id = object_id
650
795
  ::Thread.new do
796
+ ::Thread.current.name = "ractor-wrapper:server:#{wrapper_id}"
651
797
  Server.run_local(object: object,
798
+ stub: @stub,
652
799
  port: @port,
653
800
  name: name,
654
801
  enable_logging: enable_logging?,
@@ -669,14 +816,16 @@ class Ractor
669
816
  threads: threads)
670
817
  end
671
818
  @port = @ractor.default_port
672
- @port.send(object, move: true)
819
+ freeze
820
+ init_message = InitMessage.new(object: object, stub: @stub)
821
+ @port.send(init_message, move: true)
673
822
  end
674
823
 
675
824
  ##
676
825
  # Create a transaction ID, used for logging
677
826
  #
678
827
  def make_transaction
679
- ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).freeze
828
+ ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).rjust(16, "0").freeze
680
829
  end
681
830
 
682
831
  ##
@@ -685,7 +834,7 @@ class Ractor
685
834
  def make_block_arg(settings, &)
686
835
  if !block_given?
687
836
  nil
688
- elsif settings.execute_blocks_in_place?
837
+ elsif settings.block_environment == :wrapped
689
838
  ::Ractor.shareable_proc(&)
690
839
  else
691
840
  :send_block_message
@@ -699,8 +848,9 @@ class Ractor
699
848
  maybe_log("Yielding to block", method_name: method_name, transaction: transaction)
700
849
  begin
701
850
  block_result = yield(*message.args, **message.kwargs)
851
+ block_result = nil if settings.block_results == :void
702
852
  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?)
853
+ message.reply_port.send(ReturnMessage.new(block_result), move: settings.block_results == :move)
704
854
  rescue ::Exception => e # rubocop:disable Lint/RescueException
705
855
  maybe_log("Sending block exception", method_name: method_name, transaction: transaction)
706
856
  begin
@@ -745,8 +895,8 @@ class Ractor
745
895
  # @private
746
896
  # Create and run a server hosted in the current Ractor
747
897
  #
748
- def self.run_local(object:, port:, name:, enable_logging: false, threads: 0)
749
- server = new(isolated: false, object:, port:, name:, enable_logging:, threads:)
898
+ def self.run_local(object:, stub:, port:, name:, enable_logging: false, threads: 0)
899
+ server = new(isolated: false, object:, stub:, port:, name:, enable_logging:, threads:)
750
900
  server.run
751
901
  end
752
902
 
@@ -756,18 +906,19 @@ class Ractor
756
906
  #
757
907
  def self.run_isolated(name:, enable_logging: false, threads: 0)
758
908
  port = ::Ractor.current.default_port
759
- server = new(isolated: true, object: nil, port:, name:, enable_logging:, threads:)
909
+ server = new(isolated: true, object: nil, stub: nil, port:, name:, enable_logging:, threads:)
760
910
  server.run
761
911
  end
762
912
 
763
913
  # @private
764
- def initialize(isolated:, object:, port:, name:, enable_logging:, threads:)
914
+ def initialize(isolated:, object:, stub:, port:, name:, enable_logging:, threads:)
765
915
  @isolated = isolated
766
916
  @object = object
917
+ @stub = stub
767
918
  @port = port
768
919
  @name = name
769
920
  @enable_logging = enable_logging
770
- @threads = threads.positive? ? threads : nil
921
+ @threads_requested = threads.positive? ? threads : false
771
922
  @join_requests = []
772
923
  end
773
924
 
@@ -779,14 +930,16 @@ class Ractor
779
930
  #
780
931
  def run
781
932
  receive_remote_object if @isolated
782
- start_workers if @threads
933
+ start_workers if @threads_requested
783
934
  main_loop
784
- stop_workers if @threads
935
+ stop_workers if @threads_requested
785
936
  cleanup
786
937
  @object
787
- rescue ::StandardError => e
788
- maybe_log("Unexpected error: #{e.inspect}")
938
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
939
+ @crash_exception = e
789
940
  @object
941
+ ensure
942
+ crash_cleanup if @crash_exception
790
943
  end
791
944
 
792
945
  private
@@ -796,8 +949,10 @@ class Ractor
796
949
  # separate Ractor.
797
950
  #
798
951
  def receive_remote_object
799
- maybe_log("Waiting for remote object")
800
- @object = @port.receive
952
+ maybe_log("Waiting for initialization")
953
+ init_message = @port.receive
954
+ @object = init_message.object
955
+ @stub = init_message.stub
801
956
  end
802
957
 
803
958
  ##
@@ -805,10 +960,11 @@ class Ractor
805
960
  # shared queue. Called only if worker threading is enabled.
806
961
  #
807
962
  def start_workers
963
+ maybe_log("Spawning #{@threads_requested} worker threads")
808
964
  @queue = ::Queue.new
809
- maybe_log("Spawning #{@threads} worker threads")
810
- (1..@threads).map do |worker_num|
811
- ::Thread.new { worker_thread(worker_num) }
965
+ @active_workers = {}
966
+ (1..@threads_requested).each do |worker_num|
967
+ @active_workers[worker_num] = ::Thread.new { worker_thread(worker_num) }
812
968
  end
813
969
  end
814
970
 
@@ -834,14 +990,14 @@ class Ractor
834
990
  case message
835
991
  when CallMessage
836
992
  maybe_log("Received CallMessage", call_message: message)
837
- if @threads
993
+ if @threads_requested
838
994
  @queue.enq(message)
839
995
  else
840
996
  handle_method(message)
841
997
  end
842
998
  when WorkerStoppedMessage
843
999
  maybe_log("Received unexpected WorkerStoppedMessage")
844
- @threads -= 1 if @threads
1000
+ @active_workers.delete(message.worker_num) if @threads_requested
845
1001
  break
846
1002
  when StopMessage
847
1003
  maybe_log("Received stop")
@@ -876,7 +1032,7 @@ class Ractor
876
1032
  #
877
1033
  def stop_workers
878
1034
  @queue.close
879
- while @threads.positive?
1035
+ until @active_workers.empty?
880
1036
  maybe_log("Waiting for message in stopping phase")
881
1037
  message = @port.receive
882
1038
  case message
@@ -884,7 +1040,7 @@ class Ractor
884
1040
  refuse_method(message)
885
1041
  when WorkerStoppedMessage
886
1042
  maybe_log("Acknowledged WorkerStoppedMessage: #{message.worker_num}")
887
- @threads -= 1
1043
+ @active_workers.delete(message.worker_num)
888
1044
  when StopMessage
889
1045
  maybe_log("Stop received when already stopping")
890
1046
  when JoinMessage
@@ -901,8 +1057,6 @@ class Ractor
901
1057
  def cleanup
902
1058
  maybe_log("Closing inbox")
903
1059
  @port.close
904
- maybe_log("Responding to join requests")
905
- @join_requests.each { |port| send_join_reply(port) }
906
1060
  maybe_log("Draining inbox")
907
1061
  loop do
908
1062
  message = begin
@@ -924,6 +1078,94 @@ class Ractor
924
1078
  send_join_reply(message.reply_port)
925
1079
  end
926
1080
  end
1081
+ maybe_log("Responding to join requests")
1082
+ @join_requests.each { |port| send_join_reply(port) }
1083
+ end
1084
+
1085
+ ##
1086
+ # Called from the ensure block in run when an unexpected exception
1087
+ # terminated the server. Drains pending requests that are not otherwise
1088
+ # being handled, responding to all pending callers and join requesters,
1089
+ # and also joins any worker threads.
1090
+ #
1091
+ def crash_cleanup
1092
+ maybe_log("Running crash cleanup after: #{@crash_exception.message} (#{@crash_exception.class})")
1093
+ error = CrashedError.new("Server crashed: #{@crash_exception.message} (#{@crash_exception.class})")
1094
+ # `@queue` should not be nil in threaded mode, but we're checking
1095
+ # anyway just in case a crash happened during setup
1096
+ drain_queue_after_crash(@queue, error) if @threads_requested && @queue
1097
+ drain_inbox_after_crash(@port, error)
1098
+ # `@active_workers` should not be nil in threaded mode, but we're
1099
+ # checking anyway just in case a crash happened during setup
1100
+ join_workers_after_crash(@active_workers) if @threads_requested && @active_workers
1101
+ @join_requests.each { |port| send_join_reply(port) }
1102
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1103
+ maybe_log("Suppressed exception during crash_cleanup: #{e.message} (#{e.class})")
1104
+ end
1105
+
1106
+ ##
1107
+ # Drains any remaining queued call messages after a crash, sending errors
1108
+ # to callers whose calls had not yet been dispatched to a worker thread.
1109
+ #
1110
+ def drain_queue_after_crash(queue, error)
1111
+ queue.close
1112
+ loop do
1113
+ message = queue.deq
1114
+ break if message.nil?
1115
+ begin
1116
+ message.reply_port.send(ExceptionMessage.new(error))
1117
+ rescue ::Ractor::Error
1118
+ maybe_log("Failed to send crash error to queued caller", call_message: message)
1119
+ end
1120
+ end
1121
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1122
+ maybe_log("Suppressed exception during drain_queue_after_crash: " \
1123
+ "#{e.message} (#{e.class})")
1124
+ end
1125
+
1126
+ ##
1127
+ # Drains any remaining inbox messages after a crash, sending errors to
1128
+ # pending callers and responding to any join requests.
1129
+ #
1130
+ def drain_inbox_after_crash(port, error)
1131
+ begin
1132
+ port.close
1133
+ rescue ::Ractor::Error
1134
+ # Port was already closed (maybe because it was the cause of the crash)
1135
+ end
1136
+ loop do
1137
+ message = begin
1138
+ port.receive
1139
+ rescue ::Ractor::Error
1140
+ nil
1141
+ end
1142
+ break if message.nil?
1143
+ case message
1144
+ when CallMessage
1145
+ begin
1146
+ message.reply_port.send(ExceptionMessage.new(error))
1147
+ rescue ::Ractor::Error
1148
+ maybe_log("Failed to send crash error to caller", call_message: message)
1149
+ end
1150
+ when JoinMessage
1151
+ send_join_reply(message.reply_port)
1152
+ when WorkerStoppedMessage, StopMessage
1153
+ # Ignore
1154
+ end
1155
+ end
1156
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1157
+ maybe_log("Suppressed exception during drain_inbox_after_crash: #{e.message} (#{e.class})")
1158
+ end
1159
+
1160
+ ##
1161
+ # Wait until all workers have stopped after a crash
1162
+ #
1163
+ def join_workers_after_crash(workers)
1164
+ workers.each_value do |thread|
1165
+ thread.join
1166
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1167
+ maybe_log("Suppressed exception during join_workers_after_crash: #{e.message} (#{e.class})")
1168
+ end
927
1169
  end
928
1170
 
929
1171
  ##
@@ -947,7 +1189,7 @@ class Ractor
947
1189
  begin
948
1190
  @port.send(WorkerStoppedMessage.new(worker_num))
949
1191
  rescue ::Ractor::ClosedError
950
- maybe_log("Orphaned worker thread", worker_num: worker_num)
1192
+ maybe_log("Worker unable to report stop, possibly due to server crash", worker_num: worker_num)
951
1193
  end
952
1194
  end
953
1195
 
@@ -963,20 +1205,20 @@ class Ractor
963
1205
  def handle_method(message, worker_num: nil)
964
1206
  block = make_block(message)
965
1207
  maybe_log("Running method", worker_num: worker_num, call_message: message)
1208
+ result = @object.__send__(message.method_name, *message.args, **message.kwargs, &block)
1209
+ result = @stub if result.equal?(@object)
1210
+ result = nil if message.settings.results == :void
1211
+ maybe_log("Sending return value", worker_num: worker_num, call_message: message)
1212
+ message.reply_port.send(ReturnMessage.new(result), move: message.settings.results == :move)
1213
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1214
+ maybe_log("Sending exception", worker_num: worker_num, call_message: message)
966
1215
  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)
1216
+ message.reply_port.send(ExceptionMessage.new(e))
1217
+ rescue ::Exception # rubocop:disable Lint/RescueException
972
1218
  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
1219
+ message.reply_port.send(ExceptionMessage.new(::RuntimeError.new(e.inspect)))
1220
+ rescue ::Exception # rubocop:disable Lint/RescueException
1221
+ maybe_log("Failure to send method response", worker_num: worker_num, call_message: message)
980
1222
  end
981
1223
  end
982
1224
  end
@@ -995,10 +1237,15 @@ class Ractor
995
1237
  return message.block_arg unless message.block_arg == :send_block_message
996
1238
  proc do |*args, **kwargs|
997
1239
  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
1240
+ reply_message = begin
1241
+ args.map! { |arg| arg.equal?(@object) ? @stub : arg }
1242
+ kwargs.transform_values! { |arg| arg.equal?(@object) ? @stub : arg }
1243
+ yield_message = YieldMessage.new(args: args, kwargs: kwargs, reply_port: reply_port)
1244
+ message.reply_port.send(yield_message, move: message.settings.block_arguments == :move)
1245
+ reply_port.receive
1246
+ ensure
1247
+ reply_port.close
1248
+ end
1002
1249
  case reply_message
1003
1250
  when ExceptionMessage
1004
1251
  raise reply_message.exception
@@ -1016,7 +1263,7 @@ class Ractor
1016
1263
  def refuse_method(message)
1017
1264
  maybe_log("Refusing method call", call_message: message)
1018
1265
  begin
1019
- error = ::Ractor::ClosedError.new("Wrapper is shutting down")
1266
+ error = StoppedError.new("Wrapper is shutting down")
1020
1267
  message.reply_port.send(ExceptionMessage.new(error))
1021
1268
  rescue ::Ractor::Error
1022
1269
  maybe_log("Failed to send refusal message", call_message: message)
@@ -1027,7 +1274,7 @@ class Ractor
1027
1274
  # This attempts to send a signal that a wrapper join has completed.
1028
1275
  #
1029
1276
  def send_join_reply(port)
1030
- port.send(nil)
1277
+ port.send(JoinReplyMessage.new.freeze)
1031
1278
  rescue ::Ractor::ClosedError
1032
1279
  maybe_log("Join reply port is closed")
1033
1280
  end
@@ -1039,13 +1286,15 @@ class Ractor
1039
1286
  return unless @enable_logging
1040
1287
  transaction ||= call_message&.transaction
1041
1288
  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
1289
+ metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper:#{@name}"]
1290
+ metadata << "Worker:#{worker_num}" if worker_num
1291
+ metadata << "Transaction:#{transaction}" if transaction
1292
+ metadata << "Method:#{method_name}" if method_name
1046
1293
  metadata = metadata.join(" ")
1047
1294
  $stderr.puts("[#{metadata}] #{str}")
1048
1295
  $stderr.flush
1296
+ rescue ::StandardError
1297
+ # Swallow any errors during logging
1049
1298
  end
1050
1299
  end
1051
1300
  end