omq 0.18.0 → 0.19.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4c49622de9b6972433ec90b148e0d20e68f69d5bb4939debd5abd5095d0396b
4
- data.tar.gz: a70873e070a90ac7b804585231180926ef030e8ffeb9cf65fc44e889dc3619e2
3
+ metadata.gz: 19b283aa20e543617cd7be9c899d2954c219631fd57417536be4fb26798e6e85
4
+ data.tar.gz: a39d1c870e6171726a00a5f1809556e3ce7c3a8c0e4b0e84722eae15f83f93b6
5
5
  SHA512:
6
- metadata.gz: a4c994245259282909189bcd44d69ef9153b52ef1dd2675b8cf8635b8970ed9c630048b55837cdc0fa20ecb04c00edd9b3bff17e5d9ad93d70107eb8701b08a3
7
- data.tar.gz: d3f88cd73a8ea21fb572518a8f9b8128244f2bb10cfcf0e43b8ac73a797158ef1c2a59fe8fe75be407eac6277f8ebd05bd7ab554f6fa3a4d1e215ccaf3c034ab
6
+ metadata.gz: 7a020d02b2a1da25dd37f6ff92836348985fb7bcd7b8c17392bacb4c23d0ee443b2dd294f8e30d5df5724e11049462d428efb6d2b5a3313b479ab7a3e7e71388
7
+ data.tar.gz: 3b9724342594f22aabe00d1b13d7aa71bfe652ca21954c6b4789556e95743fd03d1c62214a164be8cad717405766a5d0acdbb4c3fd73a0f95aa14738cfb17d81
data/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.2 — 2026-04-13
4
+
5
+ ### Added
6
+
7
+ - **`:disconnected` monitor events carry the underlying error.** When
8
+ a connection drops due to a `Protocol::ZMTP::Error` (oversized
9
+ frame, bad framing, zstd bytebomb, nonce exhaustion, …) or a
10
+ `CONNECTION_LOST` error, the `:disconnected` event's `detail` hash
11
+ now includes `error:` (the exception instance) and `reason:` (its
12
+ message). Peer tooling can match on `detail[:error].is_a?(...)` to
13
+ enforce its own policy — e.g. `omq-cli` terminates the command on
14
+ `Protocol::ZMTP::Error`, while the library keeps the libzmq-parity
15
+ behavior of silently dropping the offending connection and
16
+ reconnecting.
17
+ - **`OMQ::Socket#engine` public reader.** The socket's engine is now
18
+ a documented (if low-level) accessor for peer tooling that needs
19
+ to reach into internals — notably so `omq-cli`'s monitor callback
20
+ can call `sock.engine.signal_fatal_error(error)` without
21
+ `instance_variable_get`. Not part of the stable user API.
22
+
23
+ ### Fixed
24
+
25
+ - **`signal_fatal_error` preserves the underlying cause.** The
26
+ resulting `SocketDeadError` now chains back to the original error
27
+ via `Exception#cause` regardless of whether `signal_fatal_error`
28
+ is called from inside a rescue block or from a monitor callback
29
+ (where `$!` is `nil`). Uses a raise-in-rescue helper to force the
30
+ cause chain. The wrapped error's message also includes the
31
+ original reason so tooling that only logs the top-level message
32
+ still shows what happened.
33
+
34
+ ## 0.19.1 — 2026-04-13
35
+
36
+ ### Fixed
37
+
38
+ - **Send-queue batch accounting tolerates non-string parts.**
39
+ `Routing::RoundRobin#drain_send_queue_capped` previously called
40
+ `#bytesize` directly on each message part for the fairness cap, which
41
+ crashed when a connection wrapper enqueued structured parts for later
42
+ transformation (notably `OMQ::Ractor`'s `MarshalConnection`, which
43
+ hands off live Ruby objects and marshals them in `#write_messages`).
44
+ The fairness cap now skips parts that don't respond to `#bytesize`.
45
+
46
+ ## 0.19.0 — 2026-04-12
47
+
48
+ ### Added
49
+
50
+ - **Verbose-monitor helpers `Engine#emit_verbose_msg_sent` and
51
+ `#emit_verbose_msg_received`.** Used by `RecvPump` and every
52
+ send-pump routing strategy (`conn_send_pump`, `round_robin`,
53
+ `pair`, `fan_out`) to emit `:message_sent` / `:message_received`
54
+ monitor events with a connection reference. When the connection
55
+ exposes `#last_wire_size_out` / `#last_wire_size_in` (as the
56
+ `omq-rfc-zstd` `CompressionConnection` wrapper does), the event
57
+ detail includes `wire_size:` so verbose traces can annotate
58
+ compressed message previews with the post-compression byte count.
59
+ `RecvPump` now emits the trace *before* enqueueing the message
60
+ so the monitor fiber runs before the application fiber, which
61
+ preserves log-before-body ordering at `-vvv`.
62
+
63
+ ### Changed
64
+
65
+ - **`OMQ::Transport::TCP` normalizes host shorthands.** `tcp://*:PORT`
66
+ now binds *dual-stack* (both `0.0.0.0` and `::` on the same port,
67
+ with `IPV6_V6ONLY` set) rather than IPv4-only `0.0.0.0`, matching
68
+ [Puma v8.0.0's behavior](https://github.com/puma/puma/releases/tag/v8.0.0).
69
+ `tcp://:PORT`, `tcp://localhost:PORT`, and `tcp://*:PORT` on the
70
+ connect side all normalize to the loopback host — `::1` on
71
+ IPv6-capable machines (at least one non-loopback, non-link-local
72
+ IPv6 address), otherwise `127.0.0.1`. Explicit addresses
73
+ (`0.0.0.0`, `::`, `127.0.0.1`, `::1`) pass through unchanged.
74
+ Documented in `GETTING_STARTED.md` under "TCP host shorthands".
75
+ This normalization previously lived in `omq-cli` and is now
76
+ shared by all callers.
77
+
78
+ - **TCP accept loop uses `Socket.tcp_server_sockets`** instead of
79
+ manually iterating `Addrinfo.getaddrinfo` + `TCPServer.new`.
80
+ `tcp_server_sockets` handles dual-stack port coordination and
81
+ `IPV6_V6ONLY` automatically. `Listener#servers` now holds
82
+ `Socket` instances rather than `TCPServer`; `#accept` returns
83
+ `[client, addrinfo]` pairs, which the accept loop destructures.
84
+
85
+ - **`Listener#start_accept_loops` uses `yield`** instead of capturing
86
+ the block as an explicit `&on_accepted` proc. The block is bound
87
+ to the enclosing method even when invoked from inside a spawned
88
+ `Async::Task`, so the explicit capture was unnecessary. Applies
89
+ to both TCP and IPC transports.
90
+
3
91
  ## 0.18.0 — 2026-04-12
4
92
 
5
93
  ### Changed
@@ -133,8 +133,18 @@ module OMQ
133
133
  # routing removal, monitor event, reconnect scheduling.
134
134
  # Idempotent: a no-op if already :closed.
135
135
  #
136
- def lost!
137
- tear_down!(reconnect: true)
136
+ def lost!(reason: nil)
137
+ tear_down!(reconnect: true, reason: reason || @disconnect_reason)
138
+ end
139
+
140
+
141
+ # Records the exception that took down a pump task so that the
142
+ # supervisor can surface it in the :disconnected monitor event.
143
+ # First writer wins — subsequent pumps unwinding on the same
144
+ # teardown don't overwrite the original cause.
145
+ #
146
+ def record_disconnect_reason(error)
147
+ @disconnect_reason ||= error
138
148
  end
139
149
 
140
150
 
@@ -187,21 +197,20 @@ module OMQ
187
197
  end
188
198
  rescue Async::Stop, Async::Cancel
189
199
  # socket or supervisor cancelled externally (socket closing)
190
- rescue Protocol::ZMTP::Error, *CONNECTION_LOST
191
- # expected pump exit on disconnect
192
200
  ensure
193
201
  lost!
194
202
  end
195
203
  end
196
204
 
197
205
 
198
- def tear_down!(reconnect:)
206
+ def tear_down!(reconnect:, reason: nil)
199
207
  return if @state == :closed
200
208
  transition!(:closed)
201
209
  @engine.connections.delete(@conn)
202
210
  @engine.routing.connection_removed(@conn) if @conn
203
211
  @conn&.close rescue nil
204
- @engine.emit_monitor_event(:disconnected, endpoint: @endpoint)
212
+ detail = reason ? { error: reason, reason: reason.message } : nil
213
+ @engine.emit_monitor_event(:disconnected, endpoint: @endpoint, detail: detail)
205
214
  @done&.resolve(true)
206
215
  @engine.resolve_all_peers_gone_if_empty
207
216
  @engine.maybe_reconnect(@endpoint) if reconnect
@@ -94,18 +94,24 @@ module OMQ
94
94
  while count < FAIRNESS_MESSAGES && bytes < FAIRNESS_BYTES
95
95
  msg = conn.receive_message
96
96
  msg = transform.call(msg).freeze
97
+ # Emit the verbose trace BEFORE enqueueing so the monitor
98
+ # fiber is woken before the application fiber -- the
99
+ # async scheduler is FIFO on the ready list, so this
100
+ # preserves log-before-stdout ordering for -vvv traces.
101
+ engine.emit_verbose_msg_received(conn, msg)
97
102
  recv_queue.enqueue(msg)
98
- engine.emit_verbose_monitor_event(:message_received, parts: msg)
99
103
  count += 1
100
104
  bytes += msg.sum(&:bytesize) if count_bytes
101
105
  end
102
106
  task.yield
103
107
  end
104
108
  rescue Async::Stop, Async::Cancel
105
- rescue Protocol::ZMTP::Error, *CONNECTION_LOST
106
- # expected disconnect — supervisor will trigger teardown
109
+ rescue Protocol::ZMTP::Error, *CONNECTION_LOST => error
110
+ # expected disconnect — stash reason for the :disconnected
111
+ # monitor event, let the lifecycle reconnect as usual
112
+ engine.connections[conn]&.record_disconnect_reason(error)
107
113
  rescue => error
108
- @engine.signal_fatal_error(error)
114
+ engine.signal_fatal_error(error)
109
115
  end
110
116
  end
111
117
 
@@ -124,18 +130,20 @@ module OMQ
124
130
  bytes = 0
125
131
  while count < FAIRNESS_MESSAGES && bytes < FAIRNESS_BYTES
126
132
  msg = conn.receive_message
133
+ engine.emit_verbose_msg_received(conn, msg)
127
134
  recv_queue.enqueue(msg)
128
- engine.emit_verbose_monitor_event(:message_received, parts: msg)
129
135
  count += 1
130
136
  bytes += msg.sum(&:bytesize) if count_bytes
131
137
  end
132
138
  task.yield
133
139
  end
134
140
  rescue Async::Stop, Async::Cancel
135
- rescue Protocol::ZMTP::Error, *CONNECTION_LOST
136
- # expected disconnect — supervisor will trigger teardown
141
+ rescue Protocol::ZMTP::Error, *CONNECTION_LOST => error
142
+ # expected disconnect — stash reason for the :disconnected
143
+ # monitor event, let the lifecycle reconnect as usual
144
+ engine.connections[conn]&.record_disconnect_reason(error)
137
145
  rescue => error
138
- @engine.signal_fatal_error(error)
146
+ engine.signal_fatal_error(error)
139
147
  end
140
148
  end
141
149
 
data/lib/omq/engine.rb CHANGED
@@ -386,8 +386,12 @@ module OMQ
386
386
 
387
387
  lifecycle.barrier.async(transient: true, annotation: annotation) do
388
388
  yield
389
- rescue Async::Stop, Async::Cancel, Protocol::ZMTP::Error, *CONNECTION_LOST
390
- # normal shutdown / expected disconnect / sibling tore us down
389
+ rescue Async::Stop, Async::Cancel
390
+ # normal shutdown / sibling tore us down
391
+ rescue Protocol::ZMTP::Error, *CONNECTION_LOST => error
392
+ # expected disconnect — stash reason for the :disconnected
393
+ # monitor event, then let the lifecycle reconnect as usual
394
+ lifecycle.record_disconnect_reason(error)
391
395
  rescue => error
392
396
  signal_fatal_error(error)
393
397
  end
@@ -395,25 +399,35 @@ module OMQ
395
399
 
396
400
 
397
401
  # Wraps an unexpected pump error as {OMQ::SocketDeadError} and
398
- # unblocks any callers waiting on the recv queue.
399
- #
400
- # Must be called from inside a rescue block so that +error+ is
401
- # +$!+ and Ruby sets it as +#cause+ on the new exception.
402
+ # unblocks any callers waiting on the recv queue. The original
403
+ # error is preserved as +#cause+ so callers can surface the real
404
+ # reason.
402
405
  #
403
406
  # @param error [Exception]
404
407
  #
405
408
  def signal_fatal_error(error)
406
409
  return unless @lifecycle.open?
407
- @fatal_error = begin
408
- raise SocketDeadError, "internal error killed #{@socket_type} socket"
409
- rescue => wrapped
410
- wrapped
411
- end
410
+ @fatal_error = build_fatal_error(error)
412
411
  routing.recv_queue.push(nil) rescue nil
413
412
  @lifecycle.peer_connected.resolve(nil) rescue nil
414
413
  end
415
414
 
416
415
 
416
+ # Constructs a SocketDeadError whose +cause+ is +error+. Uses the
417
+ # raise-in-rescue idiom because Ruby only sets +cause+ on an
418
+ # exception when it is raised from inside a rescue block -- works
419
+ # regardless of the original caller's +$!+ state.
420
+ def build_fatal_error(error)
421
+ raise error
422
+ rescue
423
+ begin
424
+ raise SocketDeadError, "#{@socket_type} socket killed: #{error.message}"
425
+ rescue SocketDeadError => wrapped
426
+ wrapped
427
+ end
428
+ end
429
+
430
+
417
431
  # Captures the socket's task tree root and starts the socket-level
418
432
  # maintenance task. If +parent+ is given, it is used as the parent
419
433
  # for every task spawned under this socket (connection supervisors,
@@ -461,6 +475,28 @@ module OMQ
461
475
  end
462
476
 
463
477
 
478
+ # Emits a :message_sent verbose event and enriches it with the
479
+ # on-wire (post-compression) byte size if +conn+ exposes
480
+ # +last_wire_size_out+ (installed by ZMTP-Zstd etc.).
481
+ def emit_verbose_msg_sent(conn, parts)
482
+ return unless @verbose_monitor
483
+ detail = { parts: parts }
484
+ detail[:wire_size] = conn.last_wire_size_out if conn.respond_to?(:last_wire_size_out)
485
+ emit_monitor_event(:message_sent, detail: detail)
486
+ end
487
+
488
+
489
+ # Emits a :message_received verbose event and enriches it with the
490
+ # on-wire (pre-decompression) byte size if +conn+ exposes
491
+ # +last_wire_size_in+.
492
+ def emit_verbose_msg_received(conn, parts)
493
+ return unless @verbose_monitor
494
+ detail = { parts: parts }
495
+ detail[:wire_size] = conn.last_wire_size_in if conn.respond_to?(:last_wire_size_in)
496
+ emit_monitor_event(:message_received, detail: detail)
497
+ end
498
+
499
+
464
500
  # Looks up the transport module for an endpoint URI.
465
501
  #
466
502
  # @param endpoint [String] endpoint URI (e.g. "tcp://...", "inproc://...")
@@ -30,9 +30,7 @@ module OMQ
30
30
 
31
31
  conn.flush
32
32
 
33
- batch.each do |parts|
34
- engine.emit_verbose_monitor_event :message_sent, parts: parts
35
- end
33
+ batch.each { |parts| engine.emit_verbose_msg_sent(conn, parts) }
36
34
  end
37
35
  end
38
36
 
@@ -179,7 +179,7 @@ module OMQ
179
179
  Routing.drain_send_queue(q, batch)
180
180
  if write_matching_batch(conn, batch, use_wire)
181
181
  conn.flush
182
- batch.each { |parts| @engine.emit_verbose_monitor_event(:message_sent, parts: parts) }
182
+ batch.each { |parts| @engine.emit_verbose_msg_sent(conn, parts) }
183
183
  end
184
184
  end
185
185
  end
@@ -227,7 +227,7 @@ module OMQ
227
227
 
228
228
  conn.write_message(latest)
229
229
  conn.flush
230
- @engine.emit_verbose_monitor_event(:message_sent, parts: latest)
230
+ @engine.emit_verbose_msg_sent(conn, latest)
231
231
  end
232
232
  end
233
233
  end
@@ -96,7 +96,7 @@ module OMQ
96
96
  conn.write_messages(batch)
97
97
  end
98
98
  conn.flush
99
- batch.each { |parts| @engine.emit_verbose_monitor_event(:message_sent, parts: parts) }
99
+ batch.each { |parts| @engine.emit_verbose_msg_sent(conn, parts) }
100
100
  end
101
101
  end
102
102
  @tasks << @send_pump
@@ -134,7 +134,7 @@ module OMQ
134
134
  ensure
135
135
  @in_flight -= batch.size
136
136
  end
137
- batch.each { |parts| @engine.emit_verbose_monitor_event(:message_sent, parts: parts) }
137
+ batch.each { |parts| @engine.emit_verbose_msg_sent(conn, parts) }
138
138
  Async::Task.current.yield
139
139
  end
140
140
  end
@@ -147,16 +147,26 @@ module OMQ
147
147
  BATCH_BYTE_CAP = 512 * 1024
148
148
 
149
149
  def drain_send_queue_capped(batch)
150
- bytes = batch[0].sum(&:bytesize)
150
+ bytes = batch_bytes(batch[0])
151
151
  while batch.size < BATCH_MSG_CAP && bytes < BATCH_BYTE_CAP
152
152
  msg = @send_queue.dequeue(timeout: 0)
153
153
  break unless msg
154
154
  batch << msg
155
- bytes += msg.sum(&:bytesize)
155
+ bytes += batch_bytes(msg)
156
156
  end
157
157
  end
158
158
 
159
159
 
160
+ # Byte accounting for send-queue batching. Connection wrappers
161
+ # (e.g. OMQ::Ractor's MarshalConnection) may enqueue non-string
162
+ # parts that get transformed at write time — skip those for the
163
+ # fairness cap rather than crashing on #bytesize.
164
+ #
165
+ def batch_bytes(parts)
166
+ parts.sum { |p| p.respond_to?(:bytesize) ? p.bytesize : 0 }
167
+ end
168
+
169
+
160
170
  def write_batch(conn, batch)
161
171
  if batch.size == 1
162
172
  conn.send_message(transform_send(batch[0]))
data/lib/omq/socket.rb CHANGED
@@ -16,6 +16,13 @@ module OMQ
16
16
  attr_reader :last_tcp_port
17
17
 
18
18
 
19
+ # @return [Engine] the socket's engine. Exposed for peer tooling
20
+ # (omq-cli, omq-ffi, omq-ractor) that needs to reach into the
21
+ # socket's internals — not part of the stable user API.
22
+ #
23
+ attr_reader :engine
24
+
25
+
19
26
  # Delegate socket option accessors to @options.
20
27
  #
21
28
  extend Forwardable
@@ -79,7 +79,7 @@ module OMQ
79
79
  if abstract?(path)
80
80
  "\0#{path[1..]}"
81
81
  else
82
- path # TODO: return Pathname
82
+ path
83
83
  end
84
84
  end
85
85
 
@@ -106,7 +106,7 @@ module OMQ
106
106
 
107
107
  # @param endpoint [String] the IPC endpoint URI
108
108
  # @param server [UNIXServer]
109
- # @param path [String] filesystem or abstract namespace path # TODO: Pathname
109
+ # @param path [String] filesystem or abstract namespace path
110
110
  # @param engine [Engine]
111
111
  #
112
112
  def initialize(endpoint, server, path, engine)
@@ -124,15 +124,14 @@ module OMQ
124
124
  # @param parent_task [Async::Task]
125
125
  # @yieldparam io [IO::Stream::Buffered]
126
126
  #
127
- def start_accept_loops(parent_task, &on_accepted)
127
+ def start_accept_loops(parent_task)
128
128
  annotation = "ipc accept #{@endpoint}"
129
129
  @task = parent_task.async(transient: true, annotation:) do
130
130
  loop do
131
131
  client = @server.accept
132
132
  IPC.apply_buffer_sizes(client, @engine.options)
133
133
  Async::Task.current.defer_stop do
134
- # TODO use yield
135
- on_accepted.call(IO::Stream::Buffered.wrap(client))
134
+ yield IO::Stream::Buffered.wrap(client)
136
135
  end
137
136
  end
138
137
  rescue Async::Stop
@@ -155,7 +154,7 @@ module OMQ
155
154
  @server.close rescue nil
156
155
 
157
156
  # Clean up socket file for file-based paths
158
- unless @path.start_with?("@") # TODO: check if it's a Pathname instead
157
+ unless @path.start_with?("@")
159
158
  File.delete(@path) rescue nil
160
159
  end
161
160
  end
@@ -17,23 +17,19 @@ module OMQ
17
17
  # @return [Listener]
18
18
  #
19
19
  def bind(endpoint, engine)
20
- host, port = self.parse_endpoint(endpoint)
21
- host = "0.0.0.0" if host == "*" # FIXME: support IPv6, see omq-cli v0.11.2
22
-
23
- addrs = Addrinfo.getaddrinfo(host, port, nil, :STREAM, nil, ::Socket::AI_PASSIVE)
24
- raise ::Socket::ResolutionError, "no addresses for #{host}" if addrs.empty?
25
-
26
- servers = []
27
- actual_port = nil
28
-
29
- addrs.each do |addr|
30
- server = TCPServer.new(addr.ip_address, actual_port || port)
31
- actual_port ||= server.local_address.ip_port
32
- servers << server
33
- end
34
-
35
- host_part = host.include?(":") ? "[#{host}]" : host
36
- resolved = "tcp://#{host_part}:#{actual_port}"
20
+ host, port = self.parse_endpoint(endpoint)
21
+ lookup_host = normalize_bind_host(host)
22
+
23
+ # Socket.tcp_server_sockets coordinates ephemeral ports across
24
+ # address families and sets IPV6_V6ONLY so IPv4 and IPv6
25
+ # wildcards don't collide on Linux.
26
+ servers = ::Socket.tcp_server_sockets(lookup_host, port)
27
+ raise ::Socket::ResolutionError, "no addresses for #{host.inspect}" if servers.empty?
28
+
29
+ actual_port = servers.first.local_address.ip_port
30
+ display_host = host == "*" ? "*" : (lookup_host || "*")
31
+ host_part = display_host.include?(":") ? "[#{display_host}]" : display_host
32
+ resolved = "tcp://#{host_part}:#{actual_port}"
37
33
  Listener.new(resolved, servers, actual_port, engine)
38
34
  end
39
35
 
@@ -45,7 +41,8 @@ module OMQ
45
41
  #
46
42
  def validate_endpoint!(endpoint)
47
43
  host, _port = parse_endpoint(endpoint)
48
- Addrinfo.getaddrinfo(host, nil, nil, :STREAM) if host
44
+ lookup_host = normalize_bind_host(host)
45
+ Addrinfo.getaddrinfo(lookup_host, nil, nil, :STREAM) if lookup_host
49
46
  end
50
47
 
51
48
 
@@ -57,12 +54,54 @@ module OMQ
57
54
  #
58
55
  def connect(endpoint, engine)
59
56
  host, port = self.parse_endpoint(endpoint)
60
- sock = ::Socket.tcp(host, port, connect_timeout: connect_timeout(engine.options))
57
+ host = normalize_connect_host(host)
58
+ sock = ::Socket.tcp(host, port, connect_timeout: connect_timeout(engine.options))
61
59
  apply_buffer_sizes(sock, engine.options)
62
60
  engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
63
61
  end
64
62
 
65
63
 
64
+ # Normalizes the bind host:
65
+ # "*" → nil (dual-stack wildcard via AI_PASSIVE)
66
+ # "" / nil / "localhost" → loopback_host (::1 on IPv6-capable hosts, else 127.0.0.1)
67
+ # else → unchanged
68
+ #
69
+ def normalize_bind_host(host)
70
+ case host
71
+ when "*" then nil
72
+ when nil, "", "localhost" then loopback_host
73
+ else host
74
+ end
75
+ end
76
+
77
+
78
+ # Normalizes the connect host: "", nil, "*", and "localhost" all
79
+ # map to the loopback host. Everything else is passed through so
80
+ # real hostnames still go through the resolver + Happy Eyeballs.
81
+ #
82
+ def normalize_connect_host(host)
83
+ case host
84
+ when nil, "", "*", "localhost" then loopback_host
85
+ else host
86
+ end
87
+ end
88
+
89
+
90
+ # Loopback address preference for bind/connect normalization.
91
+ # Returns "::1" when the host has at least one non-loopback,
92
+ # non-link-local IPv6 address, otherwise "127.0.0.1".
93
+ #
94
+ def loopback_host
95
+ @loopback_host ||= begin
96
+ has_ipv6 = ::Socket.getifaddrs.any? do |ifa|
97
+ addr = ifa.addr
98
+ addr&.ipv6? && !addr.ipv6_loopback? && !addr.ipv6_linklocal?
99
+ end
100
+ has_ipv6 ? "::1" : "127.0.0.1"
101
+ end
102
+ end
103
+
104
+
66
105
  # Connect timeout: cap each attempt at the reconnect interval so a
67
106
  # hung connect(2) (e.g. macOS kqueue + IPv6 ECONNREFUSED not delivered)
68
107
  # doesn't block the retry loop. Floor at 0.5s for real-network latency.
@@ -114,13 +153,13 @@ module OMQ
114
153
  #
115
154
  attr_reader :port
116
155
 
117
- # @return [Array<TCPServer>] bound server sockets
156
+ # @return [Array<Socket>] bound server sockets
118
157
  #
119
158
  attr_reader :servers
120
159
 
121
160
 
122
161
  # @param endpoint [String] resolved endpoint URI
123
- # @param servers [Array<TCPServer>]
162
+ # @param servers [Array<Socket>]
124
163
  # @param port [Integer] bound port number
125
164
  # @param engine [Engine]
126
165
  #
@@ -139,17 +178,16 @@ module OMQ
139
178
  # @param parent_task [Async::Task]
140
179
  # @yieldparam io [IO::Stream::Buffered]
141
180
  #
142
- def start_accept_loops(parent_task, &on_accepted)
181
+ def start_accept_loops(parent_task)
143
182
  @tasks = @servers.map do |server|
144
183
  # TODO: use this server's exact host:port (@endpoint might not be unique)
145
184
  annotation = "tcp accept #{@endpoint}"
146
185
  parent_task.async(transient: true, annotation:) do
147
186
  loop do
148
- client = server.accept
187
+ client, _addr = server.accept
149
188
  TCP.apply_buffer_sizes(client, @engine.options)
150
189
  Async::Task.current.defer_stop do
151
- # TODO: why not yield?
152
- on_accepted.call(IO::Stream::Buffered.wrap(client))
190
+ yield IO::Stream::Buffered.wrap(client)
153
191
  end
154
192
  end
155
193
  rescue Async::Stop
data/lib/omq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OMQ
4
- VERSION = "0.18.0"
4
+ VERSION = "0.19.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger