omq 0.17.9 → 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.
@@ -37,7 +37,10 @@ module OMQ
37
37
  #
38
38
  def bind(endpoint, engine)
39
39
  @mutex.synchronize do
40
- raise ArgumentError, "endpoint already bound: #{endpoint}" if @registry.key?(endpoint)
40
+ if @registry.key?(endpoint)
41
+ raise ArgumentError, "endpoint already bound: #{endpoint}"
42
+ end
43
+
41
44
  @registry[endpoint] = engine
42
45
 
43
46
  # Wake any pending connects
@@ -96,34 +99,55 @@ module OMQ
96
99
  def establish_link(client_engine, server_engine, endpoint)
97
100
  client_type = client_engine.socket_type
98
101
  server_type = server_engine.socket_type
102
+
99
103
  unless Protocol::ZMTP::VALID_PEERS[client_type]&.include?(server_type)
100
104
  raise Protocol::ZMTP::Error,
101
105
  "incompatible socket types: #{client_type} cannot connect to #{server_type}"
102
106
  end
107
+
103
108
  needs_cmds = needs_commands?(client_engine, server_engine, client_type, server_type)
104
- client_pipe, server_pipe = make_pipe_pair(client_engine, server_engine, client_type, server_type, needs_cmds)
109
+ client_pipe, server_pipe = make_pipe_pair client_engine, server_engine,
110
+ client_type, server_type, needs_cmds
111
+
105
112
  client_engine.connection_ready(client_pipe, endpoint: endpoint)
106
113
  server_engine.connection_ready(server_pipe, endpoint: endpoint)
107
114
  end
108
115
 
109
116
 
117
+ # Decides whether a DirectPipe pair needs command queues.
118
+ # DirectPipe's fast path skips queues entirely; command queues
119
+ # are only needed for socket types that exchange ZMTP commands
120
+ # (e.g. ROUTER/DEALER identity, PUB/SUB subscriptions) or when
121
+ # either side enables QoS ≥ 1.
122
+ #
123
+ # @return [Boolean]
124
+ #
110
125
  def needs_commands?(ce, se, ct, st)
111
- COMMAND_TYPES.include?(ct) || COMMAND_TYPES.include?(st) ||
112
- ce.options.qos >= 1 || se.options.qos >= 1
126
+ return true if COMMAND_TYPES.include?(ct) || COMMAND_TYPES.include?(st)
127
+ return true if ce.options.qos >= 1 || se.options.qos >= 1
128
+ false
113
129
  end
114
130
 
115
131
 
132
+ # Builds a bidirectional {DirectPipe} pair for client + server.
133
+ # When +needs_cmds+ is false the pipes have no command queues
134
+ # (fast path — all traffic bypasses Async::Queue entirely).
135
+ #
136
+ # @return [Array(DirectPipe, DirectPipe)] client, server
137
+ #
116
138
  def make_pipe_pair(ce, se, ct, st, needs_cmds)
117
139
  if needs_cmds
118
140
  a_to_b = Async::Queue.new
119
141
  b_to_a = Async::Queue.new
120
142
  end
143
+
121
144
  client = DirectPipe.new(send_queue: needs_cmds ? a_to_b : nil,
122
145
  receive_queue: needs_cmds ? b_to_a : nil,
123
146
  peer_identity: se.options.identity, peer_type: st.to_s)
124
147
  server = DirectPipe.new(send_queue: needs_cmds ? b_to_a : nil,
125
148
  receive_queue: needs_cmds ? a_to_b : nil,
126
149
  peer_identity: ce.options.identity, peer_type: ct.to_s)
150
+
127
151
  client.peer = server
128
152
  server.peer = client
129
153
  [client, server]
@@ -136,11 +160,20 @@ module OMQ
136
160
  ri = engine.options.reconnect_interval
137
161
  timeout = ri.is_a?(Range) ? ri.begin : ri
138
162
  promise = Async::Promise.new
139
- @mutex.synchronize { @waiters[endpoint] << promise }
163
+
164
+ @mutex.synchronize do
165
+ @waiters[endpoint] << promise
166
+ end
167
+
140
168
  if promise.wait?(timeout: timeout)
141
- @mutex.synchronize { @registry[endpoint] }
169
+ @mutex.synchronize do
170
+ @registry[endpoint]
171
+ end
142
172
  else
143
- @mutex.synchronize { @waiters[endpoint].delete(promise) }
173
+ @mutex.synchronize do
174
+ @waiters[endpoint].delete(promise)
175
+ end
176
+
144
177
  start_connect_retry(endpoint, engine)
145
178
  nil
146
179
  end
@@ -158,6 +191,7 @@ module OMQ
158
191
  loop do
159
192
  sleep ivl
160
193
  bound_engine = @mutex.synchronize { @registry[endpoint] }
194
+
161
195
  if bound_engine
162
196
  establish_link(engine, bound_engine, endpoint)
163
197
  break
@@ -19,7 +19,7 @@ module OMQ
19
19
  # @return [Listener]
20
20
  #
21
21
  def bind(endpoint, engine)
22
- path = parse_path(endpoint)
22
+ path = parse_path(endpoint)
23
23
  sock_path = to_socket_path(path)
24
24
 
25
25
  # Remove stale socket file for file-based paths
@@ -45,17 +45,31 @@ module OMQ
45
45
  engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
46
46
  end
47
47
 
48
+
49
+ # Applies SO_SNDBUF / SO_RCVBUF to +sock+ from the socket's
50
+ # {Options}. No-op when both are nil (OS default).
51
+ #
52
+ # @param sock [UNIXSocket, UNIXServer]
53
+ # @param options [Options]
54
+ #
48
55
  def apply_buffer_sizes(sock, options)
49
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, options.sndbuf) if options.sndbuf
50
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, options.rcvbuf) if options.rcvbuf
56
+ if options.sndbuf
57
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, options.sndbuf)
58
+ end
59
+
60
+ if options.rcvbuf
61
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, options.rcvbuf)
62
+ end
51
63
  end
52
64
 
65
+
53
66
  private
54
67
 
68
+
55
69
  # Extracts path from "ipc://path".
56
70
  #
57
71
  def parse_path(endpoint)
58
- endpoint.sub(%r{\Aipc://}, "")
72
+ endpoint.delete_prefix("ipc://")
59
73
  end
60
74
 
61
75
 
@@ -110,12 +124,15 @@ module OMQ
110
124
  # @param parent_task [Async::Task]
111
125
  # @yieldparam io [IO::Stream::Buffered]
112
126
  #
113
- def start_accept_loops(parent_task, &on_accepted)
114
- @task = parent_task.async(transient: true, annotation: "ipc accept #{@endpoint}") do
127
+ def start_accept_loops(parent_task)
128
+ annotation = "ipc accept #{@endpoint}"
129
+ @task = parent_task.async(transient: true, annotation:) do
115
130
  loop do
116
131
  client = @server.accept
117
132
  IPC.apply_buffer_sizes(client, @engine.options)
118
- Async::Task.current.defer_stop { on_accepted.call(IO::Stream::Buffered.wrap(client)) }
133
+ Async::Task.current.defer_stop do
134
+ yield IO::Stream::Buffered.wrap(client)
135
+ end
119
136
  end
120
137
  rescue Async::Stop
121
138
  rescue IOError
@@ -135,11 +152,13 @@ module OMQ
135
152
  def stop
136
153
  @task&.stop
137
154
  @server.close rescue nil
155
+
138
156
  # Clean up socket file for file-based paths
139
157
  unless @path.start_with?("@")
140
158
  File.delete(@path) rescue nil
141
159
  end
142
160
  end
161
+
143
162
  end
144
163
  end
145
164
  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 == "*"
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.
@@ -85,9 +124,20 @@ module OMQ
85
124
  end
86
125
 
87
126
 
127
+ # Applies SO_SNDBUF / SO_RCVBUF to +sock+ from the socket's
128
+ # {Options}. No-op when both are nil (OS default).
129
+ #
130
+ # @param sock [Socket, TCPSocket]
131
+ # @param options [Options]
132
+ #
88
133
  def apply_buffer_sizes(sock, options)
89
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, options.sndbuf) if options.sndbuf
90
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, options.rcvbuf) if options.rcvbuf
134
+ if options.sndbuf
135
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, options.sndbuf)
136
+ end
137
+
138
+ if options.rcvbuf
139
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, options.rcvbuf)
140
+ end
91
141
  end
92
142
  end
93
143
 
@@ -103,13 +153,13 @@ module OMQ
103
153
  #
104
154
  attr_reader :port
105
155
 
106
- # @return [Array<TCPServer>] bound server sockets
156
+ # @return [Array<Socket>] bound server sockets
107
157
  #
108
158
  attr_reader :servers
109
159
 
110
160
 
111
161
  # @param endpoint [String] resolved endpoint URI
112
- # @param servers [Array<TCPServer>]
162
+ # @param servers [Array<Socket>]
113
163
  # @param port [Integer] bound port number
114
164
  # @param engine [Engine]
115
165
  #
@@ -128,13 +178,17 @@ module OMQ
128
178
  # @param parent_task [Async::Task]
129
179
  # @yieldparam io [IO::Stream::Buffered]
130
180
  #
131
- def start_accept_loops(parent_task, &on_accepted)
181
+ def start_accept_loops(parent_task)
132
182
  @tasks = @servers.map do |server|
133
- parent_task.async(transient: true, annotation: "tcp accept #{@endpoint}") do
183
+ # TODO: use this server's exact host:port (@endpoint might not be unique)
184
+ annotation = "tcp accept #{@endpoint}"
185
+ parent_task.async(transient: true, annotation:) do
134
186
  loop do
135
- client = server.accept
187
+ client, _addr = server.accept
136
188
  TCP.apply_buffer_sizes(client, @engine.options)
137
- Async::Task.current.defer_stop { on_accepted.call(IO::Stream::Buffered.wrap(client)) }
189
+ Async::Task.current.defer_stop do
190
+ yield IO::Stream::Buffered.wrap(client)
191
+ end
138
192
  end
139
193
  rescue Async::Stop
140
194
  rescue IOError
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.17.9"
4
+ VERSION = "0.19.0"
5
5
  end
data/lib/omq/writable.rb CHANGED
@@ -15,7 +15,11 @@ module OMQ
15
15
  #
16
16
  def send(message)
17
17
  parts = freeze_message(message)
18
- Reactor.run { with_timeout(@options.write_timeout) { @engine.enqueue_send(parts) } }
18
+
19
+ Reactor.run timeout: @options.write_timeout do |task|
20
+ @engine.enqueue_send(parts)
21
+ end
22
+
19
23
  self
20
24
  end
21
25
 
data/lib/omq.rb CHANGED
@@ -38,6 +38,7 @@ module OMQ
38
38
  IO::Stream::ConnectionResetError,
39
39
  ]
40
40
 
41
+
41
42
  # Errors raised when a peer cannot be reached.
42
43
  CONNECTION_FAILED = [
43
44
  Errno::ECONNREFUSED,
@@ -57,7 +58,7 @@ module OMQ
57
58
  CONNECTION_LOST.freeze
58
59
  CONNECTION_FAILED.freeze
59
60
  Engine.transports.freeze
60
- Routing.instance_variable_get(:@registry).freeze
61
+ Routing.registry.freeze
61
62
  end
62
63
  end
63
64
 
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.17.9
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.4'
18
+ version: '0.6'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.4'
25
+ version: '0.6'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement