omq 0.5.0 → 0.6.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +195 -0
  3. data/README.md +21 -19
  4. data/exe/omq +6 -0
  5. data/lib/omq/cli/base_runner.rb +423 -0
  6. data/lib/omq/cli/channel.rb +8 -0
  7. data/lib/omq/cli/client_server.rb +106 -0
  8. data/lib/omq/cli/config.rb +51 -0
  9. data/lib/omq/cli/formatter.rb +75 -0
  10. data/lib/omq/cli/pair.rb +31 -0
  11. data/lib/omq/cli/peer.rb +8 -0
  12. data/lib/omq/cli/pipe.rb +249 -0
  13. data/lib/omq/cli/pub_sub.rb +14 -0
  14. data/lib/omq/cli/push_pull.rb +14 -0
  15. data/lib/omq/cli/radio_dish.rb +27 -0
  16. data/lib/omq/cli/req_rep.rb +77 -0
  17. data/lib/omq/cli/router_dealer.rb +70 -0
  18. data/lib/omq/cli/scatter_gather.rb +14 -0
  19. data/lib/omq/cli.rb +444 -0
  20. data/lib/omq/pub_sub.rb +2 -2
  21. data/lib/omq/radio_dish.rb +2 -2
  22. data/lib/omq/socket.rb +74 -27
  23. data/lib/omq/version.rb +1 -1
  24. data/lib/omq/zmtp/connection.rb +59 -12
  25. data/lib/omq/zmtp/engine.rb +179 -17
  26. data/lib/omq/zmtp/options.rb +4 -3
  27. data/lib/omq/zmtp/reactor.rb +25 -36
  28. data/lib/omq/zmtp/routing/channel.rb +14 -3
  29. data/lib/omq/zmtp/routing/fan_out.rb +52 -10
  30. data/lib/omq/zmtp/routing/pair.rb +14 -3
  31. data/lib/omq/zmtp/routing/peer.rb +28 -6
  32. data/lib/omq/zmtp/routing/push.rb +14 -7
  33. data/lib/omq/zmtp/routing/radio.rb +45 -12
  34. data/lib/omq/zmtp/routing/rep.rb +32 -13
  35. data/lib/omq/zmtp/routing/req.rb +1 -2
  36. data/lib/omq/zmtp/routing/round_robin.rb +72 -3
  37. data/lib/omq/zmtp/routing/router.rb +30 -10
  38. data/lib/omq/zmtp/routing/scatter.rb +16 -3
  39. data/lib/omq/zmtp/routing/server.rb +28 -6
  40. data/lib/omq/zmtp/routing/xsub.rb +7 -1
  41. data/lib/omq/zmtp/routing.rb +19 -0
  42. data/lib/omq/zmtp/transport/inproc.rb +48 -5
  43. data/lib/omq/zmtp/transport/ipc.rb +9 -7
  44. data/lib/omq/zmtp/transport/tcp.rb +14 -7
  45. data/lib/omq/zmtp/writable.rb +21 -4
  46. data/lib/omq.rb +7 -0
  47. metadata +18 -3
  48. data/exe/omqcat +0 -532
@@ -13,14 +13,17 @@ module OMQ
13
13
  #
14
14
  attr_reader :peer_socket_type
15
15
 
16
+
16
17
  # @return [String] peer's identity (from READY handshake)
17
18
  #
18
19
  attr_reader :peer_identity
19
20
 
21
+
20
22
  # @return [Object] transport IO (#read, #write, #close)
21
23
  #
22
24
  attr_reader :io
23
25
 
26
+
24
27
  # @param io [#read, #write, #close] transport IO
25
28
  # @param socket_type [String] our socket type name (e.g. "REQ")
26
29
  # @param identity [String] our identity
@@ -51,6 +54,7 @@ module OMQ
51
54
  @max_message_size = max_message_size
52
55
  end
53
56
 
57
+
54
58
  # Performs the full ZMTP handshake via the configured mechanism.
55
59
  #
56
60
  # @return [void]
@@ -77,25 +81,44 @@ module OMQ
77
81
  end
78
82
  end
79
83
 
80
- # Sends a multi-frame message.
84
+
85
+ # Sends a multi-frame message (write + flush).
81
86
  #
82
87
  # @param parts [Array<String>] message frames
83
88
  # @return [void]
84
89
  #
85
90
  def send_message(parts)
86
91
  @mutex.synchronize do
87
- parts.each_with_index do |part, i|
88
- more = i < parts.size - 1
89
- if @mechanism.encrypted?
90
- @io.write(@mechanism.encrypt(part.b, more: more))
91
- else
92
- @io.write(Codec::Frame.new(part, more: more).to_wire)
93
- end
94
- end
92
+ write_frames(parts)
93
+ @io.flush
94
+ end
95
+ end
96
+
97
+
98
+ # Writes a multi-frame message to the buffer without flushing.
99
+ # Call {#flush} after batching writes.
100
+ #
101
+ # @param parts [Array<String>] message frames
102
+ # @return [void]
103
+ #
104
+ def write_message(parts)
105
+ @mutex.synchronize do
106
+ write_frames(parts)
107
+ end
108
+ end
109
+
110
+
111
+ # Flushes the write buffer to the underlying IO.
112
+ #
113
+ # @return [void]
114
+ #
115
+ def flush
116
+ @mutex.synchronize do
95
117
  @io.flush
96
118
  end
97
119
  end
98
120
 
121
+
99
122
  # Receives a multi-frame message.
100
123
  # PING/PONG commands are handled automatically by #read_frame.
101
124
  #
@@ -110,12 +133,13 @@ module OMQ
110
133
  yield frame if block_given?
111
134
  next
112
135
  end
113
- frames << frame.body
136
+ frames << frame.body.freeze
114
137
  break unless frame.more?
115
138
  end
116
- frames
139
+ frames.freeze
117
140
  end
118
141
 
142
+
119
143
  # Starts the heartbeat sender task. Call after handshake.
120
144
  #
121
145
  # @return [#stop, nil] the heartbeat task, or nil if disabled
@@ -123,7 +147,7 @@ module OMQ
123
147
  def start_heartbeat
124
148
  return nil unless @heartbeat_interval
125
149
  @last_received_at = monotonic_now
126
- @heartbeat_task = Reactor.spawn_pump do
150
+ @heartbeat_task = Reactor.spawn_pump(annotation: "heartbeat") do
127
151
  loop do
128
152
  sleep @heartbeat_interval
129
153
  # Send PING with TTL
@@ -145,6 +169,7 @@ module OMQ
145
169
  end
146
170
  end
147
171
 
172
+
148
173
  # Sends a command.
149
174
  #
150
175
  # @param command [Codec::Command]
@@ -161,6 +186,7 @@ module OMQ
161
186
  end
162
187
  end
163
188
 
189
+
164
190
  # Reads one frame from the wire. Handles PING/PONG automatically.
165
191
  # When using an encrypted mechanism, MESSAGE commands are decrypted
166
192
  # back to ZMTP frames transparently.
@@ -200,6 +226,7 @@ module OMQ
200
226
  end
201
227
  end
202
228
 
229
+
203
230
  # Closes the connection.
204
231
  #
205
232
  # @return [void]
@@ -211,16 +238,36 @@ module OMQ
211
238
  # already closed
212
239
  end
213
240
 
241
+
214
242
  private
215
243
 
244
+
245
+ # Writes message parts as ZMTP frames, encrypting if needed.
246
+ #
247
+ # @param parts [Array<String>] message frames
248
+ #
249
+ def write_frames(parts)
250
+ parts.each_with_index do |part, i|
251
+ more = i < parts.size - 1
252
+ if @mechanism.encrypted?
253
+ @io.write(@mechanism.encrypt(part.b, more: more))
254
+ else
255
+ @io.write(Codec::Frame.new(part, more: more).to_wire)
256
+ end
257
+ end
258
+ end
259
+
260
+
216
261
  def touch_heartbeat
217
262
  @last_received_at = monotonic_now if @heartbeat_interval
218
263
  end
219
264
 
265
+
220
266
  def monotonic_now
221
267
  Async::Clock.now
222
268
  end
223
269
 
270
+
224
271
  # Sends one frame to the wire.
225
272
  #
226
273
  # @param frame [Codec::Frame]
@@ -14,22 +14,27 @@ module OMQ
14
14
  #
15
15
  attr_reader :socket_type
16
16
 
17
+
17
18
  # @return [Options] socket options
18
19
  #
19
20
  attr_reader :options
20
21
 
22
+
21
23
  # @return [Routing] routing strategy
22
24
  #
23
25
  attr_reader :routing
24
26
 
27
+
25
28
  # @return [String, nil] last bound endpoint
26
29
  #
27
30
  attr_reader :last_endpoint
28
31
 
32
+
29
33
  # @return [Integer, nil] last auto-selected TCP port
30
34
  #
31
35
  attr_reader :last_tcp_port
32
36
 
37
+
33
38
  # @param socket_type [Symbol] e.g. :REQ, :REP, :PAIR
34
39
  # @param options [Options]
35
40
  #
@@ -43,10 +48,24 @@ module OMQ
43
48
  @listeners = []
44
49
  @tasks = []
45
50
  @closed = false
51
+ @closing = false
46
52
  @last_endpoint = nil
47
53
  @last_tcp_port = nil
54
+ @peer_connected = Async::Promise.new
55
+ @all_peers_gone = Async::Promise.new
56
+ @reconnect_enabled = true
57
+ @parent_task = nil
58
+ @connection_promises = {} # connection => Async::Promise
59
+ @fatal_error = nil
48
60
  end
49
61
 
62
+
63
+ attr_reader :peer_connected, :all_peers_gone, :connections, :parent_task
64
+
65
+
66
+ attr_writer :reconnect_enabled
67
+
68
+
50
69
  # Binds to an endpoint.
51
70
  #
52
71
  # @param endpoint [String] e.g. "tcp://127.0.0.1:5555", "inproc://foo"
@@ -54,6 +73,7 @@ module OMQ
54
73
  # @raise [ArgumentError] on unsupported transport
55
74
  #
56
75
  def bind(endpoint)
76
+ capture_parent_task
57
77
  transport = transport_for(endpoint)
58
78
  listener = transport.bind(endpoint, self)
59
79
  @listeners << listener
@@ -61,12 +81,14 @@ module OMQ
61
81
  @last_tcp_port = extract_tcp_port(listener.endpoint)
62
82
  end
63
83
 
84
+
64
85
  # Connects to an endpoint.
65
86
  #
66
87
  # @param endpoint [String]
67
88
  # @return [void]
68
89
  #
69
90
  def connect(endpoint)
91
+ capture_parent_task
70
92
  @connected_endpoints << endpoint
71
93
  if endpoint.start_with?("inproc://")
72
94
  # Inproc connect is synchronous and instant
@@ -78,6 +100,7 @@ module OMQ
78
100
  end
79
101
  end
80
102
 
103
+
81
104
  # Disconnects from an endpoint. Closes connections to that endpoint
82
105
  # and stops auto-reconnection for it.
83
106
  #
@@ -95,6 +118,7 @@ module OMQ
95
118
  end
96
119
  end
97
120
 
121
+
98
122
  # Unbinds from an endpoint. Stops the listener and closes all
99
123
  # connections that were accepted on it.
100
124
  #
@@ -117,6 +141,7 @@ module OMQ
117
141
  end
118
142
  end
119
143
 
144
+
120
145
  # Called by a transport when an incoming connection is accepted.
121
146
  #
122
147
  # @param io [#read, #write, #close]
@@ -124,18 +149,20 @@ module OMQ
124
149
  # @return [void]
125
150
  #
126
151
  def handle_accepted(io, endpoint: nil)
127
- setup_connection(io, as_server: true, endpoint: endpoint)
152
+ spawn_connection(io, as_server: true, endpoint: endpoint)
128
153
  end
129
154
 
155
+
130
156
  # Called by a transport when an outgoing connection is established.
131
157
  #
132
158
  # @param io [#read, #write, #close]
133
159
  # @return [void]
134
160
  #
135
161
  def handle_connected(io, endpoint: nil)
136
- setup_connection(io, as_server: false, endpoint: endpoint)
162
+ spawn_connection(io, as_server: false, endpoint: endpoint)
137
163
  end
138
164
 
165
+
139
166
  # Called by inproc transport with a pre-validated DirectPipe.
140
167
  # Skips ZMTP handshake — just registers with routing strategy.
141
168
  #
@@ -146,25 +173,43 @@ module OMQ
146
173
  @connections << pipe
147
174
  @connection_endpoints[pipe] = endpoint if endpoint
148
175
  @routing.connection_added(pipe)
176
+ @peer_connected.resolve(pipe)
149
177
  end
150
178
 
179
+
151
180
  # Dequeues the next received message. Blocks until available.
152
181
  #
153
182
  # @return [Array<String>] message parts
183
+ # @raise if a background pump task crashed
154
184
  #
155
185
  def dequeue_recv
156
- @routing.recv_queue.dequeue
186
+ raise @fatal_error if @fatal_error
187
+ msg = @routing.recv_queue.dequeue
188
+ raise @fatal_error if msg.nil? && @fatal_error
189
+ msg
157
190
  end
158
191
 
192
+
193
+ # Pushes a nil sentinel into the recv queue, unblocking a
194
+ # pending {#dequeue_recv} with a nil return value.
195
+ #
196
+ def dequeue_recv_sentinel
197
+ @routing.recv_queue.push(nil)
198
+ end
199
+
200
+
159
201
  # Enqueues a message for sending. Blocks at HWM.
160
202
  #
161
203
  # @param parts [Array<String>]
162
204
  # @return [void]
205
+ # @raise if a background pump task crashed
163
206
  #
164
207
  def enqueue_send(parts)
208
+ raise @fatal_error if @fatal_error
165
209
  @routing.enqueue(parts)
166
210
  end
167
211
 
212
+
168
213
  # Starts a recv pump for a connection, or wires the inproc
169
214
  # fast path when the connection is a DirectPipe.
170
215
  #
@@ -180,17 +225,22 @@ module OMQ
180
225
  return nil
181
226
  end
182
227
 
183
- Reactor.spawn_pump do
228
+ Reactor.spawn_pump(annotation: "recv pump") do
184
229
  loop do
185
230
  msg = conn.receive_message
186
- msg = transform ? transform.call(msg) : msg
231
+ msg = transform ? transform.call(msg).freeze : msg
187
232
  recv_queue.enqueue(msg)
188
233
  end
189
- rescue *CONNECTION_LOST
234
+ rescue Async::Stop
235
+ # normal shutdown
236
+ rescue ProtocolError, *CONNECTION_LOST
190
237
  connection_lost(conn)
238
+ rescue => error
239
+ signal_fatal_error(error)
191
240
  end
192
241
  end
193
242
 
243
+
194
244
  # Called when a connection is lost.
195
245
  #
196
246
  # @param connection [Connection]
@@ -202,41 +252,133 @@ module OMQ
202
252
  @routing.connection_removed(connection)
203
253
  connection.close
204
254
 
255
+ # Signal the connection task to exit.
256
+ done = @connection_promises.delete(connection)
257
+ done&.resolve(true)
258
+
259
+ # Resolve all_peers_gone once: had peers, now have none.
260
+ if @peer_connected.resolved? && @connections.empty?
261
+ @all_peers_gone.resolve(true)
262
+ end
263
+
205
264
  # Auto-reconnect if this was a connected (not bound) endpoint
206
- if endpoint && @connected_endpoints.include?(endpoint) && !@closed
265
+ if endpoint && @connected_endpoints.include?(endpoint) && !@closed && !@closing && @reconnect_enabled
207
266
  schedule_reconnect(endpoint)
208
267
  end
209
268
  end
210
269
 
270
+
211
271
  # Closes all connections and listeners.
212
272
  #
213
273
  # @return [void]
214
274
  #
215
275
  def close
216
- return if @closed
217
- @closed = true
276
+ return if @closed || @closing
277
+ @closing = true
278
+
279
+ # Stop accepting new connections — but only if we already have
280
+ # peers to drain to. With zero connections the listeners must
281
+ # stay open so late-arriving peers can still receive queued
282
+ # messages during the linger period.
283
+ unless @connections.empty?
284
+ @listeners.each(&:stop)
285
+ @listeners.clear
286
+ end
218
287
 
219
288
  # Linger: wait for send queues to drain before closing.
220
289
  # linger=0 → close immediately, linger=nil → wait forever.
290
+ # @closed is set AFTER draining so reconnect tasks keep
291
+ # running during the linger period.
221
292
  linger = @options.linger
222
293
  if linger.nil? || linger > 0
223
294
  drain_timeout = linger # nil = wait forever, >0 = seconds
224
295
  drain_send_queues(drain_timeout)
225
296
  end
226
297
 
298
+ @closed = true
299
+
300
+ # Stop any remaining listeners.
301
+ @listeners.each(&:stop)
302
+ @listeners.clear
303
+
227
304
  # Close connections — causes pump tasks to get EOFError/IOError
228
305
  @connections.each(&:close)
229
306
  @connections.clear
230
- @listeners.each(&:stop)
231
- @listeners.clear
232
307
  # Stop any remaining pump tasks
233
308
  @routing.stop rescue nil
234
309
  @tasks.each { |t| t.stop rescue nil }
235
310
  @tasks.clear
236
311
  end
237
312
 
313
+
314
+ # Spawns a transient pump task with error propagation.
315
+ #
316
+ # Unexpected exceptions are caught and forwarded to
317
+ # {#signal_fatal_error} so blocked callers (send/recv)
318
+ # see the real error instead of deadlocking.
319
+ #
320
+ # @param annotation [String] task annotation for debugging
321
+ # @yield the pump loop body
322
+ # @return [Async::Task]
323
+ #
324
+ def spawn_pump_task(annotation:, &block)
325
+ @parent_task.async(transient: true, annotation: annotation) do
326
+ yield
327
+ rescue Async::Stop, ProtocolError, *CONNECTION_LOST
328
+ # normal shutdown / expected disconnect
329
+ rescue => error
330
+ signal_fatal_error(error)
331
+ end
332
+ end
333
+
334
+
335
+ # Wraps an unexpected pump error as {OMQ::SocketDeadError} and
336
+ # unblocks any callers waiting on the recv queue.
337
+ #
338
+ # Must be called from inside a rescue block so that +error+ is
339
+ # +$!+ and Ruby sets it as +#cause+ on the new exception.
340
+ #
341
+ # @param error [Exception]
342
+ #
343
+ def signal_fatal_error(error)
344
+ return if @closing || @closed
345
+ @fatal_error = begin
346
+ raise OMQ::SocketDeadError, "internal error killed #{@socket_type} socket"
347
+ rescue => wrapped
348
+ wrapped
349
+ end
350
+ @routing.recv_queue.enqueue(nil) rescue nil
351
+ end
352
+
353
+
238
354
  private
239
355
 
356
+
357
+ # Saves the current Async task so connection subtrees can be
358
+ # spawned as siblings of the caller's task.
359
+ #
360
+ def capture_parent_task
361
+ @parent_task ||= Async::Task.current? ? Async::Task.current : nil
362
+ end
363
+
364
+
365
+ # Spawns an isolated connection task as a sibling of accept/reconnect
366
+ # tasks. All per-connection children (heartbeat, recv pump, reaper)
367
+ # live inside this task. When the connection dies, the entire subtree
368
+ # is cleaned up by Async.
369
+ #
370
+ def spawn_connection(io, as_server:, endpoint: nil)
371
+ task = @parent_task&.async(transient: true, annotation: "conn #{endpoint}") do
372
+ done = Async::Promise.new
373
+ setup_connection(io, as_server: as_server, endpoint: endpoint, done: done)
374
+ done.wait
375
+ rescue ProtocolError, *CONNECTION_LOST
376
+ # handshake failed or connection lost — subtree cleaned up
377
+ end
378
+ @tasks << task if task
379
+ end
380
+
381
+
240
382
  # Waits for the send queue to drain.
241
383
  #
242
384
  # @param timeout [Numeric, nil] max seconds to wait (nil = forever)
@@ -245,7 +387,8 @@ module OMQ
245
387
  return unless @routing.respond_to?(:send_queue)
246
388
  deadline = timeout ? Async::Clock.now + timeout : nil
247
389
 
248
- until @routing.send_queue.empty?
390
+ until @routing.send_queue.empty? &&
391
+ (!@routing.respond_to?(:send_pump_idle?) || @routing.send_pump_idle?)
249
392
  if deadline
250
393
  remaining = deadline - Async::Clock.now
251
394
  break if remaining <= 0
@@ -254,12 +397,21 @@ module OMQ
254
397
  end
255
398
  end
256
399
 
257
- def setup_connection(io, as_server:, endpoint: nil)
400
+
401
+ # Performs the ZMTP handshake, starts heartbeating, and registers
402
+ # the new connection with the routing strategy.
403
+ #
404
+ # @param io [#read, #write, #close] underlying transport stream
405
+ # @param as_server [Boolean] whether we are the ZMTP server side
406
+ # @param endpoint [String, nil] endpoint for reconnection tracking
407
+ # @param done [Async::Promise, nil] resolved when the connection is lost
408
+ #
409
+ def setup_connection(io, as_server:, endpoint: nil, done: nil)
258
410
  conn = Connection.new(
259
411
  io,
260
- socket_type: @socket_type.to_s,
261
- identity: @options.identity,
262
- as_server: as_server,
412
+ socket_type: @socket_type.to_s,
413
+ identity: @options.identity,
414
+ as_server: as_server,
263
415
  mechanism: @options.mechanism,
264
416
  heartbeat_interval: @options.heartbeat_interval,
265
417
  heartbeat_ttl: @options.heartbeat_ttl,
@@ -270,12 +422,21 @@ module OMQ
270
422
  conn.start_heartbeat
271
423
  @connections << conn
272
424
  @connection_endpoints[conn] = endpoint if endpoint
425
+ @connection_promises[conn] = done if done
273
426
  @routing.connection_added(conn)
427
+ @peer_connected.resolve(conn)
274
428
  rescue ProtocolError, *CONNECTION_LOST
275
429
  conn&.close
276
430
  raise
277
431
  end
278
432
 
433
+
434
+ # Spawns a background task that reconnects to the given endpoint
435
+ # with exponential back-off based on the reconnect_interval option.
436
+ #
437
+ # @param endpoint [String] endpoint to reconnect to
438
+ # @param delay [Numeric, nil] initial delay in seconds (defaults to reconnect_interval)
439
+ #
279
440
  def schedule_reconnect(endpoint, delay: nil)
280
441
  ri = @options.reconnect_interval
281
442
  if ri.is_a?(Range)
@@ -286,7 +447,7 @@ module OMQ
286
447
  max_delay = nil
287
448
  end
288
449
 
289
- @tasks << Reactor.spawn_pump do
450
+ @tasks << Reactor.spawn_pump(annotation: "reconnect #{endpoint}") do
290
451
  loop do
291
452
  break if @closed
292
453
  sleep delay if delay > 0
@@ -314,6 +475,7 @@ module OMQ
314
475
  end
315
476
  end
316
477
 
478
+
317
479
  def extract_tcp_port(endpoint)
318
480
  return nil unless endpoint&.start_with?("tcp://")
319
481
  port = endpoint.split(":").last.to_i
@@ -24,13 +24,14 @@ module OMQ
24
24
  @heartbeat_interval = nil # seconds, nil = disabled
25
25
  @heartbeat_ttl = nil # seconds, nil = use heartbeat_interval
26
26
  @heartbeat_timeout = nil # seconds, nil = use heartbeat_interval
27
- @max_message_size = nil # bytes, nil = unlimited
28
- @mechanism = Mechanism::Null.new
27
+ @max_message_size = nil # bytes, nil = unlimited
28
+ @conflate = false
29
+ @mechanism = Mechanism::Null.new
29
30
  end
30
31
 
31
32
  attr_accessor :send_hwm, :recv_hwm,
32
33
  :linger, :identity,
33
- :router_mandatory,
34
+ :router_mandatory, :conflate,
34
35
  :read_timeout, :write_timeout,
35
36
  :reconnect_interval,
36
37
  :heartbeat_interval, :heartbeat_ttl, :heartbeat_timeout,
@@ -12,11 +12,9 @@ module OMQ
12
12
  # tasks — mirroring libzmq's IO thread architecture.
13
13
  #
14
14
  module Reactor
15
- @work_queue = Thread::Queue.new
15
+ @work_queue = Async::Queue.new
16
16
  @thread = nil
17
17
  @mutex = Mutex.new
18
- @wake_r = nil
19
- @wake_w = nil
20
18
 
21
19
  class << self
22
20
  # Spawns a pump task (recv loop, send loop, accept loop).
@@ -26,14 +24,13 @@ module OMQ
26
24
  #
27
25
  # @return [#stop] a stoppable handle
28
26
  #
29
- def spawn_pump(&block)
27
+ def spawn_pump(annotation: nil, &block)
30
28
  if Async::Task.current?
31
- Async(transient: true, &block)
29
+ Async(transient: true, annotation: annotation, &block)
32
30
  else
33
31
  handle = PumpHandle.new
34
32
  ensure_started
35
- @work_queue.push([:spawn, block, handle])
36
- @wake_w.write_nonblock(".") rescue nil
33
+ @work_queue.push([:spawn, block, handle, annotation])
37
34
  handle
38
35
  end
39
36
  end
@@ -52,7 +49,6 @@ module OMQ
52
49
  result_queue = Thread::Queue.new
53
50
  ensure_started
54
51
  @work_queue.push([:run, block, result_queue])
55
- @wake_w.write_nonblock(".") rescue nil
56
52
  status, value = result_queue.pop
57
53
  raise value if status == :error
58
54
  value
@@ -66,9 +62,8 @@ module OMQ
66
62
  def ensure_started
67
63
  @mutex.synchronize do
68
64
  return if @thread&.alive?
69
- @wake_r, @wake_w = IO.pipe
70
65
  ready = Thread::Queue.new
71
- @thread = Thread.new { run_reactor(ready, @wake_r) }
66
+ @thread = Thread.new { run_reactor(ready) }
72
67
  @thread.name = "omq-io"
73
68
  ready.pop
74
69
  end
@@ -80,42 +75,35 @@ module OMQ
80
75
  #
81
76
  def stop!
82
77
  @work_queue.push([:stop])
83
- @wake_w&.write_nonblock(".") rescue nil
84
78
  @thread&.join(2)
85
79
  @thread = nil
86
- @wake_r&.close rescue nil
87
- @wake_w&.close rescue nil
88
- @wake_r = nil
89
- @wake_w = nil
90
80
  end
91
81
 
92
82
  private
93
83
 
94
- def run_reactor(ready, wake_r)
84
+ # Runs the shared Async reactor loop, dispatching work items.
85
+ #
86
+ # @param ready [Thread::Queue] signaled once the reactor is accepting work
87
+ #
88
+ def run_reactor(ready)
95
89
  Async do |task|
96
90
  ready.push(true)
97
91
  loop do
98
- # Wait for wakeup signal (non-blocking for Async scheduler)
99
- wake_r.wait_readable
100
- wake_r.read_nonblock(256) rescue nil
101
-
102
- # Drain all pending work items
103
- while (item = @work_queue.pop(true) rescue nil)
104
- case item[0]
105
- when :spawn
106
- _, block, handle = item
107
- async_task = task.async(transient: true, &block)
108
- handle.task = async_task
109
- when :run
110
- _, block, result_queue = item
111
- task.async do
112
- result_queue.push([:ok, block.call])
113
- rescue => e
114
- result_queue.push([:error, e])
115
- end
116
- when :stop
117
- return
92
+ item = @work_queue.dequeue
93
+ case item[0]
94
+ when :spawn
95
+ _, block, handle, annotation = item
96
+ async_task = task.async(transient: true, annotation: annotation, &block)
97
+ handle.task = async_task
98
+ when :run
99
+ _, block, result_queue = item
100
+ task.async do
101
+ result_queue.push([:ok, block.call])
102
+ rescue => e
103
+ result_queue.push([:error, e])
118
104
  end
105
+ when :stop
106
+ return
119
107
  end
120
108
  end
121
109
  end
@@ -129,6 +117,7 @@ module OMQ
129
117
  #
130
118
  attr_accessor :task
131
119
 
120
+
132
121
  # Stops the pump task.
133
122
  #
134
123
  # @return [void]