omq-draft 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/LICENSE +15 -0
- data/README.md +58 -0
- data/lib/omq/channel.rb +14 -0
- data/lib/omq/client_server.rb +37 -0
- data/lib/omq/draft/channel.rb +17 -0
- data/lib/omq/draft/clientserver.rb +19 -0
- data/lib/omq/draft/p2p.rb +17 -0
- data/lib/omq/draft/radiodish.rb +23 -0
- data/lib/omq/draft/scattergather.rb +19 -0
- data/lib/omq/draft/version.rb +7 -0
- data/lib/omq/peer.rb +26 -0
- data/lib/omq/radio_dish.rb +74 -0
- data/lib/omq/routing/channel.rb +83 -0
- data/lib/omq/routing/client.rb +56 -0
- data/lib/omq/routing/dish.rb +77 -0
- data/lib/omq/routing/gather.rb +46 -0
- data/lib/omq/routing/peer.rb +101 -0
- data/lib/omq/routing/radio.rb +167 -0
- data/lib/omq/routing/scatter.rb +84 -0
- data/lib/omq/routing/server.rb +101 -0
- data/lib/omq/scatter_gather.rb +23 -0
- data/lib/omq/single_frame.rb +18 -0
- data/lib/omq/transport/udp.rb +214 -0
- metadata +80 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
module Routing
|
|
7
|
+
# PEER socket routing: bidirectional multi-peer with auto-generated
|
|
8
|
+
# 4-byte routing IDs.
|
|
9
|
+
#
|
|
10
|
+
# Prepends routing ID on receive. Strips routing ID on send and
|
|
11
|
+
# routes to the identified connection.
|
|
12
|
+
#
|
|
13
|
+
class Peer
|
|
14
|
+
# @param engine [Engine]
|
|
15
|
+
#
|
|
16
|
+
def initialize(engine)
|
|
17
|
+
@engine = engine
|
|
18
|
+
@recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
|
|
19
|
+
@send_queue = Routing.build_queue(engine.options.send_hwm, :block)
|
|
20
|
+
@connections_by_routing_id = {}
|
|
21
|
+
@routing_id_by_connection = {}
|
|
22
|
+
@tasks = []
|
|
23
|
+
@send_pump_started = false
|
|
24
|
+
@send_pump_idle = true
|
|
25
|
+
@written = Set.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Async::LimitedQueue]
|
|
29
|
+
#
|
|
30
|
+
attr_reader :recv_queue, :send_queue
|
|
31
|
+
|
|
32
|
+
# @param connection [Connection]
|
|
33
|
+
#
|
|
34
|
+
def connection_added(connection)
|
|
35
|
+
routing_id = SecureRandom.bytes(4)
|
|
36
|
+
@connections_by_routing_id[routing_id] = connection
|
|
37
|
+
@routing_id_by_connection[connection] = routing_id
|
|
38
|
+
|
|
39
|
+
task = @engine.start_recv_pump(connection, @recv_queue) do |msg|
|
|
40
|
+
[routing_id, *msg]
|
|
41
|
+
end
|
|
42
|
+
@tasks << task if task
|
|
43
|
+
|
|
44
|
+
start_send_pump unless @send_pump_started
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param connection [Connection]
|
|
48
|
+
#
|
|
49
|
+
def connection_removed(connection)
|
|
50
|
+
routing_id = @routing_id_by_connection.delete(connection)
|
|
51
|
+
@connections_by_routing_id.delete(routing_id) if routing_id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param parts [Array<String>]
|
|
55
|
+
#
|
|
56
|
+
def enqueue(parts)
|
|
57
|
+
@send_queue.enqueue(parts)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stop
|
|
61
|
+
@tasks.each(&:stop)
|
|
62
|
+
@tasks.clear
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def send_pump_idle? = @send_pump_idle
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def start_send_pump
|
|
70
|
+
@send_pump_started = true
|
|
71
|
+
@tasks << @engine.spawn_pump_task(annotation: "send pump") do
|
|
72
|
+
loop do
|
|
73
|
+
@send_pump_idle = true
|
|
74
|
+
batch = [@send_queue.dequeue]
|
|
75
|
+
@send_pump_idle = false
|
|
76
|
+
Routing.drain_send_queue(@send_queue, batch)
|
|
77
|
+
|
|
78
|
+
@written.clear
|
|
79
|
+
batch.each do |parts|
|
|
80
|
+
routing_id = parts.first
|
|
81
|
+
conn = @connections_by_routing_id[routing_id]
|
|
82
|
+
next unless conn # silently drop if peer gone
|
|
83
|
+
begin
|
|
84
|
+
conn.write_message(parts[1..])
|
|
85
|
+
@written << conn
|
|
86
|
+
rescue *CONNECTION_LOST
|
|
87
|
+
# will be cleaned up
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@written.each do |conn|
|
|
92
|
+
conn.flush
|
|
93
|
+
rescue *CONNECTION_LOST
|
|
94
|
+
# will be cleaned up
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module Routing
|
|
5
|
+
# RADIO socket routing: group-based fan-out to DISH peers.
|
|
6
|
+
#
|
|
7
|
+
# Like PUB/FanOut but with exact group matching and JOIN/LEAVE
|
|
8
|
+
# commands instead of SUBSCRIBE/CANCEL.
|
|
9
|
+
#
|
|
10
|
+
# Messages are sent as two frames on the wire:
|
|
11
|
+
# group (MORE=1) + body (MORE=0)
|
|
12
|
+
#
|
|
13
|
+
class Radio
|
|
14
|
+
# Sentinel used for UDP connections that have no group filter:
|
|
15
|
+
# any group is considered a match.
|
|
16
|
+
#
|
|
17
|
+
ANY_GROUPS = Object.new.tap { |o| o.define_singleton_method(:include?) { |_| true } }.freeze
|
|
18
|
+
|
|
19
|
+
# @param engine [Engine]
|
|
20
|
+
#
|
|
21
|
+
def initialize(engine)
|
|
22
|
+
@engine = engine
|
|
23
|
+
@connections = []
|
|
24
|
+
@groups = {} # connection => Set of joined groups (or ANY_GROUPS for UDP)
|
|
25
|
+
@send_queue = Routing.build_queue(engine.options.send_hwm, :block)
|
|
26
|
+
@on_mute = engine.options.on_mute
|
|
27
|
+
@send_pump_started = false
|
|
28
|
+
@conflate = engine.options.conflate
|
|
29
|
+
@tasks = []
|
|
30
|
+
@written = Set.new
|
|
31
|
+
@latest = {} if @conflate
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Async::LimitedQueue]
|
|
35
|
+
#
|
|
36
|
+
attr_reader :send_queue
|
|
37
|
+
|
|
38
|
+
# RADIO is write-only.
|
|
39
|
+
#
|
|
40
|
+
def recv_queue
|
|
41
|
+
raise "RADIO sockets cannot receive"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param connection [Connection]
|
|
45
|
+
#
|
|
46
|
+
def connection_added(connection)
|
|
47
|
+
@connections << connection
|
|
48
|
+
if connection.respond_to?(:read_frame)
|
|
49
|
+
@groups[connection] = Set.new
|
|
50
|
+
start_group_listener(connection)
|
|
51
|
+
else
|
|
52
|
+
@groups[connection] = ANY_GROUPS # UDP: fan-out to all groups
|
|
53
|
+
end
|
|
54
|
+
start_send_pump unless @send_pump_started
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param connection [Connection]
|
|
58
|
+
#
|
|
59
|
+
def connection_removed(connection)
|
|
60
|
+
@connections.delete(connection)
|
|
61
|
+
@groups.delete(connection)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Enqueues a message for sending.
|
|
65
|
+
#
|
|
66
|
+
# @param parts [Array<String>] [group, body]
|
|
67
|
+
#
|
|
68
|
+
def enqueue(parts)
|
|
69
|
+
@send_queue.enqueue(parts)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stop
|
|
73
|
+
@tasks.each(&:stop)
|
|
74
|
+
@tasks.clear
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def muted?(conn)
|
|
80
|
+
return false if @on_mute == :block
|
|
81
|
+
q = conn.direct_recv_queue if conn.respond_to?(:direct_recv_queue)
|
|
82
|
+
q&.respond_to?(:limited?) && q.limited?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def start_send_pump
|
|
86
|
+
@send_pump_started = true
|
|
87
|
+
@tasks << @engine.spawn_pump_task(annotation: "send pump") do
|
|
88
|
+
loop do
|
|
89
|
+
@send_pump_idle = true
|
|
90
|
+
batch = [@send_queue.dequeue]
|
|
91
|
+
@send_pump_idle = false
|
|
92
|
+
Routing.drain_send_queue(@send_queue, batch)
|
|
93
|
+
|
|
94
|
+
@written.clear
|
|
95
|
+
|
|
96
|
+
if @conflate
|
|
97
|
+
# Keep only the last matching message per connection.
|
|
98
|
+
@latest.clear
|
|
99
|
+
batch.each do |parts|
|
|
100
|
+
group = parts[0]
|
|
101
|
+
body = parts[1] || EMPTY_BINARY
|
|
102
|
+
@connections.each do |conn|
|
|
103
|
+
next unless @groups[conn]&.include?(group)
|
|
104
|
+
@latest[conn] = [group, body]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
@latest.each do |conn, msg|
|
|
108
|
+
next if muted?(conn)
|
|
109
|
+
begin
|
|
110
|
+
conn.write_message(msg)
|
|
111
|
+
@written << conn
|
|
112
|
+
rescue *CONNECTION_LOST
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
batch.each do |parts|
|
|
117
|
+
group = parts[0]
|
|
118
|
+
body = parts[1] || EMPTY_BINARY
|
|
119
|
+
msg = [group, body]
|
|
120
|
+
wire_bytes = nil
|
|
121
|
+
|
|
122
|
+
@connections.each do |conn|
|
|
123
|
+
next unless @groups[conn]&.include?(group)
|
|
124
|
+
next if muted?(conn)
|
|
125
|
+
begin
|
|
126
|
+
if conn.respond_to?(:curve?) && conn.curve?
|
|
127
|
+
conn.write_message(msg)
|
|
128
|
+
elsif conn.respond_to?(:write_wire)
|
|
129
|
+
wire_bytes ||= Protocol::ZMTP::Codec::Frame.encode_message(msg)
|
|
130
|
+
conn.write_wire(wire_bytes)
|
|
131
|
+
else
|
|
132
|
+
conn.write_message(msg)
|
|
133
|
+
end
|
|
134
|
+
@written << conn
|
|
135
|
+
rescue *CONNECTION_LOST
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@written.each do |conn|
|
|
142
|
+
conn.flush
|
|
143
|
+
rescue *CONNECTION_LOST
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def start_group_listener(conn)
|
|
151
|
+
@tasks << @engine.spawn_pump_task(annotation: "group listener") do
|
|
152
|
+
loop do
|
|
153
|
+
frame = conn.read_frame
|
|
154
|
+
next unless frame.command?
|
|
155
|
+
cmd = Protocol::ZMTP::Codec::Command.from_body(frame.body)
|
|
156
|
+
case cmd.name
|
|
157
|
+
when "JOIN" then @groups[conn]&.add(cmd.data)
|
|
158
|
+
when "LEAVE" then @groups[conn]&.delete(cmd.data)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
rescue *CONNECTION_LOST
|
|
162
|
+
@engine.connection_lost(conn)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module Routing
|
|
5
|
+
# SCATTER socket routing: round-robin send to GATHER peers.
|
|
6
|
+
#
|
|
7
|
+
class Scatter
|
|
8
|
+
include RoundRobin
|
|
9
|
+
|
|
10
|
+
# @param engine [Engine]
|
|
11
|
+
#
|
|
12
|
+
def initialize(engine)
|
|
13
|
+
@engine = engine
|
|
14
|
+
@tasks = []
|
|
15
|
+
init_round_robin(engine)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# @return [Async::LimitedQueue]
|
|
20
|
+
#
|
|
21
|
+
attr_reader :send_queue
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# SCATTER is write-only.
|
|
25
|
+
#
|
|
26
|
+
def recv_queue
|
|
27
|
+
raise "SCATTER sockets cannot receive"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# @param connection [Connection]
|
|
32
|
+
#
|
|
33
|
+
def connection_added(connection)
|
|
34
|
+
@connections << connection
|
|
35
|
+
signal_connection_available
|
|
36
|
+
update_direct_pipe
|
|
37
|
+
start_send_pump unless @send_pump_started
|
|
38
|
+
start_reaper(connection)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# @param connection [Connection]
|
|
43
|
+
#
|
|
44
|
+
def connection_removed(connection)
|
|
45
|
+
@connections.delete(connection)
|
|
46
|
+
update_direct_pipe
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# @param parts [Array<String>]
|
|
51
|
+
#
|
|
52
|
+
def enqueue(parts)
|
|
53
|
+
enqueue_round_robin(parts)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Stops all background tasks (send pump, reapers).
|
|
58
|
+
#
|
|
59
|
+
def stop
|
|
60
|
+
@tasks.each(&:stop)
|
|
61
|
+
@tasks.clear
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Detects peer disconnection on write-only sockets by
|
|
68
|
+
# blocking on a receive that only returns on disconnect.
|
|
69
|
+
#
|
|
70
|
+
# @param conn [Connection]
|
|
71
|
+
#
|
|
72
|
+
def start_reaper(conn)
|
|
73
|
+
return if conn.is_a?(Transport::Inproc::DirectPipe)
|
|
74
|
+
@tasks << @engine.spawn_pump_task(annotation: "reaper") do
|
|
75
|
+
conn.receive_message
|
|
76
|
+
rescue *CONNECTION_LOST
|
|
77
|
+
@engine.connection_lost(conn)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
module Routing
|
|
7
|
+
# SERVER socket routing: identity-based routing with auto-generated
|
|
8
|
+
# 4-byte routing IDs.
|
|
9
|
+
#
|
|
10
|
+
# Prepends routing ID on receive. Strips routing ID on send and
|
|
11
|
+
# routes to the identified connection.
|
|
12
|
+
#
|
|
13
|
+
class Server
|
|
14
|
+
# @param engine [Engine]
|
|
15
|
+
#
|
|
16
|
+
def initialize(engine)
|
|
17
|
+
@engine = engine
|
|
18
|
+
@recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
|
|
19
|
+
@send_queue = Routing.build_queue(engine.options.send_hwm, :block)
|
|
20
|
+
@connections_by_routing_id = {}
|
|
21
|
+
@routing_id_by_connection = {}
|
|
22
|
+
@tasks = []
|
|
23
|
+
@send_pump_started = false
|
|
24
|
+
@send_pump_idle = true
|
|
25
|
+
@written = Set.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Async::LimitedQueue]
|
|
29
|
+
#
|
|
30
|
+
attr_reader :recv_queue, :send_queue
|
|
31
|
+
|
|
32
|
+
# @param connection [Connection]
|
|
33
|
+
#
|
|
34
|
+
def connection_added(connection)
|
|
35
|
+
routing_id = SecureRandom.bytes(4)
|
|
36
|
+
@connections_by_routing_id[routing_id] = connection
|
|
37
|
+
@routing_id_by_connection[connection] = routing_id
|
|
38
|
+
|
|
39
|
+
task = @engine.start_recv_pump(connection, @recv_queue) do |msg|
|
|
40
|
+
[routing_id, *msg]
|
|
41
|
+
end
|
|
42
|
+
@tasks << task if task
|
|
43
|
+
|
|
44
|
+
start_send_pump unless @send_pump_started
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param connection [Connection]
|
|
48
|
+
#
|
|
49
|
+
def connection_removed(connection)
|
|
50
|
+
routing_id = @routing_id_by_connection.delete(connection)
|
|
51
|
+
@connections_by_routing_id.delete(routing_id) if routing_id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param parts [Array<String>]
|
|
55
|
+
#
|
|
56
|
+
def enqueue(parts)
|
|
57
|
+
@send_queue.enqueue(parts)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stop
|
|
61
|
+
@tasks.each(&:stop)
|
|
62
|
+
@tasks.clear
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def send_pump_idle? = @send_pump_idle
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def start_send_pump
|
|
70
|
+
@send_pump_started = true
|
|
71
|
+
@tasks << @engine.spawn_pump_task(annotation: "send pump") do
|
|
72
|
+
loop do
|
|
73
|
+
@send_pump_idle = true
|
|
74
|
+
batch = [@send_queue.dequeue]
|
|
75
|
+
@send_pump_idle = false
|
|
76
|
+
Routing.drain_send_queue(@send_queue, batch)
|
|
77
|
+
|
|
78
|
+
@written.clear
|
|
79
|
+
batch.each do |parts|
|
|
80
|
+
routing_id = parts.first
|
|
81
|
+
conn = @connections_by_routing_id[routing_id]
|
|
82
|
+
next unless conn # silently drop if peer gone
|
|
83
|
+
begin
|
|
84
|
+
conn.write_message(parts[1..])
|
|
85
|
+
@written << conn
|
|
86
|
+
rescue *CONNECTION_LOST
|
|
87
|
+
# will be cleaned up
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@written.each do |conn|
|
|
92
|
+
conn.flush
|
|
93
|
+
rescue *CONNECTION_LOST
|
|
94
|
+
# will be cleaned up
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
class SCATTER < Socket
|
|
5
|
+
include Writable
|
|
6
|
+
include SingleFrame
|
|
7
|
+
|
|
8
|
+
def initialize(endpoints = nil, linger: 0, send_hwm: nil, send_timeout: nil, backend: nil)
|
|
9
|
+
_init_engine(:SCATTER, linger: linger, send_hwm: send_hwm, send_timeout: send_timeout, backend: backend)
|
|
10
|
+
_attach(endpoints, default: :connect)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class GATHER < Socket
|
|
15
|
+
include Readable
|
|
16
|
+
include SingleFrame
|
|
17
|
+
|
|
18
|
+
def initialize(endpoints = nil, linger: 0, recv_hwm: nil, recv_timeout: nil, backend: nil)
|
|
19
|
+
_init_engine(:GATHER, linger: linger, recv_hwm: recv_hwm, recv_timeout: recv_timeout, backend: backend)
|
|
20
|
+
_attach(endpoints, default: :bind)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
# Mixin that rejects multipart messages.
|
|
5
|
+
#
|
|
6
|
+
# All draft socket types (CLIENT, SERVER, RADIO, DISH, SCATTER,
|
|
7
|
+
# GATHER, PEER, CHANNEL) require single-frame messages for
|
|
8
|
+
# thread-safe atomic operations.
|
|
9
|
+
#
|
|
10
|
+
module SingleFrame
|
|
11
|
+
def send(message)
|
|
12
|
+
if message.is_a?(Array) && message.size > 1
|
|
13
|
+
raise ArgumentError, "#{self.class} does not support multipart messages"
|
|
14
|
+
end
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module OMQ
|
|
8
|
+
module Transport
|
|
9
|
+
# UDP transport for RADIO/DISH sockets.
|
|
10
|
+
#
|
|
11
|
+
# Connectionless, datagram-based. No ZMTP handshake.
|
|
12
|
+
# DISH binds and receives; RADIO connects and sends.
|
|
13
|
+
#
|
|
14
|
+
# Wire format per datagram:
|
|
15
|
+
# flags (1 byte = 0x01) | group_size (1 byte) | group (n bytes) | body
|
|
16
|
+
#
|
|
17
|
+
module UDP
|
|
18
|
+
MAX_DATAGRAM_SIZE = 65507
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Binds a UDP server for a DISH socket.
|
|
22
|
+
#
|
|
23
|
+
# @param endpoint [String] e.g. "udp://*:5555" or "udp://127.0.0.1:5555"
|
|
24
|
+
# @param engine [Engine]
|
|
25
|
+
# @return [Listener]
|
|
26
|
+
#
|
|
27
|
+
def bind(endpoint, engine)
|
|
28
|
+
host, port = parse_endpoint(endpoint)
|
|
29
|
+
host = "0.0.0.0" if host == "*"
|
|
30
|
+
socket = UDPSocket.new
|
|
31
|
+
socket.bind(host, port)
|
|
32
|
+
Listener.new(endpoint, socket, engine)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Connects a UDP sender for a RADIO socket.
|
|
36
|
+
#
|
|
37
|
+
# @param endpoint [String] e.g. "udp://127.0.0.1:5555"
|
|
38
|
+
# @param engine [Engine]
|
|
39
|
+
# @return [void]
|
|
40
|
+
#
|
|
41
|
+
def connect(endpoint, engine)
|
|
42
|
+
host, port = parse_endpoint(endpoint)
|
|
43
|
+
socket = UDPSocket.new
|
|
44
|
+
conn = RadioConnection.new(socket, host, port)
|
|
45
|
+
engine.connection_ready(conn, endpoint: endpoint)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param endpoint [String]
|
|
49
|
+
# @return [Array(String, Integer)]
|
|
50
|
+
#
|
|
51
|
+
def parse_endpoint(endpoint)
|
|
52
|
+
uri = URI.parse(endpoint)
|
|
53
|
+
[uri.hostname, uri.port]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Encodes a group + body into a UDP datagram.
|
|
57
|
+
#
|
|
58
|
+
# @param group [String]
|
|
59
|
+
# @param body [String]
|
|
60
|
+
# @return [String] binary datagram
|
|
61
|
+
#
|
|
62
|
+
def encode_datagram(group, body)
|
|
63
|
+
g = group.b
|
|
64
|
+
b = body.b
|
|
65
|
+
[0x01, g.bytesize].pack("CC") + g + b
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Decodes a UDP datagram into [group, body].
|
|
69
|
+
#
|
|
70
|
+
# @param data [String] raw datagram bytes
|
|
71
|
+
# @return [Array(String, String), nil] nil if malformed
|
|
72
|
+
#
|
|
73
|
+
def decode_datagram(data)
|
|
74
|
+
return nil if data.bytesize < 2
|
|
75
|
+
return nil unless data.getbyte(0) & 0x01 == 0x01
|
|
76
|
+
gs = data.getbyte(1)
|
|
77
|
+
return nil if data.bytesize < 2 + gs
|
|
78
|
+
group = data.byteslice(2, gs)
|
|
79
|
+
body = data.byteslice(2 + gs, data.bytesize - 2 - gs)
|
|
80
|
+
[group, body]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Outgoing UDP connection for RADIO sockets.
|
|
85
|
+
#
|
|
86
|
+
# Intentionally does not implement #read_frame — this signals
|
|
87
|
+
# Routing::Radio to skip the group listener and use ANY_GROUPS.
|
|
88
|
+
#
|
|
89
|
+
class RadioConnection
|
|
90
|
+
# @param socket [UDPSocket]
|
|
91
|
+
# @param host [String]
|
|
92
|
+
# @param port [Integer]
|
|
93
|
+
#
|
|
94
|
+
def initialize(socket, host, port)
|
|
95
|
+
@socket = socket
|
|
96
|
+
@host = host
|
|
97
|
+
@port = port
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Encodes and sends a datagram.
|
|
101
|
+
#
|
|
102
|
+
# @param parts [Array<String>] [group, body]
|
|
103
|
+
#
|
|
104
|
+
def write_message(parts)
|
|
105
|
+
group, body = parts
|
|
106
|
+
datagram = UDP.encode_datagram(group.to_s, body.to_s)
|
|
107
|
+
@socket.send(datagram, 0, @host, @port)
|
|
108
|
+
rescue Errno::ECONNREFUSED, Errno::ENETUNREACH
|
|
109
|
+
# UDP fire-and-forget — drop silently
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
alias send_message write_message
|
|
113
|
+
|
|
114
|
+
def flush; end
|
|
115
|
+
def curve?; false; end
|
|
116
|
+
|
|
117
|
+
def close
|
|
118
|
+
@socket.close rescue nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Incoming UDP connection for DISH sockets.
|
|
123
|
+
#
|
|
124
|
+
# Tracks joined groups locally. JOIN/LEAVE commands from the
|
|
125
|
+
# DISH routing strategy are intercepted via #send_command and
|
|
126
|
+
# never transmitted on the wire.
|
|
127
|
+
#
|
|
128
|
+
class DishConnection
|
|
129
|
+
# @param socket [UDPSocket] bound socket
|
|
130
|
+
#
|
|
131
|
+
def initialize(socket)
|
|
132
|
+
@socket = socket
|
|
133
|
+
@groups = Set.new
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Receives one matching datagram, blocking until available.
|
|
137
|
+
#
|
|
138
|
+
# Async-safe: rescues IO::WaitReadable and yields to the
|
|
139
|
+
# fiber scheduler via #wait_readable.
|
|
140
|
+
#
|
|
141
|
+
# @return [Array<String>] [group, body], both binary-frozen
|
|
142
|
+
#
|
|
143
|
+
def receive_message
|
|
144
|
+
loop do
|
|
145
|
+
data, = @socket.recvfrom_nonblock(MAX_DATAGRAM_SIZE)
|
|
146
|
+
parts = UDP.decode_datagram(data)
|
|
147
|
+
next unless parts
|
|
148
|
+
group, body = parts
|
|
149
|
+
next unless @groups.include?(group.b)
|
|
150
|
+
return [group.b.freeze, body.b.freeze]
|
|
151
|
+
rescue IO::WaitReadable
|
|
152
|
+
@socket.wait_readable
|
|
153
|
+
retry
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Handles JOIN/LEAVE commands locally; nothing is sent on the wire.
|
|
158
|
+
#
|
|
159
|
+
# @param cmd [Protocol::ZMTP::Codec::Command]
|
|
160
|
+
#
|
|
161
|
+
def send_command(cmd)
|
|
162
|
+
case cmd.name
|
|
163
|
+
when "JOIN" then @groups << cmd.data.b.freeze
|
|
164
|
+
when "LEAVE" then @groups.delete(cmd.data.b)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def curve?; false; end
|
|
169
|
+
|
|
170
|
+
def close
|
|
171
|
+
@socket.close rescue nil
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Bound UDP listener for DISH sockets.
|
|
176
|
+
#
|
|
177
|
+
# Unlike TCP/IPC listeners, there is no accept loop — a single
|
|
178
|
+
# DishConnection is registered directly with the engine, bypassing
|
|
179
|
+
# the ZMTP handshake path.
|
|
180
|
+
#
|
|
181
|
+
class Listener
|
|
182
|
+
# @return [String] bound endpoint
|
|
183
|
+
#
|
|
184
|
+
attr_reader :endpoint
|
|
185
|
+
|
|
186
|
+
# @param endpoint [String]
|
|
187
|
+
# @param socket [UDPSocket]
|
|
188
|
+
# @param engine [Engine]
|
|
189
|
+
#
|
|
190
|
+
def initialize(endpoint, socket, engine)
|
|
191
|
+
@endpoint = endpoint
|
|
192
|
+
@socket = socket
|
|
193
|
+
@engine = engine
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Registers a DishConnection with the engine.
|
|
197
|
+
# The on_accepted block is intentionally ignored — no ZMTP handshake.
|
|
198
|
+
#
|
|
199
|
+
# @param parent_task [Async::Task] (unused)
|
|
200
|
+
#
|
|
201
|
+
def start_accept_loops(parent_task, &_on_accepted)
|
|
202
|
+
conn = DishConnection.new(@socket)
|
|
203
|
+
@engine.connection_ready(conn, endpoint: @endpoint)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Stops the listener.
|
|
207
|
+
#
|
|
208
|
+
def stop
|
|
209
|
+
@socket.close rescue nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|