omq 0.18.0 → 0.19.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4c49622de9b6972433ec90b148e0d20e68f69d5bb4939debd5abd5095d0396b
4
- data.tar.gz: a70873e070a90ac7b804585231180926ef030e8ffeb9cf65fc44e889dc3619e2
3
+ metadata.gz: f01c18a7a887e0f2811abff2251f1b2c9b201091e800248fb852b92bedbcaf06
4
+ data.tar.gz: d32b3e07223d906a3bb25fc8542409bb5c05af36b509f7fe70c91419d0836cf9
5
5
  SHA512:
6
- metadata.gz: a4c994245259282909189bcd44d69ef9153b52ef1dd2675b8cf8635b8970ed9c630048b55837cdc0fa20ecb04c00edd9b3bff17e5d9ad93d70107eb8701b08a3
7
- data.tar.gz: d3f88cd73a8ea21fb572518a8f9b8128244f2bb10cfcf0e43b8ac73a797158ef1c2a59fe8fe75be407eac6277f8ebd05bd7ab554f6fa3a4d1e215ccaf3c034ab
6
+ metadata.gz: c0b26ad39e26fe586cec53371bc4b1beeaf357b51f32e90ad8d889a8413f62c08c13be2d8f787e3528d4fca6bccc1ceae21454474465aff25f9418c37460d957
7
+ data.tar.gz: b8d3acedee187c9c757268af173b55bd28daf502f8e054f92ef670c59a1781e2b2f73267425c239d27acc7cb036b0030ab562a12918c7db205dc34f4bcf8e38d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0 — 2026-04-12
4
+
5
+ ### Added
6
+
7
+ - **Verbose-monitor helpers `Engine#emit_verbose_msg_sent` and
8
+ `#emit_verbose_msg_received`.** Used by `RecvPump` and every
9
+ send-pump routing strategy (`conn_send_pump`, `round_robin`,
10
+ `pair`, `fan_out`) to emit `:message_sent` / `:message_received`
11
+ monitor events with a connection reference. When the connection
12
+ exposes `#last_wire_size_out` / `#last_wire_size_in` (as the
13
+ `omq-rfc-zstd` `CompressionConnection` wrapper does), the event
14
+ detail includes `wire_size:` so verbose traces can annotate
15
+ compressed message previews with the post-compression byte count.
16
+ `RecvPump` now emits the trace *before* enqueueing the message
17
+ so the monitor fiber runs before the application fiber, which
18
+ preserves log-before-body ordering at `-vvv`.
19
+
20
+ ### Changed
21
+
22
+ - **`OMQ::Transport::TCP` normalizes host shorthands.** `tcp://*:PORT`
23
+ now binds *dual-stack* (both `0.0.0.0` and `::` on the same port,
24
+ with `IPV6_V6ONLY` set) rather than IPv4-only `0.0.0.0`, matching
25
+ [Puma v8.0.0's behavior](https://github.com/puma/puma/releases/tag/v8.0.0).
26
+ `tcp://:PORT`, `tcp://localhost:PORT`, and `tcp://*:PORT` on the
27
+ connect side all normalize to the loopback host — `::1` on
28
+ IPv6-capable machines (at least one non-loopback, non-link-local
29
+ IPv6 address), otherwise `127.0.0.1`. Explicit addresses
30
+ (`0.0.0.0`, `::`, `127.0.0.1`, `::1`) pass through unchanged.
31
+ Documented in `GETTING_STARTED.md` under "TCP host shorthands".
32
+ This normalization previously lived in `omq-cli` and is now
33
+ shared by all callers.
34
+
35
+ - **TCP accept loop uses `Socket.tcp_server_sockets`** instead of
36
+ manually iterating `Addrinfo.getaddrinfo` + `TCPServer.new`.
37
+ `tcp_server_sockets` handles dual-stack port coordination and
38
+ `IPV6_V6ONLY` automatically. `Listener#servers` now holds
39
+ `Socket` instances rather than `TCPServer`; `#accept` returns
40
+ `[client, addrinfo]` pairs, which the accept loop destructures.
41
+
42
+ - **`Listener#start_accept_loops` uses `yield`** instead of capturing
43
+ the block as an explicit `&on_accepted` proc. The block is bound
44
+ to the enclosing method even when invoked from inside a spawned
45
+ `Async::Task`, so the explicit capture was unnecessary. Applies
46
+ to both TCP and IPC transports.
47
+
3
48
  ## 0.18.0 — 2026-04-12
4
49
 
5
50
  ### Changed
@@ -94,8 +94,12 @@ 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
@@ -124,8 +128,8 @@ module OMQ
124
128
  bytes = 0
125
129
  while count < FAIRNESS_MESSAGES && bytes < FAIRNESS_BYTES
126
130
  msg = conn.receive_message
131
+ engine.emit_verbose_msg_received(conn, msg)
127
132
  recv_queue.enqueue(msg)
128
- engine.emit_verbose_monitor_event(:message_received, parts: msg)
129
133
  count += 1
130
134
  bytes += msg.sum(&:bytesize) if count_bytes
131
135
  end
data/lib/omq/engine.rb CHANGED
@@ -461,6 +461,28 @@ module OMQ
461
461
  end
462
462
 
463
463
 
464
+ # Emits a :message_sent verbose event and enriches it with the
465
+ # on-wire (post-compression) byte size if +conn+ exposes
466
+ # +last_wire_size_out+ (installed by ZMTP-Zstd etc.).
467
+ def emit_verbose_msg_sent(conn, parts)
468
+ return unless @verbose_monitor
469
+ detail = { parts: parts }
470
+ detail[:wire_size] = conn.last_wire_size_out if conn.respond_to?(:last_wire_size_out)
471
+ emit_monitor_event(:message_sent, detail: detail)
472
+ end
473
+
474
+
475
+ # Emits a :message_received verbose event and enriches it with the
476
+ # on-wire (pre-decompression) byte size if +conn+ exposes
477
+ # +last_wire_size_in+.
478
+ def emit_verbose_msg_received(conn, parts)
479
+ return unless @verbose_monitor
480
+ detail = { parts: parts }
481
+ detail[:wire_size] = conn.last_wire_size_in if conn.respond_to?(:last_wire_size_in)
482
+ emit_monitor_event(:message_received, detail: detail)
483
+ end
484
+
485
+
464
486
  # Looks up the transport module for an endpoint URI.
465
487
  #
466
488
  # @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
@@ -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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger