ractor-wrapper 0.4.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.
@@ -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,6 +172,9 @@ 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
175
180
  ##
@@ -178,7 +183,10 @@ class Ractor
178
183
  class Error < ::Ractor::Error; end
179
184
 
180
185
  ##
181
- # Raised when a {Ractor::Wrapper} server has crashed unexpectedly.
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.
182
190
  #
183
191
  class CrashedError < Error; end
184
192
 
@@ -252,6 +260,11 @@ class Ractor
252
260
  # thread-safe, a value of 2 or more allows concurrent calls. Leave at
253
261
  # the default of 0 to handle calls sequentially without worker threads.
254
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.
267
+ #
255
268
  # @param value [Integer]
256
269
  #
257
270
  def threads=(value)
@@ -298,6 +311,12 @@ class Ractor
298
311
  # the wrapper sends a message back to the caller to execute the block
299
312
  # in its original context. This means the block will have access to its
300
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.
301
320
  # * `:wrapped` - Blocks are executed directly in the wrapped object's
302
321
  # context. This does not require any communication, but it means the
303
322
  # block is removed from the caller's environment and does not have
@@ -505,7 +524,10 @@ class Ractor
505
524
  # also be set via the configuration block. Defaults to the object_id.
506
525
  # @param threads [Integer] The number of worker threads to run.
507
526
  # Defaults to 0, which causes the wrapper to run sequentially without
508
- # spawning workers. Can also be set via the configuration block.
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.
509
531
  # @param arguments [:move,:copy] How to communicate method arguments by
510
532
  # default. If not specified, defaults to `:copy`.
511
533
  # @param results [:move,:copy,:void] How to communicate method return
@@ -641,7 +663,7 @@ class Ractor
641
663
  loop do
642
664
  reply_message = reply_port.receive
643
665
  case reply_message
644
- when YieldMessage
666
+ when FiberYieldMessage, BlockingYieldMessage
645
667
  handle_yield(reply_message, transaction, settings, method_name, &)
646
668
  when ReturnMessage
647
669
  maybe_log("Received result", method_name: method_name, transaction: transaction)
@@ -657,7 +679,9 @@ class Ractor
657
679
 
658
680
  ##
659
681
  # Request that the wrapper stop. All currently running calls will complete
660
- # 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}.
661
685
  #
662
686
  # This method is idempotent and can be called multiple times (even from
663
687
  # different ractors).
@@ -776,9 +800,39 @@ class Ractor
776
800
 
777
801
  ##
778
802
  # @private
779
- # 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.
780
817
  #
781
- 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)
782
836
 
783
837
  private
784
838
 
@@ -843,6 +897,11 @@ class Ractor
843
897
 
844
898
  ##
845
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.
846
905
  #
847
906
  def handle_yield(message, transaction, settings, method_name)
848
907
  maybe_log("Yielding to block", method_name: method_name, transaction: transaction)
@@ -850,14 +909,14 @@ class Ractor
850
909
  block_result = yield(*message.args, **message.kwargs)
851
910
  block_result = nil if settings.block_results == :void
852
911
  maybe_log("Sending block result", method_name: method_name, transaction: transaction)
853
- message.reply_port.send(ReturnMessage.new(block_result), move: settings.block_results == :move)
912
+ send_block_result(message, block_result, settings)
854
913
  rescue ::Exception => e # rubocop:disable Lint/RescueException
855
914
  maybe_log("Sending block exception", method_name: method_name, transaction: transaction)
856
915
  begin
857
- message.reply_port.send(ExceptionMessage.new(e))
916
+ send_block_exception(message, e)
858
917
  rescue ::StandardError
859
918
  begin
860
- message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect)))
919
+ send_block_exception(message, ::StandardError.new(e.inspect))
861
920
  rescue ::StandardError
862
921
  maybe_log("Failure to send block reply", method_name: method_name, transaction: transaction)
863
922
  end
@@ -865,6 +924,33 @@ class Ractor
865
924
  end
866
925
  end
867
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
+
868
954
  ##
869
955
  # Prints out a log message
870
956
  #
@@ -891,6 +977,168 @@ class Ractor
891
977
  # * Either sequentially or concurrently using worker threads.
892
978
  #
893
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
+
894
1142
  ##
895
1143
  # @private
896
1144
  # Create and run a server hosted in the current Ractor
@@ -920,6 +1168,13 @@ class Ractor
920
1168
  @enable_logging = enable_logging
921
1169
  @threads_requested = threads.positive? ? threads : false
922
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 = {}
923
1178
  end
924
1179
 
925
1180
  ##
@@ -956,15 +1211,20 @@ class Ractor
956
1211
  end
957
1212
 
958
1213
  ##
959
- # Start the worker threads. Each thread picks up methods to run from a
960
- # 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.
961
1218
  #
962
1219
  def start_workers
963
1220
  maybe_log("Spawning #{@threads_requested} worker threads")
964
- @queue = ::Queue.new
1221
+ @dispatcher = Dispatcher.new(@threads_requested)
965
1222
  @active_workers = {}
966
- (1..@threads_requested).each do |worker_num|
967
- @active_workers[worker_num] = ::Thread.new { worker_thread(worker_num) }
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
968
1228
  end
969
1229
  end
970
1230
 
@@ -972,8 +1232,17 @@ class Ractor
972
1232
  # This is the main loop, listening on the inbox and handling messages for
973
1233
  # normal operation:
974
1234
  #
975
- # * If it receives a CallMessage, it either runs the method (when in
976
- # 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.
977
1246
  # * If it receives a StopMessage, it exits the main loop and proceeds
978
1247
  # to the termination logic.
979
1248
  # * If it receives a JoinMessage, it adds it to the list of join ports
@@ -988,36 +1257,128 @@ class Ractor
988
1257
  maybe_log("Waiting for message in running phase")
989
1258
  message = @port.receive
990
1259
  case message
991
- when CallMessage
992
- maybe_log("Received CallMessage", call_message: message)
993
- if @threads_requested
994
- @queue.enq(message)
995
- else
996
- handle_method(message)
997
- 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
998
1263
  when WorkerStoppedMessage
999
1264
  maybe_log("Received unexpected WorkerStoppedMessage")
1000
1265
  @active_workers.delete(message.worker_num) if @threads_requested
1001
1266
  break
1002
1267
  when StopMessage
1003
1268
  maybe_log("Received stop")
1269
+ drain_pending_fibers unless @threads_requested
1004
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")
1005
1326
  when JoinMessage
1006
1327
  maybe_log("Received and queueing join request")
1007
1328
  @join_requests << message.reply_port
1329
+ else
1330
+ maybe_log("Unexpected message when draining pending fibers: #{message.class.name}")
1008
1331
  end
1009
1332
  end
1010
1333
  end
1011
1334
 
1012
1335
  ##
1013
- # This signals workers to stop by closing the queue, and then waits for
1014
- # all workers to report in that they have stopped. It is called only if
1015
- # 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.
1016
1374
  #
1017
1375
  # Responds to messages to indicate the wrapper is stopping and no longer
1018
1376
  # accepting new method requests:
1019
1377
  #
1020
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.
1021
1382
  # * If it receives a StopMessage, it does nothing (i.e. the stop
1022
1383
  # operation is idempotent).
1023
1384
  # * If it receives a JoinMessage, it adds it to the list of join ports
@@ -1031,13 +1392,16 @@ class Ractor
1031
1392
  # stopped.
1032
1393
  #
1033
1394
  def stop_workers
1034
- @queue.close
1395
+ drained = @dispatcher.close
1396
+ drained.each { |message| refuse_method(message) }
1035
1397
  until @active_workers.empty?
1036
1398
  maybe_log("Waiting for message in stopping phase")
1037
1399
  message = @port.receive
1038
1400
  case message
1039
1401
  when CallMessage
1040
1402
  refuse_method(message)
1403
+ when FiberReturnMessage, FiberExceptionMessage
1404
+ dispatch_fiber_resume(message)
1041
1405
  when WorkerStoppedMessage
1042
1406
  maybe_log("Acknowledged WorkerStoppedMessage: #{message.worker_num}")
1043
1407
  @active_workers.delete(message.worker_num)
@@ -1091,9 +1455,10 @@ class Ractor
1091
1455
  def crash_cleanup
1092
1456
  maybe_log("Running crash cleanup after: #{@crash_exception.message} (#{@crash_exception.class})")
1093
1457
  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
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
1097
1462
  drain_inbox_after_crash(@port, error)
1098
1463
  # `@active_workers` should not be nil in threaded mode, but we're
1099
1464
  # checking anyway just in case a crash happened during setup
@@ -1104,22 +1469,38 @@ class Ractor
1104
1469
  end
1105
1470
 
1106
1471
  ##
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.
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`.
1109
1477
  #
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
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)
1120
1501
  end
1121
1502
  rescue ::Exception => e # rubocop:disable Lint/RescueException
1122
- maybe_log("Suppressed exception during drain_queue_after_crash: " \
1503
+ maybe_log("Suppressed exception during drain_dispatcher_after_crash: " \
1123
1504
  "#{e.message} (#{e.class})")
1124
1505
  end
1125
1506
 
@@ -1149,7 +1530,7 @@ class Ractor
1149
1530
  end
1150
1531
  when JoinMessage
1151
1532
  send_join_reply(message.reply_port)
1152
- when WorkerStoppedMessage, StopMessage
1533
+ when WorkerStoppedMessage, StopMessage, FiberReturnMessage, FiberExceptionMessage
1153
1534
  # Ignore
1154
1535
  end
1155
1536
  end
@@ -1176,16 +1557,60 @@ class Ractor
1176
1557
  # is closed, the worker will send an acknowledgement message and then
1177
1558
  # terminate.
1178
1559
  #
1179
- def worker_thread(worker_num)
1560
+ def worker_loop(worker_num)
1180
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
1181
1582
  loop do
1182
- maybe_log("Waiting for job", worker_num: worker_num)
1183
- message = @queue.deq
1184
- break if message.nil?
1185
- 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?
1186
1592
  end
1187
- 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)
1188
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
1189
1614
  begin
1190
1615
  @port.send(WorkerStoppedMessage.new(worker_num))
1191
1616
  rescue ::Ractor::ClosedError
@@ -1193,6 +1618,40 @@ class Ractor
1193
1618
  end
1194
1619
  end
1195
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
+
1196
1655
  ##
1197
1656
  # This is called to handle a method call request.
1198
1657
  # It calls the method on the wrapped object, and then sends back a
@@ -1233,28 +1692,77 @@ class Ractor
1233
1692
  # with the block arguments, to run the block in the caller's
1234
1693
  # environment
1235
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
+ #
1236
1707
  def make_block(message)
1237
1708
  return message.block_arg unless message.block_arg == :send_block_message
1709
+ expected_fiber = ::Fiber.current
1238
1710
  proc do |*args, **kwargs|
1239
- reply_port = ::Ractor::Port.new
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
1249
- case reply_message
1250
- when ExceptionMessage
1251
- raise reply_message.exception
1252
- when ReturnMessage
1253
- 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)
1254
1717
  end
1255
1718
  end
1256
1719
  end
1257
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
+
1258
1766
  ##
1259
1767
  # This is called from the main Ractor thread to report to a caller that
1260
1768
  # the wrapper cannot handle a requested method call, likely because the
@@ -1282,12 +1790,14 @@ class Ractor
1282
1790
  ##
1283
1791
  # Print out a log message
1284
1792
  #
1285
- 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)
1286
1795
  return unless @enable_logging
1287
1796
  transaction ||= call_message&.transaction
1288
1797
  method_name ||= call_message&.method_name
1289
1798
  metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper:#{@name}"]
1290
1799
  metadata << "Worker:#{worker_num}" if worker_num
1800
+ metadata << "Fiber:#{fiber_id}" if fiber_id
1291
1801
  metadata << "Transaction:#{transaction}" if transaction
1292
1802
  metadata << "Method:#{method_name}" if method_name
1293
1803
  metadata = metadata.join(" ")