omq 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +15 -0
- data/README.md +145 -0
- data/lib/omq/pair.rb +13 -0
- data/lib/omq/pub_sub.rb +77 -0
- data/lib/omq/push_pull.rb +21 -0
- data/lib/omq/req_rep.rb +23 -0
- data/lib/omq/router_dealer.rb +36 -0
- data/lib/omq/socket.rb +178 -0
- data/lib/omq/version.rb +5 -0
- data/lib/omq/zmtp/codec/command.rb +207 -0
- data/lib/omq/zmtp/codec/frame.rb +104 -0
- data/lib/omq/zmtp/codec/greeting.rb +96 -0
- data/lib/omq/zmtp/codec.rb +18 -0
- data/lib/omq/zmtp/connection.rb +233 -0
- data/lib/omq/zmtp/engine.rb +339 -0
- data/lib/omq/zmtp/mechanism/null.rb +70 -0
- data/lib/omq/zmtp/options.rb +57 -0
- data/lib/omq/zmtp/reactor.rb +142 -0
- data/lib/omq/zmtp/readable.rb +29 -0
- data/lib/omq/zmtp/routing/dealer.rb +57 -0
- data/lib/omq/zmtp/routing/fan_out.rb +89 -0
- data/lib/omq/zmtp/routing/pair.rb +68 -0
- data/lib/omq/zmtp/routing/pub.rb +62 -0
- data/lib/omq/zmtp/routing/pull.rb +48 -0
- data/lib/omq/zmtp/routing/push.rb +57 -0
- data/lib/omq/zmtp/routing/rep.rb +83 -0
- data/lib/omq/zmtp/routing/req.rb +70 -0
- data/lib/omq/zmtp/routing/round_robin.rb +69 -0
- data/lib/omq/zmtp/routing/router.rb +88 -0
- data/lib/omq/zmtp/routing/sub.rb +80 -0
- data/lib/omq/zmtp/routing/xpub.rb +74 -0
- data/lib/omq/zmtp/routing/xsub.rb +80 -0
- data/lib/omq/zmtp/routing.rb +38 -0
- data/lib/omq/zmtp/transport/inproc.rb +299 -0
- data/lib/omq/zmtp/transport/ipc.rb +114 -0
- data/lib/omq/zmtp/transport/tcp.rb +98 -0
- data/lib/omq/zmtp/valid_peers.rb +21 -0
- data/lib/omq/zmtp/writable.rb +44 -0
- data/lib/omq/zmtp.rb +47 -0
- data/lib/omq.rb +19 -0
- metadata +110 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
module ZMTP
|
|
7
|
+
# Per-socket orchestrator.
|
|
8
|
+
#
|
|
9
|
+
# Manages connections, transports, and the routing strategy for one
|
|
10
|
+
# OMQ::Socket instance. Each socket type creates one Engine.
|
|
11
|
+
#
|
|
12
|
+
class Engine
|
|
13
|
+
# @return [Symbol] socket type (e.g. :REQ, :PAIR)
|
|
14
|
+
#
|
|
15
|
+
attr_reader :socket_type
|
|
16
|
+
|
|
17
|
+
# @return [Options] socket options
|
|
18
|
+
#
|
|
19
|
+
attr_reader :options
|
|
20
|
+
|
|
21
|
+
# @return [Routing] routing strategy
|
|
22
|
+
#
|
|
23
|
+
attr_reader :routing
|
|
24
|
+
|
|
25
|
+
# @return [String, nil] last bound endpoint
|
|
26
|
+
#
|
|
27
|
+
attr_reader :last_endpoint
|
|
28
|
+
|
|
29
|
+
# @return [Integer, nil] last auto-selected TCP port
|
|
30
|
+
#
|
|
31
|
+
attr_reader :last_tcp_port
|
|
32
|
+
|
|
33
|
+
# @param socket_type [Symbol] e.g. :REQ, :REP, :PAIR
|
|
34
|
+
# @param options [Options]
|
|
35
|
+
#
|
|
36
|
+
def initialize(socket_type, options)
|
|
37
|
+
@socket_type = socket_type
|
|
38
|
+
@options = options
|
|
39
|
+
@routing = Routing.for(socket_type).new(self)
|
|
40
|
+
@connections = []
|
|
41
|
+
@connection_endpoints = {} # connection => endpoint (for reconnection)
|
|
42
|
+
@connected_endpoints = [] # endpoints we connected to (not bound)
|
|
43
|
+
@listeners = []
|
|
44
|
+
@tasks = []
|
|
45
|
+
@closed = false
|
|
46
|
+
@last_endpoint = nil
|
|
47
|
+
@last_tcp_port = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Binds to an endpoint.
|
|
51
|
+
#
|
|
52
|
+
# @param endpoint [String] e.g. "tcp://127.0.0.1:5555", "inproc://foo"
|
|
53
|
+
# @return [void]
|
|
54
|
+
# @raise [ArgumentError] on unsupported transport
|
|
55
|
+
#
|
|
56
|
+
def bind(endpoint)
|
|
57
|
+
transport = transport_for(endpoint)
|
|
58
|
+
listener = transport.bind(endpoint, self)
|
|
59
|
+
@listeners << listener
|
|
60
|
+
@last_endpoint = listener.endpoint
|
|
61
|
+
@last_tcp_port = extract_tcp_port(listener.endpoint)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Connects to an endpoint.
|
|
65
|
+
#
|
|
66
|
+
# @param endpoint [String]
|
|
67
|
+
# @return [void]
|
|
68
|
+
#
|
|
69
|
+
def connect(endpoint)
|
|
70
|
+
@connected_endpoints << endpoint
|
|
71
|
+
transport = transport_for(endpoint)
|
|
72
|
+
transport.connect(endpoint, self)
|
|
73
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT, Errno::ETIMEDOUT, IOError, ProtocolError
|
|
74
|
+
# Server not up yet — schedule background reconnect
|
|
75
|
+
schedule_reconnect(endpoint)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Disconnects from an endpoint. Closes connections to that endpoint
|
|
79
|
+
# and stops auto-reconnection for it.
|
|
80
|
+
#
|
|
81
|
+
# @param endpoint [String]
|
|
82
|
+
# @return [void]
|
|
83
|
+
#
|
|
84
|
+
def disconnect(endpoint)
|
|
85
|
+
@connected_endpoints.delete(endpoint)
|
|
86
|
+
conns = @connection_endpoints.select { |_, ep| ep == endpoint }.keys
|
|
87
|
+
conns.each do |conn|
|
|
88
|
+
@connection_endpoints.delete(conn)
|
|
89
|
+
@connections.delete(conn)
|
|
90
|
+
@routing.connection_removed(conn)
|
|
91
|
+
conn.close
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Unbinds from an endpoint. Stops the listener and closes all
|
|
96
|
+
# connections that were accepted on it.
|
|
97
|
+
#
|
|
98
|
+
# @param endpoint [String]
|
|
99
|
+
# @return [void]
|
|
100
|
+
#
|
|
101
|
+
def unbind(endpoint)
|
|
102
|
+
listener = @listeners.find { |l| l.endpoint == endpoint }
|
|
103
|
+
return unless listener
|
|
104
|
+
listener.stop
|
|
105
|
+
@listeners.delete(listener)
|
|
106
|
+
|
|
107
|
+
# Close connections accepted on this endpoint
|
|
108
|
+
conns = @connection_endpoints.select { |_, ep| ep == endpoint }.keys
|
|
109
|
+
conns.each do |conn|
|
|
110
|
+
@connection_endpoints.delete(conn)
|
|
111
|
+
@connections.delete(conn)
|
|
112
|
+
@routing.connection_removed(conn)
|
|
113
|
+
conn.close
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Called by a transport when an incoming connection is accepted.
|
|
118
|
+
#
|
|
119
|
+
# @param io [#read, #write, #close]
|
|
120
|
+
# @param endpoint [String, nil] the endpoint this was accepted on
|
|
121
|
+
# @return [void]
|
|
122
|
+
#
|
|
123
|
+
def handle_accepted(io, endpoint: nil)
|
|
124
|
+
setup_connection(io, as_server: true, endpoint: endpoint)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Called by a transport when an outgoing connection is established.
|
|
128
|
+
#
|
|
129
|
+
# @param io [#read, #write, #close]
|
|
130
|
+
# @return [void]
|
|
131
|
+
#
|
|
132
|
+
def handle_connected(io, endpoint: nil)
|
|
133
|
+
setup_connection(io, as_server: false, endpoint: endpoint)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Called by inproc transport with a pre-validated DirectPipe.
|
|
137
|
+
# Skips ZMTP handshake — just registers with routing strategy.
|
|
138
|
+
#
|
|
139
|
+
# @param pipe [Transport::Inproc::DirectPipe]
|
|
140
|
+
# @return [void]
|
|
141
|
+
#
|
|
142
|
+
def connection_ready(pipe, endpoint: nil)
|
|
143
|
+
@connections << pipe
|
|
144
|
+
@connection_endpoints[pipe] = endpoint if endpoint
|
|
145
|
+
@routing.connection_added(pipe)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Dequeues the next received message. Blocks until available.
|
|
149
|
+
#
|
|
150
|
+
# @return [Array<String>] message parts
|
|
151
|
+
#
|
|
152
|
+
def dequeue_recv
|
|
153
|
+
@routing.recv_queue.dequeue
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Enqueues a message for sending. Blocks at HWM.
|
|
157
|
+
#
|
|
158
|
+
# @param parts [Array<String>]
|
|
159
|
+
# @return [void]
|
|
160
|
+
#
|
|
161
|
+
def enqueue_send(parts)
|
|
162
|
+
@routing.enqueue(parts)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Starts a recv pump for a connection, or wires the inproc
|
|
166
|
+
# fast path when the connection is a DirectPipe.
|
|
167
|
+
#
|
|
168
|
+
# @param conn [Connection, Transport::Inproc::DirectPipe]
|
|
169
|
+
# @param recv_queue [Async::LimitedQueue] routing strategy's recv queue
|
|
170
|
+
# @param transform [#call, nil] optional message transform
|
|
171
|
+
# @return [#stop, nil] pump task handle, or nil for DirectPipe bypass
|
|
172
|
+
#
|
|
173
|
+
def start_recv_pump(conn, recv_queue, transform: nil)
|
|
174
|
+
if conn.is_a?(Transport::Inproc::DirectPipe) && conn.peer
|
|
175
|
+
conn.peer.direct_recv_queue = recv_queue
|
|
176
|
+
conn.peer.direct_recv_transform = transform
|
|
177
|
+
return nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
Reactor.spawn_pump do
|
|
181
|
+
loop do
|
|
182
|
+
msg = conn.receive_message
|
|
183
|
+
msg = transform ? transform.call(msg) : msg
|
|
184
|
+
recv_queue.enqueue(msg)
|
|
185
|
+
end
|
|
186
|
+
rescue EOFError, IOError
|
|
187
|
+
connection_lost(conn)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Called when a connection is lost.
|
|
192
|
+
#
|
|
193
|
+
# @param connection [Connection]
|
|
194
|
+
# @return [void]
|
|
195
|
+
#
|
|
196
|
+
def connection_lost(connection)
|
|
197
|
+
endpoint = @connection_endpoints.delete(connection)
|
|
198
|
+
@connections.delete(connection)
|
|
199
|
+
@routing.connection_removed(connection)
|
|
200
|
+
connection.close
|
|
201
|
+
|
|
202
|
+
# Auto-reconnect if this was a connected (not bound) endpoint
|
|
203
|
+
if endpoint && @connected_endpoints.include?(endpoint) && !@closed
|
|
204
|
+
schedule_reconnect(endpoint)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Closes all connections and listeners.
|
|
209
|
+
#
|
|
210
|
+
# @return [void]
|
|
211
|
+
#
|
|
212
|
+
def close
|
|
213
|
+
return if @closed
|
|
214
|
+
@closed = true
|
|
215
|
+
|
|
216
|
+
# Linger: wait for send queues to drain before closing.
|
|
217
|
+
# linger=0 → close immediately, linger=nil → wait forever.
|
|
218
|
+
linger = @options.linger
|
|
219
|
+
if linger.nil? || linger > 0
|
|
220
|
+
drain_timeout = linger # nil = wait forever, >0 = seconds
|
|
221
|
+
drain_send_queues(drain_timeout)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Close connections — causes pump tasks to get EOFError/IOError
|
|
225
|
+
@connections.each(&:close)
|
|
226
|
+
@connections.clear
|
|
227
|
+
@listeners.each(&:stop)
|
|
228
|
+
@listeners.clear
|
|
229
|
+
# Stop any remaining pump tasks
|
|
230
|
+
@routing.stop rescue nil
|
|
231
|
+
@tasks.each { |t| t.stop rescue nil }
|
|
232
|
+
@tasks.clear
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
# Waits for the send queue to drain.
|
|
238
|
+
#
|
|
239
|
+
# @param timeout [Numeric, nil] max seconds to wait (nil = forever)
|
|
240
|
+
#
|
|
241
|
+
def drain_send_queues(timeout)
|
|
242
|
+
return unless @routing.respond_to?(:send_queue)
|
|
243
|
+
deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
|
|
244
|
+
|
|
245
|
+
until @routing.send_queue.empty?
|
|
246
|
+
if deadline
|
|
247
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
248
|
+
break if remaining <= 0
|
|
249
|
+
end
|
|
250
|
+
sleep 0.001
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def setup_connection(io, as_server:, endpoint: nil)
|
|
255
|
+
conn = Connection.new(
|
|
256
|
+
io,
|
|
257
|
+
socket_type: @socket_type.to_s,
|
|
258
|
+
identity: @options.identity,
|
|
259
|
+
as_server: as_server,
|
|
260
|
+
mechanism: build_mechanism,
|
|
261
|
+
heartbeat_interval: @options.heartbeat_interval,
|
|
262
|
+
heartbeat_ttl: @options.heartbeat_ttl,
|
|
263
|
+
heartbeat_timeout: @options.heartbeat_timeout,
|
|
264
|
+
max_message_size: @options.max_message_size,
|
|
265
|
+
)
|
|
266
|
+
conn.handshake!
|
|
267
|
+
conn.start_heartbeat
|
|
268
|
+
@connections << conn
|
|
269
|
+
@connection_endpoints[conn] = endpoint if endpoint
|
|
270
|
+
@routing.connection_added(conn)
|
|
271
|
+
rescue ProtocolError, EOFError
|
|
272
|
+
conn&.close
|
|
273
|
+
raise
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def schedule_reconnect(endpoint)
|
|
277
|
+
ri = @options.reconnect_interval
|
|
278
|
+
if ri.is_a?(Range)
|
|
279
|
+
delay = ri.begin
|
|
280
|
+
max_delay = ri.end
|
|
281
|
+
else
|
|
282
|
+
delay = ri
|
|
283
|
+
max_delay = nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
@tasks << Reactor.spawn_pump do
|
|
287
|
+
loop do
|
|
288
|
+
break if @closed
|
|
289
|
+
sleep delay
|
|
290
|
+
break if @closed
|
|
291
|
+
begin
|
|
292
|
+
transport = transport_for(endpoint)
|
|
293
|
+
transport.connect(endpoint, self)
|
|
294
|
+
break # reconnected successfully
|
|
295
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, IOError, ProtocolError
|
|
296
|
+
delay = [delay * 2, max_delay].min if max_delay
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_mechanism
|
|
304
|
+
case @options.mechanism
|
|
305
|
+
when :null
|
|
306
|
+
Mechanism::Null.new
|
|
307
|
+
when :curve
|
|
308
|
+
unless defined?(Mechanism::Curve)
|
|
309
|
+
raise LoadError, "require 'omq-curve' to use CURVE security"
|
|
310
|
+
end
|
|
311
|
+
Mechanism::Curve.new(
|
|
312
|
+
server_key: @options.curve_server_key,
|
|
313
|
+
public_key: @options.curve_public_key,
|
|
314
|
+
secret_key: @options.curve_secret_key,
|
|
315
|
+
as_server: @options.curve_server,
|
|
316
|
+
authenticator: @options.curve_authenticator,
|
|
317
|
+
)
|
|
318
|
+
else
|
|
319
|
+
raise ArgumentError, "unknown mechanism: #{@options.mechanism}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def transport_for(endpoint)
|
|
324
|
+
case endpoint
|
|
325
|
+
when /\Atcp:\/\// then Transport::TCP
|
|
326
|
+
when /\Aipc:\/\// then Transport::IPC
|
|
327
|
+
when /\Ainproc:\/\// then Transport::Inproc
|
|
328
|
+
else raise ArgumentError, "unsupported transport: #{endpoint}"
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def extract_tcp_port(endpoint)
|
|
333
|
+
return nil unless endpoint&.start_with?("tcp://")
|
|
334
|
+
port = endpoint.split(":").last.to_i
|
|
335
|
+
port.positive? ? port : nil
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module ZMTP
|
|
5
|
+
module Mechanism
|
|
6
|
+
# NULL security mechanism — no encryption, no authentication.
|
|
7
|
+
#
|
|
8
|
+
# Performs the ZMTP 3.1 greeting exchange and READY command handshake.
|
|
9
|
+
#
|
|
10
|
+
class Null
|
|
11
|
+
MECHANISM_NAME = "NULL"
|
|
12
|
+
|
|
13
|
+
# Performs the full NULL handshake over +io+.
|
|
14
|
+
#
|
|
15
|
+
# 1. Exchange 64-byte greetings
|
|
16
|
+
# 2. Validate peer greeting (version, mechanism)
|
|
17
|
+
# 3. Exchange READY commands (socket type + identity)
|
|
18
|
+
#
|
|
19
|
+
# @param io [#read, #write] transport IO
|
|
20
|
+
# @param as_server [Boolean]
|
|
21
|
+
# @param socket_type [String]
|
|
22
|
+
# @param identity [String]
|
|
23
|
+
# @return [Hash] { peer_socket_type:, peer_identity: }
|
|
24
|
+
# @raise [ProtocolError]
|
|
25
|
+
#
|
|
26
|
+
def handshake!(io, as_server:, socket_type:, identity:)
|
|
27
|
+
# Send our greeting
|
|
28
|
+
io.write(Codec::Greeting.encode(mechanism: MECHANISM_NAME, as_server: as_server))
|
|
29
|
+
|
|
30
|
+
# Read peer greeting
|
|
31
|
+
greeting_data = io.read_exactly(Codec::Greeting::SIZE)
|
|
32
|
+
peer_greeting = Codec::Greeting.decode(greeting_data)
|
|
33
|
+
|
|
34
|
+
unless peer_greeting[:mechanism] == MECHANISM_NAME
|
|
35
|
+
raise ProtocolError, "unsupported mechanism: #{peer_greeting[:mechanism]}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Send our READY command
|
|
39
|
+
ready_cmd = Codec::Command.ready(socket_type: socket_type, identity: identity)
|
|
40
|
+
io.write(ready_cmd.to_frame.to_wire)
|
|
41
|
+
|
|
42
|
+
# Read peer READY command
|
|
43
|
+
frame = Codec::Frame.read_from(io)
|
|
44
|
+
unless frame.command?
|
|
45
|
+
raise ProtocolError, "expected command frame, got data frame"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
peer_cmd = Codec::Command.from_body(frame.body)
|
|
49
|
+
unless peer_cmd.name == "READY"
|
|
50
|
+
raise ProtocolError, "expected READY command, got #{peer_cmd.name}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
props = peer_cmd.properties
|
|
54
|
+
peer_socket_type = props["Socket-Type"]
|
|
55
|
+
peer_identity = props["Identity"] || ""
|
|
56
|
+
|
|
57
|
+
unless peer_socket_type
|
|
58
|
+
raise ProtocolError, "peer READY missing Socket-Type"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
{ peer_socket_type: peer_socket_type, peer_identity: peer_identity }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] false — NULL does not encrypt frames
|
|
65
|
+
#
|
|
66
|
+
def encrypted? = false
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module ZMTP
|
|
5
|
+
# Pure Ruby socket options.
|
|
6
|
+
#
|
|
7
|
+
# All timeouts are in seconds (Numeric) or nil (no timeout).
|
|
8
|
+
# HWM values are integers.
|
|
9
|
+
#
|
|
10
|
+
class Options
|
|
11
|
+
DEFAULT_HWM = 1000
|
|
12
|
+
|
|
13
|
+
# @param linger [Integer] linger period in seconds (default 0)
|
|
14
|
+
#
|
|
15
|
+
def initialize(linger: 0)
|
|
16
|
+
@send_hwm = DEFAULT_HWM
|
|
17
|
+
@recv_hwm = DEFAULT_HWM
|
|
18
|
+
@linger = linger
|
|
19
|
+
@identity = "".b
|
|
20
|
+
@router_mandatory = false
|
|
21
|
+
@read_timeout = nil # seconds, nil = no timeout
|
|
22
|
+
@write_timeout = nil
|
|
23
|
+
@reconnect_interval = 0.1 # seconds, or Range for backoff (e.g. 0.1..5.0)
|
|
24
|
+
@heartbeat_interval = nil # seconds, nil = disabled
|
|
25
|
+
@heartbeat_ttl = nil # seconds, nil = use heartbeat_interval
|
|
26
|
+
@heartbeat_timeout = nil # seconds, nil = use heartbeat_interval
|
|
27
|
+
@max_message_size = nil # bytes, nil = unlimited
|
|
28
|
+
@connect_timeout = 60 # seconds, nil = OS default
|
|
29
|
+
@mechanism = :null # :null or :curve
|
|
30
|
+
@curve_server = false
|
|
31
|
+
@curve_server_key = nil # 32-byte binary (server's permanent public key)
|
|
32
|
+
@curve_public_key = nil # 32-byte binary (our permanent public key)
|
|
33
|
+
@curve_secret_key = nil # 32-byte binary (our permanent secret key)
|
|
34
|
+
@curve_authenticator = nil # nil = allow all, Set = allowlist, #call = custom
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_accessor :send_hwm, :recv_hwm,
|
|
38
|
+
:linger, :identity,
|
|
39
|
+
:router_mandatory,
|
|
40
|
+
:read_timeout, :write_timeout,
|
|
41
|
+
:reconnect_interval,
|
|
42
|
+
:heartbeat_interval, :heartbeat_ttl, :heartbeat_timeout,
|
|
43
|
+
:max_message_size,
|
|
44
|
+
:connect_timeout,
|
|
45
|
+
:mechanism,
|
|
46
|
+
:curve_server, :curve_server_key,
|
|
47
|
+
:curve_public_key, :curve_secret_key,
|
|
48
|
+
:curve_authenticator
|
|
49
|
+
|
|
50
|
+
alias_method :router_mandatory?, :router_mandatory
|
|
51
|
+
alias_method :recv_timeout, :read_timeout
|
|
52
|
+
alias_method :recv_timeout=, :read_timeout=
|
|
53
|
+
alias_method :send_timeout, :write_timeout
|
|
54
|
+
alias_method :send_timeout=, :write_timeout=
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
module ZMTP
|
|
7
|
+
# Shared IO reactor for the Ruby backend.
|
|
8
|
+
#
|
|
9
|
+
# When user code runs inside an Async reactor, pump tasks are spawned
|
|
10
|
+
# as transient Async tasks directly. When no reactor is available
|
|
11
|
+
# (e.g. bare Thread.new), a single shared IO thread hosts all pump
|
|
12
|
+
# tasks — mirroring libzmq's IO thread architecture.
|
|
13
|
+
#
|
|
14
|
+
module Reactor
|
|
15
|
+
@work_queue = Thread::Queue.new
|
|
16
|
+
@thread = nil
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@wake_r = nil
|
|
19
|
+
@wake_w = nil
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Spawns a pump task (recv loop, send loop, accept loop).
|
|
23
|
+
#
|
|
24
|
+
# Inside an Async reactor: spawns as transient Async task.
|
|
25
|
+
# Outside: dispatches to the shared IO thread.
|
|
26
|
+
#
|
|
27
|
+
# @return [#stop] a stoppable handle
|
|
28
|
+
#
|
|
29
|
+
def spawn_pump(&block)
|
|
30
|
+
if Async::Task.current?
|
|
31
|
+
Async(transient: true, &block)
|
|
32
|
+
else
|
|
33
|
+
handle = PumpHandle.new
|
|
34
|
+
ensure_started
|
|
35
|
+
@work_queue.push([:spawn, block, handle])
|
|
36
|
+
@wake_w.write_nonblock(".") rescue nil
|
|
37
|
+
handle
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Runs a block synchronously within an Async context.
|
|
42
|
+
#
|
|
43
|
+
# Inside an Async reactor: runs directly.
|
|
44
|
+
# Outside: dispatches to the shared IO thread and waits.
|
|
45
|
+
#
|
|
46
|
+
# @return [Object] the block's return value
|
|
47
|
+
#
|
|
48
|
+
def run(&block)
|
|
49
|
+
if Async::Task.current?
|
|
50
|
+
yield
|
|
51
|
+
else
|
|
52
|
+
result_queue = Thread::Queue.new
|
|
53
|
+
ensure_started
|
|
54
|
+
@work_queue.push([:run, block, result_queue])
|
|
55
|
+
@wake_w.write_nonblock(".") rescue nil
|
|
56
|
+
status, value = result_queue.pop
|
|
57
|
+
raise value if status == :error
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Ensures the shared IO thread is running.
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
#
|
|
66
|
+
def ensure_started
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
return if @thread&.alive?
|
|
69
|
+
@wake_r, @wake_w = IO.pipe
|
|
70
|
+
ready = Thread::Queue.new
|
|
71
|
+
@thread = Thread.new { run_reactor(ready, @wake_r) }
|
|
72
|
+
@thread.name = "omq-io"
|
|
73
|
+
ready.pop
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Stops the shared IO thread. Used in tests.
|
|
78
|
+
#
|
|
79
|
+
# @return [void]
|
|
80
|
+
#
|
|
81
|
+
def stop!
|
|
82
|
+
@work_queue.push([:stop])
|
|
83
|
+
@wake_w&.write_nonblock(".") rescue nil
|
|
84
|
+
@thread&.join(2)
|
|
85
|
+
@thread = nil
|
|
86
|
+
@wake_r&.close rescue nil
|
|
87
|
+
@wake_w&.close rescue nil
|
|
88
|
+
@wake_r = nil
|
|
89
|
+
@wake_w = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def run_reactor(ready, wake_r)
|
|
95
|
+
Async do |task|
|
|
96
|
+
ready.push(true)
|
|
97
|
+
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
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A stoppable handle for a pump task running in the shared reactor.
|
|
126
|
+
#
|
|
127
|
+
class PumpHandle
|
|
128
|
+
# @return [Async::Task, nil]
|
|
129
|
+
#
|
|
130
|
+
attr_accessor :task
|
|
131
|
+
|
|
132
|
+
# Stops the pump task.
|
|
133
|
+
#
|
|
134
|
+
# @return [void]
|
|
135
|
+
#
|
|
136
|
+
def stop
|
|
137
|
+
@task&.stop
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
module ZMTP
|
|
7
|
+
# Pure Ruby Readable mixin. Dequeues messages from the engine's recv queue.
|
|
8
|
+
#
|
|
9
|
+
module Readable
|
|
10
|
+
# Receives the next message.
|
|
11
|
+
#
|
|
12
|
+
# @return [Array<String>] message parts
|
|
13
|
+
# @raise [IO::TimeoutError] if read_timeout exceeded
|
|
14
|
+
#
|
|
15
|
+
def receive
|
|
16
|
+
with_timeout(@options.read_timeout) { @engine.dequeue_recv }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Waits until the socket is readable.
|
|
20
|
+
#
|
|
21
|
+
# @param timeout [Numeric, nil] timeout in seconds
|
|
22
|
+
# @return [true]
|
|
23
|
+
#
|
|
24
|
+
def wait_readable(timeout = @options.read_timeout)
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module ZMTP
|
|
5
|
+
module Routing
|
|
6
|
+
# DEALER socket routing: round-robin send, fair-queue receive.
|
|
7
|
+
#
|
|
8
|
+
# No envelope manipulation — messages pass through unchanged.
|
|
9
|
+
#
|
|
10
|
+
class Dealer
|
|
11
|
+
include RoundRobin
|
|
12
|
+
|
|
13
|
+
# @param engine [Engine]
|
|
14
|
+
#
|
|
15
|
+
def initialize(engine)
|
|
16
|
+
@engine = engine
|
|
17
|
+
@recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
|
|
18
|
+
@tasks = []
|
|
19
|
+
init_round_robin(engine)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Async::LimitedQueue]
|
|
23
|
+
#
|
|
24
|
+
attr_reader :recv_queue, :send_queue
|
|
25
|
+
|
|
26
|
+
# @param connection [Connection]
|
|
27
|
+
#
|
|
28
|
+
def connection_added(connection)
|
|
29
|
+
@connections << connection
|
|
30
|
+
signal_connection_available
|
|
31
|
+
task = @engine.start_recv_pump(connection, @recv_queue)
|
|
32
|
+
@tasks << task if task
|
|
33
|
+
start_send_pump unless @send_pump_started
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param connection [Connection]
|
|
37
|
+
#
|
|
38
|
+
def connection_removed(connection)
|
|
39
|
+
@connections.delete(connection)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param parts [Array<String>]
|
|
43
|
+
#
|
|
44
|
+
def enqueue(parts)
|
|
45
|
+
@send_queue.enqueue(parts)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#
|
|
49
|
+
def stop
|
|
50
|
+
@tasks.each(&:stop)
|
|
51
|
+
@tasks.clear
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|