julewire-ractor 1.0.0 → 1.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.
@@ -6,19 +6,51 @@ require "concurrent/atomic/atomic_reference"
6
6
  module Julewire
7
7
  module Ractor
8
8
  class Destination # rubocop:disable Metrics/ClassLength -- Owns parent queue, worker lifecycle, and health.
9
+ class QueueSlots
10
+ def initialize(max_queue:)
11
+ @max_queue = max_queue
12
+ @in_flight = Concurrent::AtomicFixnum.new
13
+ end
14
+
15
+ def value = @in_flight.value
16
+
17
+ def reserve
18
+ return true unless @max_queue.positive?
19
+
20
+ result = {}
21
+ @in_flight.update do |current|
22
+ result[:reserved] = current < @max_queue
23
+ result.fetch(:reserved) ? current + 1 : current
24
+ end
25
+ result.fetch(:reserved)
26
+ end
27
+
28
+ def release
29
+ return false unless @max_queue.positive?
30
+
31
+ result = {}
32
+ @in_flight.update do |current|
33
+ result[:underflow] = current.zero?
34
+ current.positive? ? current - 1 : current
35
+ end
36
+ result.fetch(:underflow)
37
+ end
38
+ end
39
+
9
40
  COUNTER_KEYS = %i[
10
41
  closed_dropped
11
42
  queue_full_dropped
12
43
  queued
13
44
  received
14
45
  send_error
46
+ slot_underflow_ignored
15
47
  worker_accepted
16
48
  worker_dropped
17
49
  ].freeze
18
50
  DEFAULT_MAX_QUEUE = 1024
19
51
  DEFAULT_REQUEST_TIMEOUT = 1
20
- TIMEOUT_THREAD_NAME = "julewire-ractor-destination-timeout"
21
- private_constant :COUNTER_KEYS
52
+ WORKER_STOP_MESSAGE = { command: :close_worker }.freeze
53
+ private_constant :COUNTER_KEYS, :QueueSlots, :WORKER_STOP_MESSAGE
22
54
 
23
55
  attr_reader :name
24
56
 
@@ -41,6 +73,8 @@ module Julewire
41
73
  Core::Validation.validate_byte_limit!(max_record_bytes, name: :max_record_bytes)
42
74
  Core::Validation.validate_non_negative_integer!(max_queue, name: :max_queue)
43
75
  Core::Validation.validate_timeout!(request_timeout, name: :request_timeout)
76
+ raise ArgumentError, "request_timeout must be a non-negative finite Numeric" if request_timeout.nil?
77
+
44
78
  Core::Validation.validate_callable!(on_drop, name: :on_drop, allow_nil: true)
45
79
  Core::Validation.validate_callable!(on_failure, name: :on_failure, allow_nil: true)
46
80
  @output = output
@@ -50,7 +84,6 @@ module Julewire
50
84
  @request_timeout = request_timeout
51
85
  @on_drop = on_drop
52
86
  @on_failure = on_failure
53
- @scheduler = Core::Scheduling::DeadlineScheduler.new(thread_name: TIMEOUT_THREAD_NAME, idle: :exit)
54
87
  initialize_tracking
55
88
  start_worker
56
89
  end
@@ -58,33 +91,37 @@ module Julewire
58
91
  def emit(record)
59
92
  increment(:received)
60
93
  return drop(:closed_dropped, record) if closed?
61
- return drop(:queue_full_dropped, record) if queue_full?
94
+ return drop(:queue_full_dropped, record) unless @queue_slots.reserve
62
95
 
63
- @port.send({ command: :emit, record: record })
64
- @in_flight.increment
65
- increment(:queued)
96
+ begin
97
+ degradation_marker = @health.degradation_marker
98
+ @port.send({ command: :emit, degradation_marker: degradation_marker, record: record })
99
+ increment(:queued)
100
+ rescue StandardError => e
101
+ release_slot
102
+ record_failure(e, phase: :ractor_send)
103
+ drop(:send_error, record)
104
+ end
66
105
  nil
67
- rescue StandardError => e
68
- record_failure(e, phase: :ractor_send)
69
- drop(:send_error, record)
70
106
  end
71
107
 
72
108
  def flush(timeout: nil)
73
- timeout = @request_timeout if timeout.nil?
74
- request(:flush, timeout: timeout)
109
+ @health.recover_if_successful { request(:flush, timeout: lifecycle_timeout(timeout)) }
75
110
  end
76
111
 
77
112
  def close(timeout: nil)
78
- timeout = @request_timeout if timeout.nil?
113
+ timeout = lifecycle_timeout(timeout)
79
114
  @closed.set(true)
80
- result = request(:close, timeout: timeout)
81
- close_ports
115
+ result = request(:close, timeout: timeout, allow_closed: true)
116
+ close_ports(timeout: timeout)
82
117
  result
83
118
  end
84
119
 
85
120
  def after_fork!
86
- close_ports
87
- @scheduler.after_fork!
121
+ if (@process_id - Process.pid).zero? && !close_ports(timeout: @request_timeout)
122
+ raise Core::Error, "ractor destination worker did not stop within #{@request_timeout} seconds"
123
+ end
124
+
88
125
  initialize_tracking
89
126
  start_worker
90
127
  self
@@ -97,11 +134,17 @@ module Julewire
97
134
 
98
135
  def health
99
136
  worker = request(:health, timeout: @request_timeout)
100
- worker = @worker_health.get unless worker.is_a?(Hash)
101
- @worker_health.set(worker) if worker.is_a?(Hash)
137
+ if worker.equal?(false)
138
+ worker = @worker_health.get
139
+ else
140
+ raise TypeError, "ractor destination health must be a Hash" unless worker.instance_of?(Hash)
141
+
142
+ Core::Integration::Protocol.validate_symbol_keys(worker)
143
+ @worker_health.set(worker)
144
+ end
102
145
 
103
146
  @health.snapshot(
104
- in_flight: @in_flight.value,
147
+ in_flight: @queue_slots.value,
105
148
  max_queue: @max_queue,
106
149
  status: status_for(worker),
107
150
  worker: worker
@@ -116,22 +159,28 @@ module Julewire
116
159
  end
117
160
 
118
161
  def initialize_tracking
119
- @closed = Concurrent::AtomicReference.new(false)
162
+ @process_id = Process.pid
163
+ @scheduler = ReplyTimeoutScheduler.new(timeout_value: false)
164
+ @closed = Concurrent::AtomicReference.new
120
165
  @health = Core::Integration::DestinationHealth.new(counter_keys: COUNTER_KEYS, failure_counter: nil)
121
- @in_flight = Concurrent::AtomicFixnum.new(0)
166
+ @queue_slots = QueueSlots.new(max_queue: @max_queue)
122
167
  @worker_health = Concurrent::AtomicReference.new
123
168
  end
124
169
 
125
170
  def start_worker
126
171
  @ack_port = ::Ractor::Port.new
127
- setup_port = ::Ractor::Port.new
128
- @worker = spawn_worker(setup_port)
129
- @port = receive_worker_port(setup_port)
172
+ PortLifecycle.with_port do |setup_port|
173
+ @worker = spawn_worker(setup_port)
174
+ @port = WorkerHandshake.receive(
175
+ setup_port: setup_port,
176
+ worker: @worker,
177
+ scheduler: @scheduler
178
+ )
179
+ end
130
180
  @ack_thread = start_ack_thread
131
- rescue StandardError => e
132
- raise ArgumentError, "ractor destination collaborators must be ractor-copyable or shareable: #{e.message}"
133
- ensure
134
- PortLifecycle.close(setup_port) if defined?(setup_port) && setup_port
181
+ rescue TypeError, ::Ractor::Error => e
182
+ @port = @worker = @ack_port = @ack_thread = nil
183
+ raise ArgumentError, "ractor destination collaborators must be ractor-copyable or shareable: #{e}"
135
184
  end
136
185
 
137
186
  def spawn_worker(setup_port)
@@ -142,7 +191,7 @@ module Julewire
142
191
  max_record_bytes = @max_record_bytes
143
192
  close_output = @close_output
144
193
 
145
- # :nocov:
194
+ # simplecov:disable
146
195
  ::Ractor.new(setup_port, ack_port, formatter, encoder, output, max_record_bytes, close_output,
147
196
  name: "julewire-ractor-destination") do |worker_port, worker_ack_port, worker_formatter,
148
197
  worker_encoder, worker_output, worker_max_record_bytes,
@@ -159,21 +208,19 @@ module Julewire
159
208
  close_output: worker_close_output
160
209
  )
161
210
  end
162
- # :nocov:
163
- end
164
-
165
- def receive_worker_port(setup_port)
166
- selected, value = ::Ractor.select(setup_port, @worker)
167
- return value if selected.equal?(setup_port) && value.is_a?(::Ractor::Port)
168
-
169
- raise ArgumentError, "ractor destination worker did not start"
211
+ # simplecov:enable
170
212
  end
171
213
 
172
214
  def start_ack_thread
215
+ ack_port = @ack_port
173
216
  thread = Thread.new do
174
217
  loop do
175
- message = @ack_port.receive
176
- break if message.is_a?(Hash) && message[:event] == :closed
218
+ message = ack_port.receive
219
+ Core::Integration::Protocol.validate_symbol_hash(message)
220
+ event = message.fetch(:event)
221
+ unless event == :ack
222
+ raise ArgumentError, "unknown ractor destination acknowledgement event: #{event.inspect}"
223
+ end
177
224
 
178
225
  handle_ack(message)
179
226
  end
@@ -181,65 +228,86 @@ module Julewire
181
228
  record_failure(e, phase: :ack)
182
229
  end
183
230
  thread.name = "julewire-ractor-destination-ack"
184
- thread.report_on_exception = true
185
231
  thread
186
232
  end
187
233
 
188
234
  def handle_ack(message)
189
- return unless message.is_a?(Hash) && message[:event] == :ack
190
-
191
- decrement_in_flight
192
- case message[:status]
235
+ release_slot
236
+ case message.fetch(:status)
193
237
  when :accepted
194
238
  increment(:worker_accepted)
239
+ @health.clear_degradation_if_unchanged(message.fetch(:degradation_marker))
195
240
  when :dropped
196
241
  increment(:worker_dropped)
242
+ else
243
+ raise ArgumentError, "unknown ractor destination acknowledgement status: #{message.fetch(:status).inspect}"
197
244
  end
198
245
  end
199
246
 
200
- def request(command, timeout:)
201
- return false if closed? && command != :health && command != :close
247
+ def request(command, timeout:, allow_closed: false)
248
+ return false if closed? && !allow_closed
202
249
 
203
- reply = ::Ractor::Port.new
204
- @port.send({ command: command, reply: reply })
205
- wait_for_reply(reply, timeout)
250
+ PortLifecycle.with_port do |reply|
251
+ @port.send({ command: command, reply: reply })
252
+ wait_for_reply(reply, timeout, command)
253
+ end
206
254
  rescue StandardError => e
207
255
  record_failure(e, phase: :request, command: command)
208
256
  false
209
- ensure
210
- PortLifecycle.close(reply) if reply
211
257
  end
212
258
 
213
- def wait_for_reply(reply, timeout)
214
- return reply.receive unless timeout
259
+ def wait_for_reply(reply, timeout, command)
260
+ @scheduler.with_timeout(reply, timeout: timeout) do
261
+ selected, value = ::Ractor.select(reply, @worker)
262
+ return value if selected.equal?(reply)
215
263
 
216
- timeout_port = ::Ractor::Port.new
217
- token = @scheduler.schedule(timeout) do
218
- timeout_port.send(:timeout)
219
- rescue StandardError
220
- nil
264
+ raise Core::Error, "ractor destination worker stopped before #{command} replied"
221
265
  end
222
- selected, response = ::Ractor.select(reply, timeout_port)
223
-
224
- selected.equal?(timeout_port) ? false : response
225
- ensure
226
- @scheduler.cancel(token) if defined?(token)
227
- PortLifecycle.close(timeout_port) if defined?(timeout_port) && timeout_port
266
+ rescue ::Ractor::RemoteError
267
+ raise Core::Error, "ractor destination worker stopped before #{command} replied"
228
268
  end
229
269
 
230
- def queue_full?
231
- @max_queue.positive? && @in_flight.value >= @max_queue
270
+ def closed? = @closed.get
271
+
272
+ def close_ports(timeout:)
273
+ worker_stopped = true
274
+ begin
275
+ if @worker
276
+ begin
277
+ @port.send(WORKER_STOP_MESSAGE)
278
+ rescue ::Ractor::ClosedError
279
+ # A stopped worker no longer accepts commands. Still collect it
280
+ # below so an abnormal exit remains observable.
281
+ end
282
+ worker_stopped = wait_for_worker(timeout)
283
+ end
284
+ rescue ::Ractor::RemoteError => e
285
+ record_failure(e, phase: :worker_stop)
286
+ worker_stopped = true
287
+ ensure
288
+ @ack_thread&.kill
289
+ @ack_thread&.join
290
+ PortLifecycle.close(@ack_port)
291
+ @port = @worker = nil if worker_stopped
292
+ @ack_port = nil
293
+ @ack_thread = nil
294
+ end
295
+
296
+ worker_stopped
232
297
  end
233
298
 
234
- def closed? = @closed.get
299
+ def wait_for_worker(timeout)
300
+ PortLifecycle.with_port do |timeout_port|
301
+ @scheduler.with_timeout(timeout_port, timeout: timeout) do
302
+ selected, = ::Ractor.select(@worker, timeout_port)
303
+ selected.equal?(@worker)
304
+ end
305
+ end
306
+ end
235
307
 
236
- def close_ports
237
- @port&.send({ command: :close_worker }) unless @port&.closed?
238
- rescue StandardError
239
- nil
240
- ensure
241
- PortLifecycle.close(@port) if @port
242
- PortLifecycle.close(@ack_port) if @ack_port
308
+ def lifecycle_timeout(timeout)
309
+ Core::Validation.validate_timeout!(timeout, name: :timeout)
310
+ timeout || @request_timeout
243
311
  end
244
312
 
245
313
  def drop(reason, record)
@@ -253,24 +321,23 @@ module Julewire
253
321
  end
254
322
 
255
323
  def record_loss(reason, record)
256
- metadata = Core::Records::Metadata.call(record)
257
324
  @health.record_loss(
258
325
  reason: reason,
259
- event: metadata[:event],
260
- severity: metadata[:severity],
261
- source: metadata[:source]
326
+ event: record.event,
327
+ severity: record.severity,
328
+ source: record.source
262
329
  )
263
330
  end
264
331
 
265
332
  def record_failure(error, **metadata)
266
- @health.record_failure(error, counter: nil, **metadata)
333
+ @health.record_failure(error, **metadata)
267
334
  Core::Diagnostics::CallbackNotifier.call(@on_failure, error, { destination: name }.merge(metadata))
268
- rescue StandardError
269
- nil
270
335
  end
271
336
 
272
- def decrement_in_flight
273
- @in_flight.decrement if @in_flight.value.positive?
337
+ def release_slot
338
+ # Late or duplicate ACKs can arrive after teardown/reset; keep them visible
339
+ # without treating the ignored underflow as an operator-facing defect.
340
+ increment(:slot_underflow_ignored) if @queue_slots.release
274
341
  end
275
342
 
276
343
  def increment(key)
@@ -279,10 +346,8 @@ module Julewire
279
346
 
280
347
  def status_for(worker)
281
348
  return :closed if closed?
282
- return :degraded if @health.degraded?
283
- return :degraded if worker.is_a?(Hash) && worker[:status] == :degraded
284
349
 
285
- :ok
350
+ :degraded if worker&.fetch(:status) == :degraded
286
351
  end
287
352
  end
288
353
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # :nocov:
4
3
  module Julewire
5
4
  module Ractor
6
5
  class DestinationWorker
@@ -47,27 +46,24 @@ module Julewire
47
46
  @ack_port = ack_port
48
47
  loop do
49
48
  message = command_port.receive
50
- break if close_message?(message)
49
+ current_command = command(message)
50
+ break if current_command == :close_worker
51
51
 
52
- break if dispatch(message) == :close
52
+ break if dispatch(message, current_command) == :close
53
53
  end
54
54
  ensure
55
55
  close_output
56
- ack(:closed)
57
56
  end
58
57
 
59
58
  private
60
59
 
61
- def close_message?(message)
62
- message.is_a?(Hash) && message[:command] == :close_worker
63
- end
64
-
65
- def dispatch(message)
66
- return unless message.is_a?(Hash)
67
-
68
- case message[:command]
60
+ def dispatch(message, current_command)
61
+ case current_command
69
62
  when :emit
70
- emit(message[:record])
63
+ emit(
64
+ message.fetch(:record),
65
+ parent_degradation_marker: message.fetch(:degradation_marker)
66
+ )
71
67
  when :flush
72
68
  reply_to(message, call_output_lifecycle(:flush))
73
69
  when :close
@@ -75,51 +71,47 @@ module Julewire
75
71
  :close
76
72
  when :health
77
73
  reply_to(message, health)
74
+ else
75
+ raise ArgumentError, "unknown ractor destination command: #{current_command.inspect}"
78
76
  end
79
- rescue StandardError => e
80
- record_failure(e, phase: :dispatch)
81
- reply_to(message, false)
82
77
  end
83
78
 
84
- def emit(record)
85
- ack(@write_step.call(record) == :accepted ? :accepted : :dropped)
86
- rescue StandardError => e
87
- record_failure(e, phase: :emit)
88
- ack(:dropped)
79
+ def command(message)
80
+ Core::Integration::Protocol.validate_symbol_hash(message)
81
+ message.fetch(:command)
82
+ end
83
+
84
+ def emit(record, parent_degradation_marker:)
85
+ accepted = @health.recover_if_successful { @write_step.call(record) }
86
+ ack(accepted ? :accepted : :dropped, degradation_marker: parent_degradation_marker)
89
87
  end
90
88
 
91
89
  def call_output_lifecycle(method_name)
92
- return close_lifecycle if method_name == :close
93
- return true unless @output.respond_to?(method_name)
90
+ return close_lifecycle_succeeds? if method_name == :close
94
91
 
95
- @output.public_send(method_name) != false
92
+ @health.recover_if_successful do
93
+ !@output.respond_to?(method_name) || @output.public_send(method_name) != false
94
+ end
96
95
  rescue StandardError => e
97
96
  record_failure(e, phase: :output_lifecycle, action: method_name)
98
97
  false
99
98
  end
100
99
 
101
- def close_lifecycle
102
- return true if output_closed?
100
+ def close_lifecycle_succeeds?
101
+ return true if @output.respond_to?(:closed?) && @output.closed?
103
102
  return @output.close != false if @close_output && @output.respond_to?(:close)
104
103
  return @output.flush != false if @output.respond_to?(:flush)
105
104
 
106
105
  true
107
- rescue StandardError => e
108
- record_failure(e, phase: :output_lifecycle, action: :close)
109
- false
110
106
  end
111
107
 
112
108
  def close_output
113
- return if output_closed?
109
+ return if @output.respond_to?(:closed?) && @output.closed?
114
110
  return unless @close_output && @output.respond_to?(:close)
115
111
 
116
112
  @output.close
117
- rescue StandardError => e
118
- record_failure(e, phase: :output_lifecycle, action: :close)
119
- end
120
-
121
- def output_closed?
122
- @output.respond_to?(:closed?) ? @output.closed? : false
113
+ rescue StandardError
114
+ nil
123
115
  end
124
116
 
125
117
  def health
@@ -131,7 +123,7 @@ module Julewire
131
123
  end
132
124
 
133
125
  def record_failure(error, **metadata)
134
- @health.record_failure(error, counter: nil, **metadata)
126
+ @health.record_failure(error, **metadata)
135
127
  end
136
128
 
137
129
  def record_loss(reason, **metadata)
@@ -139,7 +131,7 @@ module Julewire
139
131
  end
140
132
 
141
133
  def record_write_step_failure(error, metadata)
142
- record_failure(error, **recordless_metadata(metadata))
134
+ record_failure(error, **metadata)
143
135
  end
144
136
 
145
137
  def record_write_step_loss(reason, metadata)
@@ -156,15 +148,19 @@ module Julewire
156
148
  metadata.except(:record)
157
149
  end
158
150
 
159
- def ack(status)
160
- @ack_port.send({ event: :ack, status: status })
161
- rescue StandardError
162
- nil
151
+ def ack(status, degradation_marker:)
152
+ @ack_port.send({ degradation_marker: degradation_marker, event: :ack, status: status })
163
153
  end
164
154
 
165
155
  def reply_to(message, response)
166
- reply = message[:reply]
167
- reply.send(response) if reply.is_a?(::Ractor::Port)
156
+ reply = message.fetch(:reply)
157
+ raise TypeError, "ractor destination reply must be a Ractor::Port" unless reply.is_a?(::Ractor::Port)
158
+
159
+ send_reply(reply, response)
160
+ end
161
+
162
+ def send_reply(reply, response)
163
+ reply.send(response)
168
164
  rescue StandardError => e
169
165
  record_failure(e, phase: :reply)
170
166
  end
@@ -173,4 +169,3 @@ module Julewire
173
169
  private_constant :DestinationWorker
174
170
  end
175
171
  end
176
- # :nocov:
@@ -7,7 +7,9 @@ module Julewire
7
7
 
8
8
  def initialize(destinations:, name: :ractor_fanout, on_failure: nil)
9
9
  @name = Core::Destinations.normalize_name(name)
10
- @destinations = Array(destinations).map { normalize_destination(it) }.freeze
10
+ raise ArgumentError, "destinations must be an Array" unless destinations.instance_of?(Array)
11
+
12
+ @destinations = destinations.map { normalize_destination(it) }
11
13
  raise ArgumentError, "destinations must not be empty" if @destinations.empty?
12
14
 
13
15
  Core::Validation.validate_callable!(on_failure, name: :on_failure, allow_nil: true)
@@ -16,7 +18,9 @@ module Julewire
16
18
  end
17
19
 
18
20
  def emit(record)
19
- @destinations.each { emit_to_destination(it, record) }
21
+ @health.recover_if_successful do
22
+ @destinations.each { emit_to_destination(it, record) }
23
+ end
20
24
  nil
21
25
  end
22
26
 
@@ -29,10 +33,12 @@ module Julewire
29
33
  end
30
34
 
31
35
  def after_fork!
32
- @destinations.each do |destination|
33
- destination.after_fork! if destination.respond_to?(:after_fork!)
34
- rescue StandardError => e
35
- record_failure(e, action: :after_fork, destination: destination.name)
36
+ @health.recover_if_successful do
37
+ @destinations.each do |destination|
38
+ destination.after_fork! if destination.respond_to?(:after_fork!)
39
+ rescue StandardError => e
40
+ record_failure(e, action: :after_fork, destination: destination.name)
41
+ end
36
42
  end
37
43
  self
38
44
  end
@@ -61,14 +67,16 @@ module Julewire
61
67
  end
62
68
 
63
69
  def call_lifecycle(method_name, timeout:)
64
- ok = true
65
- @destinations.each do |destination|
66
- ok = false if destination.public_send(method_name, timeout: timeout) == false
67
- rescue StandardError => e
68
- record_failure(e, action: method_name, destination: destination.name)
69
- ok = false
70
+ @health.recover_if_successful do
71
+ ok = true
72
+ @destinations.each do |destination|
73
+ ok = false if destination.public_send(method_name, timeout: timeout) == false
74
+ rescue StandardError => e
75
+ record_failure(e, action: method_name, destination: destination.name)
76
+ ok = false
77
+ end
78
+ ok
70
79
  end
71
- ok
72
80
  end
73
81
 
74
82
  def destination_health(destination)
@@ -78,17 +86,16 @@ module Julewire
78
86
  end
79
87
 
80
88
  def health_status(destinations)
81
- return :degraded if @health.last_failure
82
- return :degraded if destinations.any? { |_name, health| health[:status] == :degraded || health[:phase] }
83
-
84
- :ok
89
+ :degraded if destinations.any? { |_name, health| health[:status] == :degraded || health[:phase] }
85
90
  end
86
91
 
87
92
  def record_failure(error, **metadata)
88
- @health.record_failure(error, counter: nil, phase: :ractor_fanout, **metadata)
89
- @on_failure&.call(error, **metadata, phase: :ractor_fanout)
90
- rescue StandardError
91
- nil
93
+ @health.record_failure(error, phase: :ractor_fanout, **metadata)
94
+ callback_result = Core::Diagnostics::CallbackNotifier.call(@on_failure, error,
95
+ metadata.merge(phase: :ractor_fanout))
96
+ return unless Core::Diagnostics::CallbackNotifier.failure?(callback_result)
97
+
98
+ @health.record_callback_failure(callback_result)
92
99
  end
93
100
  end
94
101
  end
@@ -4,11 +4,17 @@ module Julewire
4
4
  module Ractor
5
5
  module PortLifecycle
6
6
  class << self
7
+ def with_port(port = ::Ractor::Port.new)
8
+ yield port
9
+ ensure
10
+ close(port)
11
+ end
12
+
7
13
  def close(port)
8
14
  return unless port.respond_to?(:close)
9
- return if port.respond_to?(:closed?) && port.closed?
10
15
 
11
16
  port.close
17
+ nil
12
18
  rescue StandardError
13
19
  nil
14
20
  end