zzq 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 +17 -0
- data/LICENSE +13 -0
- data/README.md +153 -0
- data/lib/zzq/broker.rb +34 -0
- data/lib/zzq/client.rb +51 -0
- data/lib/zzq/engine/connection_lifecycle.rb +139 -0
- data/lib/zzq/engine/keepalive.rb +75 -0
- data/lib/zzq/engine/socket_lifecycle.rb +79 -0
- data/lib/zzq/engine.rb +163 -0
- data/lib/zzq/error.rb +42 -0
- data/lib/zzq/message.rb +113 -0
- data/lib/zzq/monitor_event.rb +14 -0
- data/lib/zzq/options.rb +94 -0
- data/lib/zzq/persistence/interface.rb +43 -0
- data/lib/zzq/persistence/memory.rb +37 -0
- data/lib/zzq/persistence/pstore.rb +66 -0
- data/lib/zzq/routing/broker.rb +380 -0
- data/lib/zzq/routing/client.rb +327 -0
- data/lib/zzq/routing/packet_id_allocator.rb +54 -0
- data/lib/zzq/routing/qos_tracker.rb +108 -0
- data/lib/zzq/routing/retained_store.rb +67 -0
- data/lib/zzq/routing/topic_match.rb +49 -0
- data/lib/zzq/routing/topic_trie.rb +232 -0
- data/lib/zzq/socket.rb +146 -0
- data/lib/zzq/subscription.rb +70 -0
- data/lib/zzq/transport/ipc.rb +92 -0
- data/lib/zzq/transport/mqtt.rb +83 -0
- data/lib/zzq/transport/mqtts.rb +173 -0
- data/lib/zzq/version.rb +5 -0
- data/lib/zzq.rb +26 -0
- metadata +128 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "protocol/mqtt"
|
|
6
|
+
require_relative "../engine/keepalive"
|
|
7
|
+
require_relative "../message"
|
|
8
|
+
require_relative "packet_id_allocator"
|
|
9
|
+
require_relative "qos_tracker"
|
|
10
|
+
require_relative "retained_store"
|
|
11
|
+
require_relative "topic_match"
|
|
12
|
+
require_relative "topic_trie"
|
|
13
|
+
|
|
14
|
+
module ZZQ
|
|
15
|
+
module Routing
|
|
16
|
+
# Broker-side routing.
|
|
17
|
+
#
|
|
18
|
+
# Current scope:
|
|
19
|
+
# * Read CONNECT, send CONNACK.
|
|
20
|
+
# * Per-connection read loop: PINGREQ/PINGRESP, DISCONNECT,
|
|
21
|
+
# PUBLISH fan-out to matching sessions (QoS 0/1/2), SUBSCRIBE /
|
|
22
|
+
# UNSUBSCRIBE with SUBACK / UNSUBACK, and PUBACK/PUBREC/PUBREL/
|
|
23
|
+
# PUBCOMP to drive outbound QoS state per session.
|
|
24
|
+
# * Simple linear session map; proper topic trie + retained store
|
|
25
|
+
# come in M7.
|
|
26
|
+
class Broker
|
|
27
|
+
CONNECT_TIMEOUT = 10 # seconds to wait for the initial CONNECT
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Session = Struct.new(
|
|
31
|
+
:client_id, :conn, :subscriptions, :packet_ids, :qos_tracker,
|
|
32
|
+
:will, :keepalive, :clean_disconnect,
|
|
33
|
+
keyword_init: true,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def initialize(engine)
|
|
38
|
+
@engine = engine
|
|
39
|
+
@sessions = {} # client_id => Session
|
|
40
|
+
@by_conn = {} # Connection => Session
|
|
41
|
+
@trie = TopicTrie.new
|
|
42
|
+
@retained = RetainedStore.new(persistence: engine.options.persistence)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def handle_accepted(mqtt, lifecycle)
|
|
47
|
+
connect = nil
|
|
48
|
+
Async::Task.current.with_timeout(CONNECT_TIMEOUT) do
|
|
49
|
+
connect = mqtt.read_connect_packet
|
|
50
|
+
raise ConnectionRejected, "no CONNECT received" if connect.nil?
|
|
51
|
+
|
|
52
|
+
client_id = connect.client_id
|
|
53
|
+
if client_id.empty? && mqtt.version == 3 && !connect.clean_start
|
|
54
|
+
write_connack(mqtt, reason: Protocol::MQTT::ReasonCodes::V3Connack::IDENTIFIER_REJECTED)
|
|
55
|
+
raise ConnectionRejected, "empty ClientId with clean_session=false is illegal in v3"
|
|
56
|
+
end
|
|
57
|
+
assigned = client_id.empty?
|
|
58
|
+
client_id = generate_client_id if assigned
|
|
59
|
+
|
|
60
|
+
# v5 Receive Maximum: client advertises the max number of
|
|
61
|
+
# outstanding QoS > 0 publishes it is willing to accept. Sized
|
|
62
|
+
# on the packet-id allocator used for fan-out to this session.
|
|
63
|
+
client_rcv_max = connect.properties[:receive_maximum] || 65_535
|
|
64
|
+
session = build_session(client_id, mqtt, will: connect.will,
|
|
65
|
+
receive_maximum: client_rcv_max)
|
|
66
|
+
@sessions[client_id] = session
|
|
67
|
+
@by_conn[mqtt] = session
|
|
68
|
+
|
|
69
|
+
write_connack(mqtt, reason: 0, assigned_client_id: assigned ? client_id : nil)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
lifecycle.handshake_succeeded!
|
|
73
|
+
start_read_loop(mqtt, lifecycle)
|
|
74
|
+
start_keepalive(mqtt, lifecycle, connect.keep_alive)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def connection_added(_conn); end
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def connection_removed(conn)
|
|
82
|
+
session = @by_conn.delete(conn)
|
|
83
|
+
return unless session
|
|
84
|
+
publish_will(session) unless session.clean_disconnect
|
|
85
|
+
@sessions.delete(session.client_id)
|
|
86
|
+
@trie.remove_session(session)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Publish the client's Last Will and Testament via the normal
|
|
91
|
+
# dispatch path. Called when the session is torn down without
|
|
92
|
+
# having seen a clean DISCONNECT.
|
|
93
|
+
def publish_will(session)
|
|
94
|
+
will = session.will
|
|
95
|
+
return unless will
|
|
96
|
+
msg = Message.new(
|
|
97
|
+
topic: will.fetch(:topic),
|
|
98
|
+
payload: will.fetch(:payload),
|
|
99
|
+
qos: will.fetch(:qos, 0),
|
|
100
|
+
retain: will.fetch(:retain, false),
|
|
101
|
+
properties: will.fetch(:properties, {}) || {},
|
|
102
|
+
)
|
|
103
|
+
# Also honor RETAIN on wills.
|
|
104
|
+
apply_retain(msg) if msg.retain
|
|
105
|
+
dispatch_message(msg)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def close; end
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Used by tests.
|
|
113
|
+
def session_count = @sessions.size
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Fan out a Message to every session subscribed to a matching
|
|
117
|
+
# filter. Used by both inbound PUBLISH handling and
|
|
118
|
+
# {Broker#ingest} from a non-MQTT source.
|
|
119
|
+
def dispatch_message(message, origin: nil)
|
|
120
|
+
delivered = {} # session.object_id => true (one copy per session)
|
|
121
|
+
@trie.match(message.topic).each do |hit|
|
|
122
|
+
# v5 No Local (MQTT-3.8.3-3): when set on the subscription,
|
|
123
|
+
# don't echo a publish back to the publishing client.
|
|
124
|
+
next if hit.entry[:no_local] && origin && hit.session.conn.equal?(origin)
|
|
125
|
+
next if delivered[hit.session.object_id]
|
|
126
|
+
delivered[hit.session.object_id] = true
|
|
127
|
+
forward_publish(hit.session, message, hit.entry)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_session(client_id, mqtt, will: nil, receive_maximum: 65_535)
|
|
136
|
+
Session.new(
|
|
137
|
+
client_id: client_id,
|
|
138
|
+
conn: mqtt,
|
|
139
|
+
subscriptions: [],
|
|
140
|
+
packet_ids: PacketIdAllocator.new(receive_maximum: receive_maximum),
|
|
141
|
+
qos_tracker: QosTracker.new,
|
|
142
|
+
will: will,
|
|
143
|
+
keepalive: nil,
|
|
144
|
+
clean_disconnect: false,
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def start_keepalive(mqtt, lifecycle, keep_alive)
|
|
150
|
+
return if keep_alive.nil? || keep_alive.zero?
|
|
151
|
+
session = @by_conn[mqtt] or return
|
|
152
|
+
session.keepalive = Engine::Keepalive.new(
|
|
153
|
+
mqtt, keep_alive: keep_alive, role: :watcher,
|
|
154
|
+
on_timeout: -> { lifecycle.lost! },
|
|
155
|
+
)
|
|
156
|
+
lifecycle.barrier.async(annotation: "zzq broker keepalive #{lifecycle.endpoint}") do
|
|
157
|
+
session.keepalive.run
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def write_connack(mqtt, reason:, assigned_client_id: nil, session_present: false)
|
|
163
|
+
props = {}
|
|
164
|
+
props[:assigned_client_identifier] = assigned_client_id if assigned_client_id && mqtt.version == 5
|
|
165
|
+
mqtt.write_packet(
|
|
166
|
+
Protocol::MQTT::Packet::Connack.new(
|
|
167
|
+
session_present: session_present,
|
|
168
|
+
reason_code: reason,
|
|
169
|
+
properties: props,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def generate_client_id
|
|
176
|
+
"zzq-#{SecureRandom.hex(6)}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def start_read_loop(mqtt, lifecycle)
|
|
181
|
+
lifecycle.barrier.async(annotation: "zzq broker read #{lifecycle.endpoint}") do
|
|
182
|
+
loop do
|
|
183
|
+
pkt = mqtt.read_packet
|
|
184
|
+
break if pkt.nil?
|
|
185
|
+
@by_conn[mqtt]&.keepalive&.note_received
|
|
186
|
+
dispatch(pkt, mqtt)
|
|
187
|
+
rescue *CONNECTION_LOST, Protocol::MQTT::MalformedPacket
|
|
188
|
+
break
|
|
189
|
+
end
|
|
190
|
+
ensure
|
|
191
|
+
lifecycle.lost! unless lifecycle.state == :closed
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def dispatch(pkt, mqtt)
|
|
197
|
+
case pkt
|
|
198
|
+
when Protocol::MQTT::Packet::Pingreq
|
|
199
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Pingresp.new)
|
|
200
|
+
when Protocol::MQTT::Packet::Disconnect
|
|
201
|
+
# Clean DISCONNECT: v3 = any disconnect; v5 = reason 0x00
|
|
202
|
+
# (NORMAL_DISCONNECTION). v5 reason 0x04 explicitly requests
|
|
203
|
+
# that the will still be published. Others suppress (spec).
|
|
204
|
+
session = @by_conn[mqtt]
|
|
205
|
+
if session
|
|
206
|
+
reason = mqtt.version == 5 ? pkt.reason_code : 0
|
|
207
|
+
session.clean_disconnect = (reason != 0x04)
|
|
208
|
+
end
|
|
209
|
+
raise EOFError # cleanly unwind the read loop
|
|
210
|
+
when Protocol::MQTT::Packet::Publish
|
|
211
|
+
handle_publish(pkt, mqtt)
|
|
212
|
+
when Protocol::MQTT::Packet::Subscribe
|
|
213
|
+
handle_subscribe(pkt, mqtt)
|
|
214
|
+
when Protocol::MQTT::Packet::Unsubscribe
|
|
215
|
+
handle_unsubscribe(pkt, mqtt)
|
|
216
|
+
when Protocol::MQTT::Packet::Puback
|
|
217
|
+
handle_puback(pkt, mqtt)
|
|
218
|
+
when Protocol::MQTT::Packet::Pubrec
|
|
219
|
+
handle_pubrec(pkt, mqtt)
|
|
220
|
+
when Protocol::MQTT::Packet::Pubrel
|
|
221
|
+
handle_pubrel(pkt, mqtt)
|
|
222
|
+
when Protocol::MQTT::Packet::Pubcomp
|
|
223
|
+
handle_pubcomp(pkt, mqtt)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def handle_publish(pkt, mqtt)
|
|
229
|
+
session = @by_conn[mqtt] or return
|
|
230
|
+
message = Message.from_publish(pkt)
|
|
231
|
+
case pkt.qos
|
|
232
|
+
when 0
|
|
233
|
+
apply_retain(message) if pkt.retain
|
|
234
|
+
dispatch_message(message, origin: mqtt)
|
|
235
|
+
when 1
|
|
236
|
+
apply_retain(message) if pkt.retain
|
|
237
|
+
dispatch_message(message, origin: mqtt)
|
|
238
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Puback.new(packet_id: pkt.packet_id))
|
|
239
|
+
when 2
|
|
240
|
+
# Fan out only on first receipt; duplicates are still PUBRECed.
|
|
241
|
+
if session.qos_tracker.on_qos2_publish(pkt.packet_id)
|
|
242
|
+
apply_retain(message) if pkt.retain
|
|
243
|
+
dispatch_message(message, origin: mqtt)
|
|
244
|
+
end
|
|
245
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Pubrec.new(packet_id: pkt.packet_id))
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def apply_retain(message)
|
|
251
|
+
@retained.store(message)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def handle_subscribe(pkt, mqtt)
|
|
256
|
+
session = @by_conn[mqtt] or return
|
|
257
|
+
added = []
|
|
258
|
+
reasons = pkt.filters.map do |entry|
|
|
259
|
+
sub_entry = {
|
|
260
|
+
filter: entry.fetch(:filter),
|
|
261
|
+
qos: entry.fetch(:qos, 0),
|
|
262
|
+
no_local: entry.fetch(:no_local, false),
|
|
263
|
+
retain_as_published: entry.fetch(:retain_as_published, false),
|
|
264
|
+
retain_handling: entry.fetch(:retain_handling, 0),
|
|
265
|
+
}
|
|
266
|
+
session.subscriptions << sub_entry
|
|
267
|
+
@trie.add(sub_entry[:filter], session, sub_entry)
|
|
268
|
+
added << sub_entry
|
|
269
|
+
sub_entry[:qos] # granted QoS == requested (MVP)
|
|
270
|
+
end
|
|
271
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Suback.new(
|
|
272
|
+
packet_id: pkt.packet_id, reason_codes: reasons,
|
|
273
|
+
))
|
|
274
|
+
# Replay retained messages for each newly-added filter.
|
|
275
|
+
added.each { |sub_entry| replay_retained(session, sub_entry) }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def handle_unsubscribe(pkt, mqtt)
|
|
280
|
+
session = @by_conn[mqtt]
|
|
281
|
+
reasons = pkt.filters.map do |filter|
|
|
282
|
+
matched = session&.subscriptions&.reject! { |s| s[:filter] == filter }
|
|
283
|
+
@trie.remove(filter, session) if matched && session
|
|
284
|
+
matched && !matched.empty? ? 0x00 : 0x11 # 0x11 = No subscription existed
|
|
285
|
+
end
|
|
286
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Unsuback.new(
|
|
287
|
+
packet_id: pkt.packet_id, reason_codes: reasons,
|
|
288
|
+
))
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# For each retained message matching the new filter, deliver a
|
|
293
|
+
# PUBLISH copy with RETAIN=1 (MQTT-3.3.1-8). Retain handling per
|
|
294
|
+
# v5: 0 = send retained on every SUBSCRIBE; 1 = only if the sub
|
|
295
|
+
# didn't exist; 2 = never. MVP honours 0/2; 1 is a future polish.
|
|
296
|
+
def replay_retained(session, sub_entry)
|
|
297
|
+
return if sub_entry[:retain_handling] == 2
|
|
298
|
+
@retained.each_matching(sub_entry[:filter]) do |msg|
|
|
299
|
+
forward_publish(session, msg, sub_entry, retain: true)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --- outbound-QoS ack handlers -------------------------------------
|
|
305
|
+
|
|
306
|
+
def handle_puback(pkt, mqtt)
|
|
307
|
+
session = @by_conn[mqtt] or return
|
|
308
|
+
pending = session.qos_tracker.on_puback(pkt.packet_id)
|
|
309
|
+
session.packet_ids.release(pkt.packet_id) if pending
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def handle_pubrec(pkt, mqtt)
|
|
314
|
+
session = @by_conn[mqtt] or return
|
|
315
|
+
entry = session.qos_tracker.on_pubrec(pkt.packet_id)
|
|
316
|
+
return unless entry
|
|
317
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Pubrel.new(packet_id: pkt.packet_id))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# Inbound PUBREL: the client is telling us to release its own
|
|
322
|
+
# inbound QoS 2 dedup entry (it published at QoS 2; we PUBRECed;
|
|
323
|
+
# now it's clearing). Respond with PUBCOMP.
|
|
324
|
+
def handle_pubrel(pkt, mqtt)
|
|
325
|
+
session = @by_conn[mqtt] or return
|
|
326
|
+
session.qos_tracker.on_pubrel(pkt.packet_id)
|
|
327
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Pubcomp.new(packet_id: pkt.packet_id))
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def handle_pubcomp(pkt, mqtt)
|
|
332
|
+
session = @by_conn[mqtt] or return
|
|
333
|
+
pending = session.qos_tracker.on_pubcomp(pkt.packet_id)
|
|
334
|
+
session.packet_ids.release(pkt.packet_id) if pending
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def forward_publish(session, message, entry, retain: false)
|
|
339
|
+
granted_qos = [message.qos, entry[:qos]].min
|
|
340
|
+
# retain_as_published (v5): when true, preserve publisher's
|
|
341
|
+
# RETAIN flag; otherwise spec forces RETAIN=0 on fan-out (and
|
|
342
|
+
# =1 only on retained-replay, tracked via the +retain+ arg).
|
|
343
|
+
out_retain =
|
|
344
|
+
if retain
|
|
345
|
+
true
|
|
346
|
+
elsif entry[:retain_as_published]
|
|
347
|
+
message.retain
|
|
348
|
+
else
|
|
349
|
+
false
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
if granted_qos.zero?
|
|
353
|
+
out = Protocol::MQTT::Packet::Publish.new(
|
|
354
|
+
topic: message.topic, payload: message.payload,
|
|
355
|
+
qos: 0, retain: out_retain, dup: false, packet_id: nil,
|
|
356
|
+
properties: message.properties,
|
|
357
|
+
)
|
|
358
|
+
session.conn.write_packet(out)
|
|
359
|
+
return
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
packet_id = session.packet_ids.acquire
|
|
363
|
+
out = Protocol::MQTT::Packet::Publish.new(
|
|
364
|
+
topic: message.topic, payload: message.payload,
|
|
365
|
+
qos: granted_qos, retain: out_retain, dup: false,
|
|
366
|
+
packet_id: packet_id, properties: message.properties,
|
|
367
|
+
)
|
|
368
|
+
if granted_qos == 1
|
|
369
|
+
session.qos_tracker.track_qos1(packet_id, out)
|
|
370
|
+
else
|
|
371
|
+
session.qos_tracker.track_qos2(packet_id, out)
|
|
372
|
+
end
|
|
373
|
+
session.conn.write_packet(out)
|
|
374
|
+
rescue *CONNECTION_LOST, ClosedError
|
|
375
|
+
# recipient is gone; tear-down handled by their read loop.
|
|
376
|
+
session.packet_ids.release(packet_id) if packet_id
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/promise"
|
|
5
|
+
require "protocol/mqtt"
|
|
6
|
+
require_relative "../engine/keepalive"
|
|
7
|
+
require_relative "../message"
|
|
8
|
+
require_relative "../subscription"
|
|
9
|
+
require_relative "packet_id_allocator"
|
|
10
|
+
require_relative "qos_tracker"
|
|
11
|
+
require_relative "topic_match"
|
|
12
|
+
|
|
13
|
+
module ZZQ
|
|
14
|
+
module Routing
|
|
15
|
+
# Client-side routing.
|
|
16
|
+
#
|
|
17
|
+
# Responsibilities:
|
|
18
|
+
# * CONNECT / CONNACK handshake on +handle_connected+.
|
|
19
|
+
# * Per-connection read loop dispatching PINGRESP, DISCONNECT,
|
|
20
|
+
# PUBLISH (fan-out to matching subscriptions), SUBACK/UNSUBACK
|
|
21
|
+
# (resolve pending acks), and PUBACK/PUBREC/PUBREL/PUBCOMP
|
|
22
|
+
# (drive outbound QoS 1/2 state machines).
|
|
23
|
+
# * Keepalive fiber.
|
|
24
|
+
# * QoS 0/1/2 publish and subscribe.
|
|
25
|
+
class Client
|
|
26
|
+
HANDSHAKE_TIMEOUT = 10 # seconds to wait for CONNACK
|
|
27
|
+
SUBSCRIBE_TIMEOUT = 30 # seconds to wait for SUBACK / UNSUBACK
|
|
28
|
+
PUBLISH_TIMEOUT = 30 # seconds to wait for terminal ack on qos>0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# @return [Protocol::MQTT::Packet::Connack, nil] the most recent
|
|
32
|
+
# CONNACK received, or nil before the first connect.
|
|
33
|
+
attr_reader :last_connack
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def initialize(engine)
|
|
37
|
+
@engine = engine
|
|
38
|
+
@conn = nil
|
|
39
|
+
@keepalive = nil
|
|
40
|
+
@last_connack = nil
|
|
41
|
+
@subscriptions = [] # ZZQ::Subscription
|
|
42
|
+
@packet_ids = PacketIdAllocator.new(
|
|
43
|
+
receive_maximum: @engine.options.receive_maximum,
|
|
44
|
+
)
|
|
45
|
+
@qos_tracker = QosTracker.new
|
|
46
|
+
@pending_ctl = {} # packet_id => Async::Promise (SUBACK / UNSUBACK)
|
|
47
|
+
@inbound_qos2 = {} # packet_id => Message awaiting PUBREL (manual-ack path; M9)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def session_present?
|
|
52
|
+
@last_connack&.session_present || false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Called by Engine#handle_connected. Runs inside the caller's
|
|
57
|
+
# Async task (Socket#connect → Engine.connect → Transport.connect
|
|
58
|
+
# → Engine.handle_connected), so we can synchronously drive the
|
|
59
|
+
# CONNECT/CONNACK exchange and raise to the caller on refusal.
|
|
60
|
+
def handle_connected(mqtt, lifecycle)
|
|
61
|
+
opts = @engine.options
|
|
62
|
+
mqtt.version = opts.version
|
|
63
|
+
|
|
64
|
+
connect_packet = build_connect(opts)
|
|
65
|
+
|
|
66
|
+
Async::Task.current.with_timeout(HANDSHAKE_TIMEOUT) do
|
|
67
|
+
mqtt.write_packet(connect_packet)
|
|
68
|
+
reply = mqtt.read_packet
|
|
69
|
+
raise ConnectionRefused.new(0, "no CONNACK from broker") if reply.nil?
|
|
70
|
+
raise ConnectionRefused.new(0, "expected CONNACK, got #{reply.class.name}") unless reply.is_a?(Protocol::MQTT::Packet::Connack)
|
|
71
|
+
unless success_connack?(reply)
|
|
72
|
+
raise ConnectionRefused.new(reply.reason_code)
|
|
73
|
+
end
|
|
74
|
+
@last_connack = reply
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
@conn = mqtt
|
|
78
|
+
lifecycle.handshake_succeeded!
|
|
79
|
+
|
|
80
|
+
start_read_loop(mqtt, lifecycle)
|
|
81
|
+
start_keepalive(mqtt, lifecycle)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def connection_added(_conn); end
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def connection_removed(conn)
|
|
89
|
+
return unless @conn.equal?(conn)
|
|
90
|
+
@conn = nil
|
|
91
|
+
@subscriptions.each(&:close)
|
|
92
|
+
@subscriptions.clear
|
|
93
|
+
@pending_ctl.each_value { |p| p.resolve(nil) unless p.resolved? }
|
|
94
|
+
@pending_ctl.clear
|
|
95
|
+
# Wake any fiber blocked on a publish ack. Don't clear the
|
|
96
|
+
# tracker yet — M6 retransmit-on-reconnect will want to inspect
|
|
97
|
+
# +inflight_outbound+. For now, just unblock with nil.
|
|
98
|
+
@qos_tracker.inflight_outbound.each do |pending|
|
|
99
|
+
pending.promise.resolve(nil) unless pending.promise.resolved?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def close
|
|
105
|
+
return unless @conn
|
|
106
|
+
begin
|
|
107
|
+
@conn.write_packet(Protocol::MQTT::Packet::Disconnect.new)
|
|
108
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET, ClosedError
|
|
109
|
+
# peer already gone; fine
|
|
110
|
+
end
|
|
111
|
+
@conn = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Send a PUBLISH. For QoS 0 returns immediately after write. For
|
|
116
|
+
# QoS 1 blocks the fiber until PUBACK. For QoS 2 blocks until
|
|
117
|
+
# PUBCOMP.
|
|
118
|
+
def publish(topic:, payload:, qos: 0, retain: false, properties: {})
|
|
119
|
+
raise ClosedError, "client not connected" unless @conn
|
|
120
|
+
if qos.zero?
|
|
121
|
+
pkt = Protocol::MQTT::Packet::Publish.new(
|
|
122
|
+
topic: topic, payload: payload, qos: 0, retain: retain,
|
|
123
|
+
properties: properties,
|
|
124
|
+
)
|
|
125
|
+
@conn.write_packet(pkt)
|
|
126
|
+
@keepalive&.note_sent
|
|
127
|
+
return nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
packet_id = @packet_ids.acquire
|
|
131
|
+
pkt = Protocol::MQTT::Packet::Publish.new(
|
|
132
|
+
topic: topic, payload: payload, qos: qos, retain: retain,
|
|
133
|
+
packet_id: packet_id, properties: properties,
|
|
134
|
+
)
|
|
135
|
+
pending = qos == 1 \
|
|
136
|
+
? @qos_tracker.track_qos1(packet_id, pkt) \
|
|
137
|
+
: @qos_tracker.track_qos2(packet_id, pkt)
|
|
138
|
+
begin
|
|
139
|
+
@conn.write_packet(pkt)
|
|
140
|
+
@keepalive&.note_sent
|
|
141
|
+
result = Async::Task.current.with_timeout(PUBLISH_TIMEOUT) { pending.promise.wait }
|
|
142
|
+
raise ClosedError, "connection lost before publish ack" if result.nil?
|
|
143
|
+
ensure
|
|
144
|
+
@packet_ids.release(packet_id)
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Send a SUBSCRIBE, wait for SUBACK, return a Subscription.
|
|
151
|
+
def subscribe(filter, qos: 0, no_local: false, retain_as_published: false,
|
|
152
|
+
retain_handling: 0)
|
|
153
|
+
raise ClosedError, "client not connected" unless @conn
|
|
154
|
+
packet_id = @packet_ids.acquire
|
|
155
|
+
filter_entry = {
|
|
156
|
+
filter: filter, qos: qos, no_local: no_local,
|
|
157
|
+
retain_as_published: retain_as_published,
|
|
158
|
+
retain_handling: retain_handling,
|
|
159
|
+
}
|
|
160
|
+
subscription = Subscription.new(
|
|
161
|
+
filters: [filter],
|
|
162
|
+
hwm: @engine.options.recv_hwm,
|
|
163
|
+
on_close: ->(sub) { send_unsubscribe(sub) },
|
|
164
|
+
)
|
|
165
|
+
@subscriptions << subscription
|
|
166
|
+
|
|
167
|
+
promise = Async::Promise.new
|
|
168
|
+
@pending_ctl[packet_id] = promise
|
|
169
|
+
begin
|
|
170
|
+
@conn.write_packet(Protocol::MQTT::Packet::Subscribe.new(
|
|
171
|
+
packet_id: packet_id, filters: [filter_entry],
|
|
172
|
+
))
|
|
173
|
+
@keepalive&.note_sent
|
|
174
|
+
|
|
175
|
+
suback = Async::Task.current.with_timeout(SUBSCRIBE_TIMEOUT) { promise.wait }
|
|
176
|
+
raise ClosedError, "connection lost before SUBACK" if suback.nil?
|
|
177
|
+
reason = suback.reason_codes.first
|
|
178
|
+
if reason >= 0x80
|
|
179
|
+
@subscriptions.delete(subscription)
|
|
180
|
+
raise Error, "SUBSCRIBE refused: reason=0x#{reason.to_s(16)}"
|
|
181
|
+
end
|
|
182
|
+
ensure
|
|
183
|
+
@packet_ids.release(packet_id)
|
|
184
|
+
end
|
|
185
|
+
subscription
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_connect(opts)
|
|
193
|
+
Protocol::MQTT::Packet::Connect.new(
|
|
194
|
+
client_id: opts.client_id.to_s,
|
|
195
|
+
clean_start: opts.clean_start,
|
|
196
|
+
keep_alive: opts.keep_alive,
|
|
197
|
+
username: opts.username,
|
|
198
|
+
password: opts.password,
|
|
199
|
+
will: opts.will,
|
|
200
|
+
properties: build_connect_properties(opts),
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_connect_properties(opts)
|
|
206
|
+
return {} unless opts.version == 5
|
|
207
|
+
props = {}
|
|
208
|
+
props[:session_expiry_interval] = opts.session_expiry_interval if opts.session_expiry_interval > 0
|
|
209
|
+
props[:receive_maximum] = opts.receive_maximum if opts.receive_maximum != 65_535
|
|
210
|
+
props[:maximum_packet_size] = opts.max_packet_size if opts.max_packet_size
|
|
211
|
+
props[:topic_alias_maximum] = opts.topic_alias_maximum if opts.topic_alias_maximum > 0
|
|
212
|
+
props
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def success_connack?(ack)
|
|
217
|
+
# v3 accepted = 0x00; v5 success = 0x00. Both map to 0.
|
|
218
|
+
ack.reason_code.zero?
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def send_unsubscribe(subscription)
|
|
223
|
+
return unless @conn
|
|
224
|
+
packet_id = @packet_ids.acquire
|
|
225
|
+
promise = Async::Promise.new
|
|
226
|
+
@pending_ctl[packet_id] = promise
|
|
227
|
+
begin
|
|
228
|
+
@conn.write_packet(Protocol::MQTT::Packet::Unsubscribe.new(
|
|
229
|
+
packet_id: packet_id, filters: subscription.filters.dup,
|
|
230
|
+
))
|
|
231
|
+
@keepalive&.note_sent
|
|
232
|
+
rescue *CONNECTION_LOST, ClosedError
|
|
233
|
+
@pending_ctl.delete(packet_id)
|
|
234
|
+
ensure
|
|
235
|
+
@packet_ids.release(packet_id)
|
|
236
|
+
end
|
|
237
|
+
@subscriptions.delete(subscription)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def start_read_loop(mqtt, lifecycle)
|
|
242
|
+
lifecycle.barrier.async(annotation: "zzq client read #{lifecycle.endpoint}") do
|
|
243
|
+
loop do
|
|
244
|
+
pkt = mqtt.read_packet
|
|
245
|
+
break if pkt.nil? # clean EOF
|
|
246
|
+
@keepalive&.note_received
|
|
247
|
+
dispatch(pkt, mqtt)
|
|
248
|
+
rescue *CONNECTION_LOST, Protocol::MQTT::MalformedPacket
|
|
249
|
+
break
|
|
250
|
+
end
|
|
251
|
+
ensure
|
|
252
|
+
lifecycle.lost! unless lifecycle.state == :closed
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def start_keepalive(mqtt, lifecycle)
|
|
258
|
+
ka = @engine.options.keep_alive
|
|
259
|
+
return if ka.zero?
|
|
260
|
+
@keepalive = Engine::Keepalive.new(
|
|
261
|
+
mqtt, keep_alive: ka,
|
|
262
|
+
on_timeout: -> { lifecycle.lost! },
|
|
263
|
+
)
|
|
264
|
+
lifecycle.barrier.async(annotation: "zzq client keepalive") do
|
|
265
|
+
@keepalive.run
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def dispatch(pkt, mqtt)
|
|
271
|
+
case pkt
|
|
272
|
+
when Protocol::MQTT::Packet::Pingresp
|
|
273
|
+
# liveness refreshed via @keepalive.note_received above
|
|
274
|
+
when Protocol::MQTT::Packet::Disconnect
|
|
275
|
+
raise EOFError # cleanly unwind the read loop
|
|
276
|
+
when Protocol::MQTT::Packet::Publish
|
|
277
|
+
handle_inbound_publish(pkt, mqtt)
|
|
278
|
+
when Protocol::MQTT::Packet::Suback, Protocol::MQTT::Packet::Unsuback
|
|
279
|
+
promise = @pending_ctl.delete(pkt.packet_id)
|
|
280
|
+
promise&.resolve(pkt)
|
|
281
|
+
when Protocol::MQTT::Packet::Puback
|
|
282
|
+
@qos_tracker.on_puback(pkt.packet_id)
|
|
283
|
+
when Protocol::MQTT::Packet::Pubrec
|
|
284
|
+
entry = @qos_tracker.on_pubrec(pkt.packet_id)
|
|
285
|
+
if entry
|
|
286
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Pubrel.new(packet_id: pkt.packet_id))
|
|
287
|
+
@keepalive&.note_sent
|
|
288
|
+
end
|
|
289
|
+
when Protocol::MQTT::Packet::Pubrel
|
|
290
|
+
# Inbound half of QoS 2 (broker → client). Release the held
|
|
291
|
+
# dedup entry and acknowledge with PUBCOMP.
|
|
292
|
+
@qos_tracker.on_pubrel(pkt.packet_id)
|
|
293
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Pubcomp.new(packet_id: pkt.packet_id))
|
|
294
|
+
@keepalive&.note_sent
|
|
295
|
+
when Protocol::MQTT::Packet::Pubcomp
|
|
296
|
+
@qos_tracker.on_pubcomp(pkt.packet_id)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def handle_inbound_publish(pkt, mqtt)
|
|
302
|
+
case pkt.qos
|
|
303
|
+
when 0
|
|
304
|
+
fan_out_publish(pkt)
|
|
305
|
+
when 1
|
|
306
|
+
fan_out_publish(pkt)
|
|
307
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Puback.new(packet_id: pkt.packet_id))
|
|
308
|
+
@keepalive&.note_sent
|
|
309
|
+
when 2
|
|
310
|
+
# Only fan out the first receipt; duplicates (same packet id
|
|
311
|
+
# before PUBREL) are dropped but still PUBRECed.
|
|
312
|
+
fan_out_publish(pkt) if @qos_tracker.on_qos2_publish(pkt.packet_id)
|
|
313
|
+
mqtt.write_packet(Protocol::MQTT::Packet::Pubrec.new(packet_id: pkt.packet_id))
|
|
314
|
+
@keepalive&.note_sent
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def fan_out_publish(pkt)
|
|
320
|
+
msg = Message.from_publish(pkt)
|
|
321
|
+
@subscriptions.each do |sub|
|
|
322
|
+
sub.deliver(msg) if sub.filters.any? { |f| TopicMatch.match?(f, pkt.topic) }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|