ractor_shepherd 0.1.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.
@@ -0,0 +1,506 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RactorShepherd
4
+ module Runtime
5
+ # Everything that runs inside a supervisor's Ractor.
6
+ #
7
+ # Whether to restart, and which children to stop, is decided in Core.
8
+ # This class only carries out the resulting plan.
9
+ #
10
+ # @api private
11
+ class SupervisorServer
12
+ # Called from the caller's Ractor: creates the supervisor Ractor and waits for it to boot.
13
+ #
14
+ # @return [SupervisorRef]
15
+ def self.boot(spec, name:, event_port: nil, boot_timeout: 30)
16
+ path = name.to_s.freeze
17
+ boot_port = Ractor::Port.new
18
+ timer = nil
19
+ ractor = Ractor.new(spec, boot_port, event_port, path, name: "shepherd:#{path}") do |sp, bp, ep, pa|
20
+ RactorShepherd::Runtime::SupervisorServer.run_root(sp, bp, ep, pa)
21
+ end
22
+ # monitor before reading, so a crash during boot is never missed;
23
+ # a Ractor that has already finished notifies immediately.
24
+ ractor.monitor(boot_port)
25
+ timer = Timer.for_current_ractor.after(boot_timeout, boot_port, Protocol.timeout(0)) unless
26
+ boot_timeout == :infinity
27
+
28
+ case boot_port.receive
29
+ in [Protocol::CHILD_READY, _id, _control_port, ref] then ref
30
+ in [Protocol::TIMEOUT, _] then raise StartError, "#{path} did not finish booting within #{boot_timeout}s"
31
+ else raise_boot_error(ractor, path)
32
+ end
33
+ ensure
34
+ timer&.cancel
35
+ # unmonitor is unusable (see Runtime::Call). Notifications to a closed port are dropped.
36
+ boot_port.close
37
+ end
38
+
39
+ # The supervisor Ractor aborted. Surface the exception it raised.
40
+ def self.raise_boot_error(ractor, path)
41
+ ractor.value
42
+ raise StartError, "#{path} exited during boot"
43
+ rescue Ractor::RemoteError => e
44
+ inner = e.cause
45
+ # Without an explicit cause:, Ruby would set the RemoteError as the cause
46
+ # and the chain would loop back on itself.
47
+ raise inner, cause: inner.cause if inner.is_a?(StartError)
48
+
49
+ raise StartError, "#{path} failed to boot: #{inner.class}: #{inner.message}", cause: inner
50
+ end
51
+ private_class_method :raise_boot_error
52
+
53
+ # Entry point for a root supervisor's Ractor.
54
+ def self.run_root(spec, boot_port, event_port, path)
55
+ # Report crashes through events instead of dumping a backtrace on stderr.
56
+ Thread.current.report_on_exception = false
57
+ new(spec, name: path, path: path, event_port: event_port).run(boot_port)
58
+ end
59
+
60
+ # Entry point when this supervisor is itself somebody's child.
61
+ def self.run_as_child(child_spec, start_port, _parent_ref, event_port, path)
62
+ Thread.current.report_on_exception = false
63
+ new(child_spec.start, name: child_spec.id, path: path, event_port: event_port).run(start_port)
64
+ end
65
+
66
+ def initialize(spec, name:, path:, event_port:)
67
+ @spec = spec
68
+ @name = name
69
+ @path = path
70
+ @event_port = event_port
71
+ @children = {}
72
+ @monitor_index = {}.compare_by_identity # Ractor.select returns the very port we passed in
73
+ @state = :booting
74
+ @generation = 0
75
+ @next_auto_id = 0
76
+ @intensity = Core::RestartIntensity.new(max_restarts: spec.max_restarts, max_seconds: spec.max_seconds)
77
+ @timer = Timer.for_current_ractor
78
+ end
79
+
80
+ attr_reader :path
81
+
82
+ # Boot, then run the watch loop. Returns once the supervisor has stopped.
83
+ def run(ready_port)
84
+ @control_port = Ractor::Port.new
85
+ @timer_port = Ractor::Port.new
86
+ @self_ref = Ractor.make_shareable(
87
+ SupervisorRef.new(name: @name, path: @path, ractor: Ractor.current, control_port: @control_port)
88
+ )
89
+
90
+ boot_children
91
+ @state = :running
92
+ emit(:supervisor_started, children: @children.keys)
93
+ ready_port << Protocol.child_ready(@name, @control_port, @self_ref)
94
+
95
+ main_loop
96
+ @state
97
+ end
98
+
99
+ # --- starting ---------------------------------------------------------
100
+
101
+ def boot_children
102
+ @spec.children.each do |child_spec|
103
+ child = add_child(child_spec)
104
+ result = start_child_sync(child)
105
+ next if result == :ok
106
+
107
+ rollback_boot!(child, result)
108
+ end
109
+ end
110
+
111
+ # The initial boot failed: stop whatever already started, in reverse order, then raise.
112
+ def rollback_boot!(child, result)
113
+ running_children.reverse_each do |other|
114
+ terminate_child_sync(other, :shutdown, requested_by: :shutdown, on_unresponsive: :abandon)
115
+ end
116
+ reason = result.is_a?(Array) ? result[1] : nil
117
+ message = "#{@path}: child #{child.id.inspect} failed to start"
118
+ raise StartError, message, cause: (reason if reason.is_a?(Exception))
119
+ end
120
+
121
+ # Start a child Ractor and wait synchronously for its child_ready.
122
+ #
123
+ # @return [Symbol, Array] :ok | [:failed, reason] | [:unresponsive]
124
+ def start_child_sync(child)
125
+ spec = child.spec
126
+ start_port = Ractor::Port.new
127
+ timer = nil
128
+ child.status = :starting
129
+ child_path = "#{@path}/#{spec.id}".freeze
130
+ ractor = Ractor.new(spec, start_port, @self_ref, @event_port, child_path,
131
+ name: "shepherd:#{child_path}") do |sp, stp, pr, ep, pa|
132
+ RactorShepherd::Runtime::ChildRunner.run(sp, stp, pr, ep, pa)
133
+ end
134
+ ractor.monitor(start_port)
135
+ timer = @timer.after(spec.start_timeout, start_port, Protocol.timeout(0)) unless
136
+ spec.start_timeout == :infinity
137
+
138
+ await_start(child, ractor, start_port)
139
+ ensure
140
+ timer&.cancel
141
+ # unmonitor is unusable (see Runtime::Call). Notifications to a closed port are dropped.
142
+ start_port.close
143
+ end
144
+
145
+ def await_start(child, ractor, start_port)
146
+ case start_port.receive
147
+ in [Protocol::CHILD_READY, _id, stop_port, ref]
148
+ activate(child, ractor, stop_port, ref)
149
+ :ok
150
+ in [Protocol::TIMEOUT, _]
151
+ child.status = :unresponsive
152
+ emit(:child_unresponsive, child: child.id, phase: :start,
153
+ timeout: child.spec.start_timeout, action: @spec.on_unresponsive)
154
+ [:unresponsive]
155
+ else
156
+ child.status = :start_failed
157
+ reason = exit_reason(ractor)
158
+ emit(:child_start_failed, child: child.id, **Core::Event.error_info(reason))
159
+ [:failed, reason]
160
+ end
161
+ end
162
+
163
+ def activate(child, ractor, stop_port, ref)
164
+ child.ractor = ractor
165
+ child.stop_port = stop_port
166
+ child.ref = ref
167
+ child.status = :running
168
+ child.monitor_port = Ractor::Port.new
169
+ # Even if it has already finished, the notification arrives at once, so ignore the result.
170
+ ractor.monitor(child.monitor_port)
171
+ @monitor_index[child.monitor_port] = child
172
+ child.backoff.record_start(now)
173
+ emit(:child_started, child: child.id, ractor_name: ractor.name, attempt: child.backoff.attempt)
174
+ end
175
+
176
+ # --- watch loop -------------------------------------------------------
177
+
178
+ def main_loop
179
+ while @state == :running # `loop do` is forbidden here: Ractor::ClosedError < StopIteration
180
+ port, message = Ractor.select(@control_port, @timer_port, *@monitor_index.keys)
181
+ if port.equal?(@control_port)
182
+ handle_control(message)
183
+ elsif port.equal?(@timer_port)
184
+ handle_timer(message)
185
+ else
186
+ child = @monitor_index.delete(port)
187
+ port.close
188
+ handle_exit(child, Compat.monitor_status(message)) if child
189
+ end
190
+ end
191
+ end
192
+
193
+ def handle_control(message)
194
+ case message
195
+ in [Protocol::CALL, reply_port, request] then reply(reply_port, request)
196
+ in [Protocol::SHUTDOWN, reason] then shutdown(reason)
197
+ else raise ProtocolError, "unexpected control message: #{message.inspect}"
198
+ end
199
+ end
200
+
201
+ def handle_timer(message)
202
+ case message
203
+ in [Protocol::RESTART_DUE, generation, ids]
204
+ # Ignore a booking made before the last shutdown or escalation.
205
+ start_children(ids) if generation == @generation
206
+ else raise ProtocolError, "unexpected timer message: #{message.inspect}"
207
+ end
208
+ end
209
+
210
+ # Answer user input errors and carry on. Anything else takes the supervisor down (let it crash).
211
+ def reply(reply_port, request)
212
+ result = begin
213
+ [:ok, handle_request(request)]
214
+ rescue Error => e
215
+ [:error, e.class.name, e.message]
216
+ end
217
+ reply_port << Protocol.reply(result)
218
+ rescue Ractor::ClosedError
219
+ # The caller timed out and went away. Drop the answer.
220
+ nil
221
+ end
222
+
223
+ def handle_request(request)
224
+ case request
225
+ in [:which_children] then @children.values.map(&:to_info)
226
+ in [:count_children] then count_children
227
+ in [:whereis, id] then find_child(id).then { |c| c.running? ? c.ref : nil }
228
+ in [:start_child, spec] then add_and_start(spec)
229
+ in [:terminate_child, id] then api_terminate_child(id)
230
+ in [:restart_child, id] then api_restart_child(id)
231
+ in [:delete_child, id] then api_delete_child(id)
232
+ else raise ProtocolError, "unexpected request: #{request.inspect}"
233
+ end
234
+ end
235
+
236
+ def count_children
237
+ values = @children.values
238
+ { specs: values.size,
239
+ active: values.count(&:running?),
240
+ workers: values.count { |c| c.type == :worker },
241
+ supervisors: values.count { |c| c.type == :supervisor } }
242
+ end
243
+
244
+ # --- noticing an exit -------------------------------------------------
245
+
246
+ def handle_exit(child, status)
247
+ reason = status == :aborted ? exit_reason(child.ractor) : :normal
248
+ emit(:child_exited, child: child.id, status: status,
249
+ reason: Core::Event.reason_kind(reason), **Core::Event.error_info(reason))
250
+ child.detach
251
+ child.status = :exited
252
+
253
+ case Core::RestartPolicy.decide(restart: child.spec.restart, status: status, dynamic: dynamic?)
254
+ when :keep_terminated then child.status = :terminated
255
+ when :remove then remove_child(child)
256
+ when :restart then restart_after_failure(child)
257
+ end
258
+ end
259
+
260
+ # Dig the reason out of a Ractor that aborted.
261
+ def exit_reason(ractor)
262
+ ractor.value
263
+ :unknown
264
+ rescue Ractor::RemoteError => e
265
+ e.cause
266
+ rescue Ractor::Error
267
+ # Another Ractor took the value first, so the reason is lost. Restart anyway.
268
+ :unknown
269
+ end
270
+
271
+ # --- restarting -------------------------------------------------------
272
+
273
+ def restart_after_failure(child)
274
+ return escalate!(MaxRestartsExceeded.new(intensity_message)) if @intensity.record(now) == :exceeded
275
+
276
+ child.restart_count += 1
277
+ child.backoff.record_failure(now)
278
+ plan = Core::StrategyPlanner.plan(children: views, failed_id: child.id, strategy: @spec.strategy)
279
+ apply_plan(plan, delay: child.backoff.delay)
280
+ end
281
+
282
+ def apply_plan(plan, delay: 0)
283
+ plan.terminate.each do |id|
284
+ terminate_child_sync(@children.fetch(id), :shutdown, requested_by: :strategy)
285
+ end
286
+ plan.remove.each { |id| remove_child(@children.fetch(id)) }
287
+ schedule_or_start(plan.start, delay)
288
+ end
289
+
290
+ def schedule_or_start(ids, delay)
291
+ ids = ids.select { |id| @children.key?(id) }
292
+ return if ids.empty?
293
+ return start_children(ids) if delay.nil? || delay <= 0
294
+
295
+ ids.each do |id|
296
+ child = @children.fetch(id)
297
+ child.status = :restart_scheduled
298
+ emit(:child_restart_scheduled, child: id, delay: delay, attempt: child.backoff.attempt)
299
+ end
300
+ @timer.after(delay, @timer_port, Protocol.restart_due(@generation, ids.freeze))
301
+ end
302
+
303
+ # Start in order. A failure costs one restart from the budget, and the rest are retried in order.
304
+ def start_children(ids)
305
+ ids.each_with_index do |id, index|
306
+ child = @children[id]
307
+ next unless child
308
+ next if start_child_sync(child) == :ok
309
+
310
+ return escalate!(MaxRestartsExceeded.new(intensity_message)) if @intensity.record(now) == :exceeded
311
+
312
+ child.backoff.record_failure(now)
313
+ return schedule_or_start(ids[index..], child.backoff.delay)
314
+ end
315
+ end
316
+
317
+ # --- stopping ---------------------------------------------------------
318
+
319
+ def shutdown(reason)
320
+ @state = :stopping
321
+ @generation += 1
322
+ emit(:supervisor_stopping, reason: reason)
323
+ running_or_scheduled.reverse_each { |child| terminate_child_sync(child, reason, requested_by: :shutdown) }
324
+ emit(:supervisor_stopped, reason: reason)
325
+ @state = :stopped
326
+ end
327
+
328
+ # Cooperative shutdown, one child at a time, in reverse start order.
329
+ def terminate_child_sync(child, reason, requested_by:, on_unresponsive: @spec.on_unresponsive)
330
+ if child.status == :restart_scheduled
331
+ child.status = :terminated
332
+ emit(:child_terminated, child: child.id, requested_by: requested_by)
333
+ return
334
+ end
335
+ return unless child.running?
336
+
337
+ child.status = :stopping
338
+ request_stop(child, reason)
339
+ await_stop(child, requested_by, on_unresponsive)
340
+ end
341
+
342
+ def request_stop(child, reason)
343
+ child.stop_port << Protocol.shutdown(reason)
344
+ rescue Ractor::ClosedError
345
+ # It has already finished; its monitor port is holding the notification, so just wait for it.
346
+ nil
347
+ end
348
+
349
+ def await_stop(child, requested_by, on_unresponsive)
350
+ op_port = Ractor::Port.new
351
+ timer = nil
352
+ timer = @timer.after(child.spec.shutdown_timeout, op_port, Protocol.timeout(0)) unless
353
+ child.spec.shutdown_timeout == :infinity
354
+
355
+ port, = Ractor.select(child.monitor_port, op_port)
356
+ if port.equal?(op_port)
357
+ handle_unresponsive(child, on_unresponsive)
358
+ else
359
+ forget_monitor(child)
360
+ child.status = :terminated
361
+ child.detach
362
+ emit(:child_terminated, child: child.id, requested_by: requested_by)
363
+ end
364
+ ensure
365
+ timer&.cancel
366
+ op_port.close
367
+ end
368
+
369
+ def handle_unresponsive(child, on_unresponsive)
370
+ emit(:child_unresponsive, child: child.id, phase: :stop,
371
+ timeout: child.spec.shutdown_timeout, action: on_unresponsive)
372
+ forget_monitor(child)
373
+ child.status = :unresponsive
374
+ return remove_child(child) if on_unresponsive == :abandon
375
+
376
+ escalate!(ChildUnresponsive.new("#{@path}/#{child.id} did not stop within " \
377
+ "#{child.spec.shutdown_timeout}s"))
378
+ end
379
+
380
+ # Give up: crash this supervisor so that its parent decides what happens next.
381
+ def escalate!(error)
382
+ @state = :crashed
383
+ @generation += 1
384
+ if error.is_a?(MaxRestartsExceeded)
385
+ emit(:max_restarts_exceeded, restarts: @intensity.count, max_restarts: @spec.max_restarts,
386
+ max_seconds: @spec.max_seconds)
387
+ end
388
+ running_children.reverse_each do |child|
389
+ # Always abandon here, so that escalation cannot escalate again.
390
+ terminate_child_sync(child, :shutdown, requested_by: :shutdown, on_unresponsive: :abandon)
391
+ end
392
+ emit(:supervisor_stopped, reason: error.class.name)
393
+ raise error
394
+ end
395
+
396
+ # --- adding and removing children -------------------------------------
397
+
398
+ def add_child(spec)
399
+ @children[spec.id] = ChildState.new(spec, reset_after: @spec.max_seconds)
400
+ end
401
+
402
+ def remove_child(child)
403
+ child.status = :removed
404
+ @children.delete(child.id)
405
+ end
406
+
407
+ def forget_monitor(child)
408
+ return unless child.monitor_port
409
+
410
+ @monitor_index.delete(child.monitor_port)
411
+ child.monitor_port.close
412
+ end
413
+
414
+ def find_child(id)
415
+ @children[id] or raise ChildNotFound, "#{@path} has no child #{id.inspect}"
416
+ end
417
+
418
+ # Add a spec and start it. Dynamic supervisors can number children themselves.
419
+ def add_and_start(spec)
420
+ raise InvalidSpec, "start_child expects a ChildSpec (got #{spec.class})" unless spec.is_a?(ChildSpec)
421
+
422
+ spec = assign_id(spec)
423
+ raise InvalidSpec, "duplicated child id: #{spec.id.inspect}" if @children.key?(spec.id) # E13
424
+ if dynamic? && @spec.max_children && @children.size >= @spec.max_children
425
+ raise MaxChildrenReached, "#{@path} already has #{@children.size} children"
426
+ end
427
+
428
+ child = add_child(spec)
429
+ result = start_child_sync(child)
430
+ if result != :ok
431
+ remove_child(child)
432
+ raise StartError, "#{@path}: child #{spec.id.inspect} failed to start#{failure_detail(result)}"
433
+ end
434
+ spec.id
435
+ end
436
+
437
+ def assign_id(spec)
438
+ return spec unless spec.id.nil?
439
+ raise InvalidSpec, "only dynamic supervisors accept a nil id" unless dynamic?
440
+
441
+ @next_auto_id += 1
442
+ Ractor.make_shareable(spec.with(id: @next_auto_id))
443
+ end
444
+
445
+ # Cooperative stop. A static supervisor keeps the spec; a dynamic one drops it.
446
+ def api_terminate_child(id)
447
+ child = find_child(id)
448
+ terminate_child_sync(child, :shutdown, requested_by: :api)
449
+ child.status = :terminated
450
+ remove_child(child) if dynamic?
451
+ :ok
452
+ end
453
+
454
+ def api_restart_child(id)
455
+ raise InvalidOperation, "dynamic supervisors do not support restart_child" if dynamic?
456
+
457
+ child = find_child(id)
458
+ raise InvalidOperation, "#{id.inspect} is #{child.status}, not terminated" unless child.status == :terminated
459
+
460
+ result = start_child_sync(child)
461
+ raise StartError, "#{@path}: child #{id.inspect} failed to restart#{failure_detail(result)}" if result != :ok
462
+
463
+ :ok
464
+ end
465
+
466
+ def api_delete_child(id)
467
+ raise InvalidOperation, "dynamic supervisors do not support delete_child" if dynamic?
468
+
469
+ child = find_child(id)
470
+ if child.running? || child.status == :restart_scheduled
471
+ raise InvalidOperation, "#{id.inspect} is #{child.status}; terminate it first"
472
+ end
473
+
474
+ remove_child(child)
475
+ :ok
476
+ end
477
+
478
+ def failure_detail(result)
479
+ reason = result.is_a?(Array) ? result[1] : nil
480
+ reason.is_a?(Exception) ? " (#{reason.class}: #{reason.message})" : ""
481
+ end
482
+
483
+ # --- odds and ends ----------------------------------------------------
484
+
485
+ def dynamic? = @spec.kind == :dynamic
486
+ def views = @children.values.map(&:to_view)
487
+ def running_children = @children.values.select(&:running?)
488
+ def running_or_scheduled = @children.values.select { |c| c.running? || c.status == :restart_scheduled }
489
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
490
+
491
+ def intensity_message
492
+ "#{@path} exceeded #{@spec.max_restarts} restarts in #{@spec.max_seconds}s"
493
+ end
494
+
495
+ def emit(type, **data)
496
+ return unless @event_port
497
+
498
+ @event_port << Core::Event.build(type, supervisor: @path, at: Process.clock_gettime(Process::CLOCK_REALTIME),
499
+ **data)
500
+ rescue Ractor::ClosedError
501
+ # Only the subscriber is gone. Losing observability must not take the supervisor down.
502
+ nil
503
+ end
504
+ end
505
+ end
506
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RactorShepherd
4
+ module Runtime
5
+ # Sends a message to a port after a delay.
6
+ #
7
+ # Ruby 4.0 has no `Ractor::Port#receive(timeout:)`, so every timed wait in
8
+ # this gem is built from a Timer plus `Ractor.select`.
9
+ #
10
+ # ponytail: one thread per timer. If timers ever run into the thousands,
11
+ # swap the implementation for a single thread with a time ordered queue;
12
+ # the interface is already shaped for that.
13
+ #
14
+ # @api private
15
+ class Timer
16
+ RACTOR_KEY = :"ractor_shepherd.timer"
17
+
18
+ # A handle on one pending message.
19
+ class Handle
20
+ def initialize(thread)
21
+ @thread = thread
22
+ end
23
+
24
+ # Cancel before it fires. Calling this afterwards is harmless.
25
+ def cancel
26
+ @thread.kill
27
+ nil
28
+ end
29
+
30
+ def pending? = @thread.alive?
31
+ end
32
+
33
+ # One timer per Ractor, kept in Ractor local storage.
34
+ def self.for_current_ractor
35
+ Ractor[RACTOR_KEY] ||= new
36
+ end
37
+
38
+ # @param seconds [Numeric] the delay
39
+ # @param port [Ractor::Port, Ractor] anything that answers `<<`
40
+ # @param message [Object] what to send; must be shareable
41
+ # @return [Handle]
42
+ def after(seconds, port, message)
43
+ Handle.new(Thread.new(seconds, port, message) do |sec, pt, msg|
44
+ Kernel.sleep(sec)
45
+ pt << msg
46
+ rescue Ractor::ClosedError
47
+ # Whoever was waiting has already gone. Nothing left to do.
48
+ nil
49
+ end)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RactorShepherd
4
+ # A GenServer style worker: `run` is already written for you, and you only
5
+ # fill in the message handlers.
6
+ #
7
+ # class Counter
8
+ # include RactorShepherd::Server
9
+ #
10
+ # def initialize(start = 0) = @count = start
11
+ #
12
+ # def handle_call(msg)
13
+ # case msg
14
+ # in :get then @count
15
+ # end
16
+ # end
17
+ #
18
+ # def handle_cast(msg)
19
+ # case msg
20
+ # in [:add, n] then @count += n
21
+ # end
22
+ # end
23
+ # end
24
+ module Server
25
+ include Worker
26
+
27
+ # Receive loop. It ends when `ctx.receive` raises ShutdownSignal.
28
+ def run(ctx)
29
+ @context = ctx
30
+ while true # `loop do` is forbidden here: Ractor::ClosedError < StopIteration
31
+ message = ctx.receive
32
+ case message
33
+ in [Runtime::Protocol::CALL, reply_port, request] then respond(reply_port, request)
34
+ else handle_cast(message)
35
+ end
36
+ end
37
+ end
38
+
39
+ # The current {RactorShepherd::Runtime::Context}.
40
+ def context = @context
41
+
42
+ # The return value becomes the reply. An exception kills this child.
43
+ def handle_call(message)
44
+ raise NotImplementedError, "#{self.class} must implement #handle_call(message)"
45
+ end
46
+
47
+ # The return value is ignored. Left unimplemented, it emits :unhandled_message and moves on.
48
+ def handle_cast(message)
49
+ context&.emit_system(:unhandled_message, message_class: message.class.name)
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def respond(reply_port, request)
56
+ reply_port << Runtime::Protocol.reply(handle_call(request))
57
+ rescue Ractor::ClosedError
58
+ # The caller already timed out and closed its reply port. Drop the answer.
59
+ nil
60
+ end
61
+ end
62
+ end