mqtt-core 0.0.1.ci.release

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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/lib/mqtt/core/client/acknowledgement.rb +31 -0
  3. data/lib/mqtt/core/client/client_id_generator.rb +19 -0
  4. data/lib/mqtt/core/client/connection.rb +193 -0
  5. data/lib/mqtt/core/client/enumerable_subscription.rb +224 -0
  6. data/lib/mqtt/core/client/filesystem_session_store.rb +197 -0
  7. data/lib/mqtt/core/client/memory_session_store.rb +84 -0
  8. data/lib/mqtt/core/client/qos0_session_store.rb +56 -0
  9. data/lib/mqtt/core/client/qos2_session_store.rb +45 -0
  10. data/lib/mqtt/core/client/qos_tracker.rb +119 -0
  11. data/lib/mqtt/core/client/retry_strategy.rb +63 -0
  12. data/lib/mqtt/core/client/session.rb +195 -0
  13. data/lib/mqtt/core/client/session_store.rb +128 -0
  14. data/lib/mqtt/core/client/socket_factory.rb +268 -0
  15. data/lib/mqtt/core/client/subscription.rb +109 -0
  16. data/lib/mqtt/core/client/uri.rb +77 -0
  17. data/lib/mqtt/core/client.rb +700 -0
  18. data/lib/mqtt/core/packet/connect.rb +39 -0
  19. data/lib/mqtt/core/packet/publish.rb +45 -0
  20. data/lib/mqtt/core/packet/subscribe.rb +190 -0
  21. data/lib/mqtt/core/packet/unsubscribe.rb +21 -0
  22. data/lib/mqtt/core/packet.rb +168 -0
  23. data/lib/mqtt/core/type/binary.rb +35 -0
  24. data/lib/mqtt/core/type/bit_flags.rb +89 -0
  25. data/lib/mqtt/core/type/boolean_byte.rb +48 -0
  26. data/lib/mqtt/core/type/fixed_int.rb +43 -0
  27. data/lib/mqtt/core/type/list.rb +41 -0
  28. data/lib/mqtt/core/type/password.rb +36 -0
  29. data/lib/mqtt/core/type/properties.rb +124 -0
  30. data/lib/mqtt/core/type/reason_codes.rb +60 -0
  31. data/lib/mqtt/core/type/remaining.rb +30 -0
  32. data/lib/mqtt/core/type/shape.rb +177 -0
  33. data/lib/mqtt/core/type/sub_type.rb +34 -0
  34. data/lib/mqtt/core/type/utf8_string.rb +39 -0
  35. data/lib/mqtt/core/type/utf8_string_pair.rb +29 -0
  36. data/lib/mqtt/core/type/var_int.rb +56 -0
  37. data/lib/mqtt/core/version.rb +10 -0
  38. data/lib/mqtt/core.rb +5 -0
  39. data/lib/mqtt/errors.rb +39 -0
  40. data/lib/mqtt/logger.rb +92 -0
  41. data/lib/mqtt/open.rb +239 -0
  42. data/lib/mqtt/options.rb +59 -0
  43. data/lib/mqtt/version.rb +6 -0
  44. data/lib/mqtt.rb +3 -0
  45. data/lib/patches/openssl.rb +19 -0
  46. metadata +98 -0
@@ -0,0 +1,700 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'client/client_id_generator'
4
+ require_relative 'client/enumerable_subscription'
5
+ require_relative 'client/socket_factory'
6
+ require_relative 'client/connection'
7
+ require_relative 'client/session'
8
+ require_relative 'client/acknowledgement'
9
+ require_relative 'client/retry_strategy'
10
+ require_relative '../logger'
11
+ require_relative '../errors'
12
+ require 'forwardable'
13
+ require 'concurrent_monitor'
14
+
15
+ module MQTT
16
+ module Core
17
+ # rubocop:disable Metrics/ClassLength
18
+
19
+ # MQTT Client
20
+ #
21
+ # @note In this documentation, "thread" refers to a {ConcurrentMonitor::Task}, which may be implemented
22
+ # as either a Thread (default) or a Fiber (when using async variants).
23
+ #
24
+ # @abstract use a specific MQTT protocol version subclass.
25
+ # @see MQTT.open
26
+ class Client
27
+ extend ClientIdGenerator
28
+
29
+ class << self
30
+ # Open an MQTT client connection
31
+ #
32
+ # If a block is provided the client is yielded to the block and disconnection is ensured, otherwise
33
+ # the client is returned directly.
34
+ # @overload open(*io_args, session_store:, **configure)
35
+ # @param [Array] io_args see {SocketFactory}
36
+ # @param [SessionStore] session_store MQTT session store for unacknowledged QOS1/QOS2 packets
37
+ # see {memory_store} or {file_store}
38
+ # @param [Hash] configure data for {#configure}
39
+ # @return [Client] (if a block is not given)
40
+ # @yield [client]
41
+ # @yieldparam [Client] client
42
+ # @yieldreturn [void]
43
+ # @see MQTT.open
44
+ def open(*io_args, monitor: mqtt_monitor, session_store: memory_store, **configure)
45
+ socket_factory = SocketFactory.create(*io_args, options: configure)
46
+ new_opts = { socket_factory:, monitor:, session_store:, **new_options(configure) }
47
+ client = new(**new_opts).tap { |c| c.configure(**configure) }
48
+
49
+ return client unless block_given?
50
+
51
+ client.sync do
52
+ yield client
53
+ client.disconnect
54
+ rescue StandardError => e
55
+ client.disconnect(cause: e)
56
+ raise
57
+ end
58
+ end
59
+
60
+ # @!group Configuration Factories
61
+
62
+ # A session store that only allows messages with QoS 0
63
+ # @return [Qos0SessionStore]
64
+ def qos0_store(...)
65
+ require_relative 'client/qos0_session_store'
66
+ Qos0SessionStore.new(...)
67
+ end
68
+
69
+ # An in-memory session store. Can recover from network interruptions but not a crash of the client.
70
+ # @return [MemorySessionStore]
71
+ def memory_store(...)
72
+ require_relative 'client/memory_session_store'
73
+ MemorySessionStore.new(...)
74
+ end
75
+
76
+ # A filesystem based session store. Can recover safely from network interruptions and process restarts
77
+ # @return [FilesystemSessionStore]
78
+ def file_store(...)
79
+ require_relative 'client/filesystem_session_store'
80
+ FilesystemSessionStore.new(...)
81
+ end
82
+
83
+ # Construct a retry strategy for automatic reconnection
84
+ # @return [RetryStrategy]
85
+ def retry_strategy(...)
86
+ RetryStrategy.new(...)
87
+ end
88
+
89
+ # @!endgroup
90
+
91
+ # @!visibility private
92
+ def thread_monitor = ConcurrentMonitor.thread_monitor
93
+
94
+ # @!visibility private
95
+ def async_monitor = ConcurrentMonitor.async_monitor
96
+
97
+ # @!visibility private
98
+ # the default concurrent_monitor uses Threads for concurrency
99
+ def mqtt_monitor = thread_monitor
100
+
101
+ # @!visibility private
102
+ def create_session(*io_args, **session_args)
103
+ self::Session.new(*io_args, **session_args)
104
+ end
105
+
106
+ # @!visibility private
107
+ def create_connection(**connection_args)
108
+ self::Connection.new(**connection_args)
109
+ end
110
+
111
+ # @!visibility private
112
+ # Extract and remove options for new - subclasses can override to add more
113
+ def new_options(_opts)
114
+ {}
115
+ end
116
+ end
117
+
118
+ extend Forwardable
119
+ include Logger
120
+
121
+ # @!visibility private
122
+ include ConcurrentMonitor
123
+
124
+ # @!attribute [r] uri
125
+ # @return [URI] universal resource indicator derived from io_args passed to {MQTT.open}
126
+ def_delegator :socket_factory, :uri
127
+
128
+ # @return [String] client_id and connection status
129
+ def to_s
130
+ "#{self.class.name}:#{uri} client_id=#{client_id}, status=#{status}"
131
+ end
132
+
133
+ def_delegators :connection, :keep_alive, :connected?
134
+
135
+ def_delegators :session, :client_id, :expiry_interval, :max_qos
136
+
137
+ # @!visibility private
138
+ def initialize(socket_factory:, monitor:, session_store:)
139
+ @socket_factory = socket_factory
140
+ @session = self.class.create_session(client: self, monitor:, session_store:)
141
+ monitor_extended(monitor.new_monitor)
142
+ @status = :configure
143
+ @acks = {}
144
+ @subs = Set.new
145
+ @unsubs = Set.new
146
+ @run_args = {}
147
+ @events = {}
148
+ end
149
+
150
+ # Configure client connection behaviour
151
+ # @param [Hash<Symbol>] connect options controlling connection and retry behaviour.
152
+ # Options not explicitly specified below are passed to the `CONNECT` packet
153
+ # @option connect [RetryStrategy,true] retry_strategy See {configure_retry}
154
+ # @raise [Error] if called after a connection has left the initial `:configure` state
155
+ # @return [self]
156
+ def configure(**connect)
157
+ synchronize { raise Error, "Can't configure with status: #{@status}" unless @status == :configure }
158
+
159
+ configure_retry(connect.delete(:retry_strategy)) if connect.key?(:retry_strategy)
160
+
161
+ @run_args.merge!(connect)
162
+ self
163
+ end
164
+
165
+ # Register a retry strategy as the disconnect handler
166
+ # @overload configure_retry(**retry_args)
167
+ # @param [Hash<Symbol>] retry_args sugar to construct a {RetryStrategy}
168
+ # @overload configure_retry(retry_strategy)
169
+ # @param [RetryStrategy|:retry!|Hash|Boolean] retry_strategy
170
+ # - true: default RetryStrategy
171
+ # - Hash: RetryStrategy.new(**hash)
172
+ # - RetryStrategy or has #retry!: use as-is
173
+ # - false/nil: no retries
174
+ # @return [self]
175
+ # @see on_disconnect
176
+ def configure_retry(*retry_strategy, **retry_args)
177
+ retry_strategy = retry_strategy.empty? ? RetryStrategy.new(**retry_args) : retry_strategy.first
178
+ retry_strategy = RetryStrategy.new if retry_strategy == true
179
+ retry_strategy = RetryStrategy.new(**retry_strategy) if retry_strategy.is_a?(Hash)
180
+ if retry_strategy
181
+ raise ArgumentError, "Invalid retry strategy: #{retry_strategy}" unless retry_strategy.respond_to?(:retry!)
182
+
183
+ on(:disconnect) { |retry_count, &raiser| retry_strategy.retry!(retry_count, &raiser) }
184
+ else
185
+ on(:disconnect) { |_c, &r| r&.call }
186
+ end
187
+ self
188
+ end
189
+
190
+ # @!group Event Handlers
191
+
192
+ # @!method on_birth(&block)
193
+ # Birth Handler: Block is executed asynchronously after the first successful connection for a session.
194
+ #
195
+ # Typically used to establish {EnumerableSubscription#async} handlers that process received messages for the
196
+ # duration of a Session.
197
+ #
198
+ # @note Prior to the completion of this event, received QoS 1/2 messages are retained in memory and matched for
199
+ # delivery against new {#subscribe} requests.
200
+ # @yield
201
+ # @yieldreturn [void]
202
+ # @return [self]
203
+
204
+ # Connect Handler
205
+ # @!method on_connect(&block)
206
+ # Called each time the client is successfully (re)connected to a broker
207
+ # @yield [connect, connack]
208
+ # @yieldparam [Packet] connect the `CONNECT` packet sent by this client
209
+ # @yieldparam [Packet] connack the `CONNACK` packet received from the server
210
+ # @yieldreturn [void]
211
+ # @return [self]
212
+
213
+ # @!method on_disconnect(&block)
214
+ # Called each time the client is disconnected from a broker.
215
+ #
216
+ # The default handler calls raiser without rescuing any errors and thus prevents the client
217
+ # from reconnecting.
218
+ #
219
+ # {#configure_retry} can be used to register a {RetryStrategy} which rescues retriable protocol and networking
220
+ # errors and uses exponential backoff before allowing reconnection.
221
+ #
222
+ # Installing a custom handler that does not re-raise errors will cause the client to retry connections forever.
223
+ #
224
+ # @yield [retry_count, &raiser]
225
+ # @yieldparam [Integer] retry_count number of retry attempts since the last successful connection
226
+ # @yieldparam [Proc] raiser callable will raise the error (if any) that caused the connection to be closed,
227
+ # @yieldreturn [void]
228
+ # @return [self]
229
+
230
+ # @!method on_publish(&block)
231
+ # Called on {#publish}
232
+ # @yield [publish, ack]
233
+ # @yieldparam [Packet] publish the `PUBLISH` packet sent by this client
234
+ # @yieldparam [Packet|nil] ack qos 0: nil, qos 1: `PUBACK`, qos 2: `PUBCOMP`
235
+ # @yieldreturn [void]
236
+ # @return [self]
237
+
238
+ # @!method on_subscribe(&block)
239
+ # Called on {#subscribe}
240
+ # @yield [subscribe, suback]
241
+ # @yieldparam [Packet] subscribe the `SUBSCRIBE` packet sent by this client
242
+ # @yieldparam [Packet] suback the `SUBACK` packet received from the server
243
+ # @yieldreturn [void]
244
+ # @return [self]
245
+
246
+ # @!method on_unsubscribe(&block)
247
+ # Called on {#unsubscribe}
248
+ # @yield [unsubscribe, unsuback]
249
+ # @yieldparam [Packet] unsubscribe the `UNSUBSCRIBE` packet sent by this client
250
+ # @yieldparam [Packet] unsuback the `UNSUBACK` packet received from the server
251
+ # @yieldreturn [void]
252
+ # @return [self]
253
+
254
+ # @!method on_send(&block)
255
+ # Called before a packet is sent
256
+ # @yield [packet]
257
+ # @yieldparam [Packet] packet
258
+ # @yieldreturn [void]
259
+ # @return [self]
260
+
261
+ # @!method on_receive(&block)
262
+ # Called when a packet is received
263
+ # @yield [packet]
264
+ # @yieldparam [Packet] packet
265
+ # @yieldreturn [void]
266
+ # @return [self]
267
+
268
+ %i[birth connect disconnect publish subscribe unsubscribe send receive].each do |event|
269
+ define_method "on_#{event}" do |&block|
270
+ on(event, &block)
271
+ end
272
+ end
273
+
274
+ # @!endgroup
275
+
276
+ # @return [Symbol] the current status of the client
277
+ attr_reader :status
278
+
279
+ # Start the MQTT connection
280
+ # @param [Hash<Symbol>] connect additional options for the `CONNECT` packet.
281
+ # Client must still be in the initial `:configure` state to pass options
282
+ # @return [self]
283
+ def connect(**connect)
284
+ configure(**connect) unless connect.empty?
285
+ connection
286
+ self
287
+ end
288
+
289
+ # Disconnect cleanly and stop the client
290
+ #
291
+ # Once called, no further calls can be made on the client.
292
+ #
293
+ # @param [Exception|nil] cause Used to set error information in the `DISCONNECT` packet
294
+ # @param [Hash<Symbol>] disconnect Additional properties for the `DISCONNECT` packet
295
+ # @return [self] with state `:stopped`
296
+ def disconnect(cause: nil, **disconnect)
297
+ synchronize do
298
+ @status = :stopped if @status == :configure
299
+ @stopping ||= current_task
300
+ end
301
+
302
+ # At this point only the original disconnect thread can use the connection
303
+ cleanup_connection(cause, **disconnect) if @stopping == current_task && @status != :stopped
304
+ @run&.wait
305
+ self
306
+ ensure
307
+ stop!
308
+ end
309
+
310
+ # @overload publish(topic_name, payload, retain: false, qos: 0, timeout: 0, **publish)
311
+ # @param [String<UTF8>> topic_name UTF8 encoded topic name
312
+ # @param [String<Binary>] payload the message payload
313
+ # @param [Boolean] retain true if the message should be retained
314
+ # @param [Integer] qos the Quality of Service level (0, 1 or 2)
315
+ # @param [Hash<Symbol>] **publish additional properties for the `PUBLISH` packet (version-dependent)
316
+ # @return [self]
317
+ # @see on_publish
318
+ def publish(*pub_args, **publish)
319
+ topic_name, payload = pub_args
320
+ publish[:topic_name] = topic_name if topic_name
321
+ publish[:payload] = payload if payload
322
+ connection.publish(**publish) { |pub| send_and_wait(pub) { |ack| handle_ack(pub, ack) } }
323
+ self
324
+ end
325
+
326
+ # Subscribe to topics
327
+ #
328
+ # @overload subscribe(*topic_filters,**subscribe)
329
+ # Subscribe and return an {EnumerableSubscription} for enumeration.
330
+ #
331
+ # The returned {EnumerableSubscription} holds received and matching messages in an internal
332
+ # queue which can be enumerated over using {EnumerableSubscription#each} or {EnumerableSubscription#async}
333
+ #
334
+ # @param [Array<String<UTF8>|Hash>] topic_filters List of filter expressions. Each element can be
335
+ # a String or a Hash with `:topic_filter` and `:max_qos` keys.
336
+ # @param [Hash<Symbol>] **subscribe additional properties for the `SUBSCRIBE` packet (version-dependent)
337
+ # @option subscribe [Integer] max_qos default maximum QoS to request for each topic_filter
338
+ # @return [EnumerableSubscription]
339
+ # @raise [SubscriptionError] if the server rejects any topic filters
340
+ # @example Wait for and return the first message
341
+ # topic, message = client.subscribe('some/topic').first
342
+ # @example Using enumerator from {EnumerableSubscription#each}
343
+ # client.subscribe('some/topic').each { |topic,msg| process(topic,msg) or break }
344
+ # @example Enumerating in a new thread via {EnumerableSubscription#async}
345
+ # client.subscribe('some/topic#').async { |topic, msg| process(topic, msg) }
346
+ # @example With different QoS levels per topic filter
347
+ # client.subscribe('status/#', { topic_filter: 'data/#', max_qos: 2 }, max_qos: 1)
348
+ #
349
+ # @overload subscribe(*topic_filters, **subscribe, &handler)
350
+ # Subscribe with a block handler for direct packet processing
351
+ #
352
+ # @param [Array<String<UTF8>|Hash>] topic_filters List of filter expressions
353
+ # @param [Hash<Symbol>] **subscribe additional properties for the `SUBSCRIBE` packet (version-dependent)
354
+ # @option subscribe [Integer] max_qos default maximum QoS to request for each topic_filter
355
+ # @yield [packet] Block is called directly from the receive thread for each matching packet
356
+ # @yieldparam [Packet<PUBLISH>|nil] packet the received `PUBLISH` packet, or nil on disconnect
357
+ # @yieldreturn [void]
358
+ # @return [Subscription]
359
+ # @raise [SubscriptionError] if the server rejects any topic filters
360
+ #
361
+ # @note WARNING: This block is executed synchronously on the IO thread that is receiving packets.
362
+ # Any blocking operations will prevent other packets from being received, causing timeouts
363
+ # and eventual disconnection.
364
+ #
365
+ # @example Direct packet processing (use with caution)
366
+ # sub = client.subscribe('some/topic') { |pkt| puts pkt.payload if pkt }
367
+ # # ...
368
+ # sub.unsubscribe
369
+ #
370
+ # @see Subscription
371
+ # @see on_subscribe
372
+ def subscribe(*topic_filters, **subscribe, &handler)
373
+ handler ||= new_queue
374
+ topic_filters += subscribe.delete(:topic_filters) || []
375
+ connection.subscribe(topic_filters:, **subscribe) do |sub_pkt|
376
+ send_and_wait(sub_pkt) do |suback_pkt|
377
+ handle_ack(sub_pkt, suback_pkt)
378
+ new_subscription(sub_pkt, suback_pkt, handler).tap { |sub| qos_subscription(sub) }
379
+ end
380
+ end
381
+ rescue SubscribeError
382
+ unsubscribe(*topic_filters)
383
+ raise
384
+ end
385
+
386
+ # Safely unsubscribe inactive topic filters
387
+ #
388
+ # @param [Array<String>] topic_filters list of filters
389
+ # @param [Hash<Symbol>] unsubscribe additional properties for the `UNSUBSCRIBE` packet
390
+ # @return [self]
391
+ # @note Topic filters that are in use by active Subscriptions are removed from the `UNSUBSCRIBE` request.
392
+ # @see Subscription#unsubscribe
393
+ # @see #on_unsubscribe
394
+ def unsubscribe(*topic_filters, **unsubscribe)
395
+ topic_filters += unsubscribe.delete(:topic_filters) || []
396
+
397
+ synchronize do
398
+ topic_filters -= (@subs - @unsubs).flat_map(&:subscribed_topic_filters)
399
+ return [] unless topic_filters.any?
400
+
401
+ connection.unsubscribe(topic_filters:, **unsubscribe) do |unsub_pkt|
402
+ send_and_wait(unsub_pkt) { |unsuback_pkt| handle_ack(unsub_pkt, unsuback_pkt) }
403
+ end
404
+ end
405
+ self
406
+ end
407
+
408
+ # @!visibility private
409
+ # Called by Subscription#unsubscribe.
410
+ def delete_subscription(subscription, **unsubscribe_params)
411
+ synchronize do
412
+ @unsubs.add(subscription)
413
+ unsubscribe(**unsubscribe_params).tap do
414
+ @unsubs.delete(subscription)
415
+ @subs.delete(subscription)
416
+ end
417
+ end
418
+ subscription.put(nil)
419
+ end
420
+
421
+ # @!visibility private
422
+ # @return [Boolean] true if this subscription is active - will receive a final 'put'
423
+ def active_subscription?(subscription)
424
+ synchronize { @subs.include?(subscription) }
425
+ end
426
+
427
+ # @!visibility private
428
+ # Called by self to enqueue packets from client threads,
429
+ # or by receive thread to enqueue packets related to protocol flow
430
+ def push_packet(*packets)
431
+ synchronize { send_queue.push(*packets) }
432
+ end
433
+
434
+ # @!visibility private
435
+ # Called by: Connection's receive thread for received ACK type packets
436
+ def receive_ack(packet)
437
+ synchronize { acks.delete(packet.id)&.fulfill(packet) }
438
+ end
439
+
440
+ # @!visibility private
441
+ # Called by: Connection receive loop for received `PUBLISH` packets
442
+ # @return [Array<Subscription>] matched subscriptions
443
+ def receive_publish(packet)
444
+ synchronize do
445
+ subs.select { |s| s.match?(packet) } # rubocop:disable Style/SelectByRegexp
446
+ end
447
+ end
448
+
449
+ # @!visibility private
450
+ # Called by: {Connection} receive_loop when it reaches io end of stream
451
+ def receive_eof
452
+ push_packet(:eof)
453
+ end
454
+
455
+ # @!visibility private
456
+ def packet_module
457
+ self.class.packet_module
458
+ end
459
+
460
+ # @!visibility private
461
+ def handle_event(event, *, **kw_args, &)
462
+ events[event]&.call(*, **kw_args, &)
463
+ end
464
+
465
+ # @!visibility private
466
+ def_delegators :packet_module, :build_packet, :deserialize
467
+
468
+ # @!visibility private
469
+ # session methods called via Subscription
470
+ def_delegators :session, :handled!
471
+
472
+ private
473
+
474
+ attr_reader :events, :session, :send_queue, :monitor, :socket_factory, :conn_cond, :subs, :acks, :conn_count
475
+
476
+ def monitor_extended(monitor)
477
+ @monitor = monitor
478
+ @send_queue = new_queue
479
+ @conn_cond = new_condition
480
+ end
481
+
482
+ def on(event, &handler)
483
+ synchronize do
484
+ raise ArgumentError, 'Configuration must be called before first packet is sent' if events.frozen?
485
+
486
+ events[event] = handler
487
+ end
488
+ self
489
+ end
490
+
491
+ # This is used for the API methods to obtain an active connection
492
+ # It starts the connect loops if necessary and blocks until a connection is available
493
+ def connection
494
+ synchronize do
495
+ run if @status == :configure
496
+ conn_cond.wait_while { @status == :disconnected }
497
+ raise ConnectionError, 'Stopped.', cause: @exception if @status == :stopped && @exception
498
+ raise ConnectionError, "Not connected. #{@status}" unless @status == :connected
499
+ raise ConnectionError, 'Disconnecting...' if @stopping && @stopping != current_task
500
+
501
+ @connection
502
+ end
503
+ end
504
+
505
+ def new_connection
506
+ log.info { "Connecting to #{uri}" }
507
+ io = socket_factory.new_io
508
+ self.class.create_connection(client: self, session:, io:, monitor:)
509
+ end
510
+
511
+ def run
512
+ return unless @status == :configure
513
+
514
+ configure_defaults
515
+ @status = :disconnected
516
+ # don't allow any more on_ events.
517
+ events.freeze
518
+ @run ||= async(:run) { safe_run_task }
519
+ end
520
+
521
+ def configure_defaults
522
+ configure_default_retry unless @events[:disconnect]
523
+ end
524
+
525
+ def configure_default_retry
526
+ log.warn <<~WARNING if session.max_qos.positive?
527
+ No automatic retry strategy has been configured for this MQTT client.
528
+
529
+ This is not recommended for applications using QoS levels 1 or 2, as message
530
+ delivery guarantees may be compromised during network interruptions.
531
+
532
+ To suppress this warning use #configure_retry to explicitly configure or disable the retry strategy.
533
+ Alternatively use the #qos0_store to limit PUBLISH and SUBSCRIBE to QoS 0.
534
+ WARNING
535
+ configure_retry(false)
536
+ end
537
+
538
+ def run_task(retry_count: 0)
539
+ # no point trying to connect if the session is already expired
540
+ session.expired!
541
+ run_connection(**@run_args) { retry_count = 0 }
542
+ disconnected!(0) { nil }
543
+ rescue StandardError => e
544
+ disconnected!(retry_count += 1) { raise e }
545
+ # If we are going to restart with a clean session existing acks and subs need to be cancelled.
546
+ retry unless synchronize do
547
+ @stopping.tap { |stopping| cancel_session('Restarting session') if !stopping && session.clean? }
548
+ end
549
+ ensure
550
+ stop!(e)
551
+ end
552
+
553
+ def safe_run_task
554
+ run_task
555
+ rescue StandardError => e
556
+ log.error(e)
557
+ end
558
+
559
+ def run_connection(**connect_data)
560
+ conn = establish_connection(**connect_data)
561
+ yield if block_given?
562
+ with_barrier do |b|
563
+ b.async(:send_loop) { send_loop(conn) }
564
+ b.async(:recv_loop) { receive_loop(conn) }
565
+ b.wait!
566
+ ensure
567
+ conn.close
568
+ end
569
+ end
570
+
571
+ def establish_connection(**connect)
572
+ # Socket factory URI can contain username, password
573
+ connect.merge!(socket_factory.auth) if socket_factory.respond_to?(:auth)
574
+
575
+ new_connection.tap do |conn|
576
+ connect_packet, connack_packet = conn.connect(**connect)
577
+ synchronize { connected!(conn, connect_packet, connack_packet) }
578
+ birth! unless session.birth_complete?
579
+ end
580
+ end
581
+
582
+ def birth!
583
+ async(:birth) do
584
+ handle_event(:birth)
585
+ session.birth_complete!
586
+ rescue ConnectionError => e
587
+ log.warn { "Ignoring ConnectionError in birth handler: #{e.class}: #{e.message}" }
588
+ rescue StandardError => e
589
+ log.error { "Unexpected error in birth handler: #{e.class}: #{e.message}. Disconnecting..." }
590
+ disconnect(cause: e)
591
+ end
592
+ end
593
+
594
+ # @note synchronized - sub needs to be available to immediate receive publish
595
+ def new_subscription(sub_packet, ack_packet, handler)
596
+ # noinspection RubyArgCount
597
+ klass = handler.respond_to?(:call) ? Subscription : EnumerableSubscription
598
+ klass.new(sub_packet, ack_packet, handler || new_queue, self).tap { |sub| @subs.add(sub) }
599
+ end
600
+
601
+ def qos_subscription(sub)
602
+ return unless sub.sub_packet.max_qos.positive?
603
+
604
+ # When re-establishing a subscription to a live session, there may be matching messages already received
605
+ session.qos_subscribed { |p| sub.match?(p) }.each { |p| sub.put(p) }
606
+ end
607
+
608
+ def connected!(conn, connect_pkt, connack_pkt)
609
+ session.connected!(connect_pkt, connack_pkt)
610
+ handle_event(:connect, connect_pkt, connack_pkt)
611
+ send_queue.push(*session.retry_packets)
612
+ @connection = conn
613
+ @status = :connected
614
+ conn_cond.broadcast
615
+ end
616
+
617
+ # @!visibility private
618
+ # @param [Connection] connection as yielded from {#run}
619
+ def send_loop(connection, keep_alive_factor: 0.7)
620
+ connection.send_loop { |keep_alive| next_packet(send_timeout(keep_alive, keep_alive_factor)) }
621
+ end
622
+
623
+ # @!visibility private
624
+ # @param [Connection] connection as yielded from {#run}
625
+ def receive_loop(connection)
626
+ connection.receive_loop
627
+ end
628
+
629
+ def send_timeout(keep_alive, factor)
630
+ return nil unless keep_alive&.positive?
631
+
632
+ keep_alive * factor
633
+ end
634
+
635
+ def disconnected!(retry_count, &)
636
+ synchronize { @status = :disconnected if @status == :connected }
637
+ session.disconnected!
638
+ handle_event(:disconnect, retry_count, &)
639
+ end
640
+
641
+ def next_packet(keep_alive_timeout = nil)
642
+ synchronize { send_queue.shift(keep_alive_timeout) }
643
+ end
644
+
645
+ def handle_ack(pkt, ack)
646
+ handle_event(pkt.packet_name, pkt, ack)
647
+ pkt.success!(ack)
648
+ end
649
+
650
+ def send_and_wait(packet, &)
651
+ synchronize do
652
+ ack = acks[packet.id] = Acknowledgement.new(packet, monitor:, &) if packet.id
653
+
654
+ send_queue.push(packet)
655
+
656
+ ack&.value || yield(nil)
657
+ end
658
+ end
659
+
660
+ def cleanup_connection(cause, **disconnect)
661
+ # wait for acks
662
+ acks.each_value(&:wait) if cause
663
+
664
+ connection.disconnect(cause, **disconnect) { |disconnect_packet| send_queue.push(disconnect_packet) }
665
+ end
666
+
667
+ def stop!(exception = nil)
668
+ return if @status == :stopped
669
+
670
+ synchronize do
671
+ next if @status == :stopped
672
+
673
+ @exception = exception
674
+ @status = :stopped
675
+ cancel_session('Connection stopped')
676
+ conn_cond.broadcast
677
+ @run&.stop unless @run&.current?
678
+ end
679
+ end
680
+
681
+ # called before retrying with a clean session or while stopping?
682
+ def cancel_session(msg)
683
+ cause = ConnectionError.new(msg)
684
+ cancel_acks(cause)
685
+ cancel_subs(cause)
686
+ end
687
+
688
+ def cancel_subs(cause)
689
+ subs.each { |sub| sub.put(cause) }
690
+ subs.clear
691
+ end
692
+
693
+ def cancel_acks(cause)
694
+ acks.each_value { |a| a.cancel(cause) }
695
+ end
696
+ end
697
+
698
+ # rubocop:enable Metrics/ClassLength
699
+ end
700
+ end