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.
@@ -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