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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 03cdb9467d4797a91a9751dd5f0421d4f0a231a3084314dbbf9b58d34812c7a8
4
+ data.tar.gz: 17800776d5fc4fd0698758bbcc79fc29d64f65a7c1d7d3ce91265faf55646c2a
5
+ SHA512:
6
+ metadata.gz: eedd90f6fe41a5e72ea11a990507c168b970e203af0a30d079ca70a76dc71b1f8bdac6c915b84b7de4abb7097ba99593683938c9e23e5f14a869d4849950ea8b
7
+ data.tar.gz: ced170d3f87f4fee052ddc4e5e9f282b3d3be257c0eb2c2b50278e3577e087d80f9fe1b7eb1e740474224f1244ca2b7e2f14f35c8c88a653dd80f9498b27fd57
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../errors'
4
+
5
+ module MQTT
6
+ module Core
7
+ class Client
8
+ # In MQTT the acknowledgement is always a packet
9
+ class Acknowledgement < ConcurrentMonitor::Future
10
+ def initialize(packet, monitor:, &deferred)
11
+ @packet = packet
12
+ @deferred = deferred
13
+ super(monitor:)
14
+ end
15
+
16
+ # @return [MQTT::Packet] the packet that this acknowledgement is for
17
+ attr_reader :packet
18
+
19
+ def cancel(exception)
20
+ reject(exception)
21
+ end
22
+
23
+ def fulfill(ack_packet)
24
+ super(@deferred ? @deferred.call(ack_packet) : [packet, ack_packet])
25
+ rescue StandardError => e
26
+ reject(e)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module MQTT
6
+ module Core
7
+ class Client
8
+ # Client ID Helpers
9
+ module ClientIdGenerator
10
+ # Base method to generate a client id consisting of a prefix and random sequence of alphanumerics
11
+ # @param [String] prefix
12
+ # @param [Integer] length
13
+ def generate_client_id(prefix: 'rb', length: 21)
14
+ "#{prefix}#{SecureRandom.alphanumeric(length)}"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'concurrent_monitor'
5
+ require_relative '../../logger'
6
+
7
+ module MQTT
8
+ module Core
9
+ class Client
10
+ # @!visibility private
11
+ # Represents a single network connection to an MQTT Server
12
+ class Connection
13
+ extend Forwardable
14
+ include Logger
15
+ include ConcurrentMonitor
16
+
17
+ def initialize(client:, session:, io:, monitor:)
18
+ @client = client
19
+ @monitor = monitor.new_monitor
20
+ @session = session
21
+ @io = io
22
+ end
23
+
24
+ # Connect runs synchronously reading packets and getting responses
25
+ # until the connection handshakes are complete.
26
+ def connect(**connect_data)
27
+ [send_packet(connect_packet(**connect_data)), complete_connection(receive_packet)]
28
+ end
29
+
30
+ # Build and send a disconnect packet
31
+ def disconnect(exception = nil, **disconnect)
32
+ if exception
33
+ io.close
34
+ else
35
+ yield disconnect_packet(**disconnect)
36
+ end
37
+ end
38
+
39
+ # @yield(packet, action:)
40
+ # @yieldparam [Packet] packet the packet to handle
41
+ # @yieldparam [Symbol] action the action to take,
42
+ # :send to forward a packet as part of protocol flow
43
+ # :acknowledge the packet is a response to a client request
44
+ # :handle other types of packets
45
+ # @yieldreturn [void]
46
+ def receive_loop
47
+ loop { return unless handle_packet(receive_packet) }
48
+ ensure
49
+ io.close_read if io.respond_to?(:close_read) && !io.closed?
50
+ end
51
+
52
+ def send_loop
53
+ packet = pingreq_packet
54
+ loop do
55
+ packet = (yield ping_remaining(packet)) || pingreq_packet
56
+ return unless send_packet(packet)
57
+ end
58
+ ensure
59
+ io.close_write if io.respond_to?(:close_write) && !io.closed?
60
+ end
61
+
62
+ # Session handles publish, subscribe, and unsubscribe, but versioned subclasses may override
63
+ def_delegators :session, :publish, :subscribe, :unsubscribe
64
+
65
+ def connected?
66
+ !io.closed?
67
+ end
68
+
69
+ def close
70
+ io.close
71
+ end
72
+
73
+ attr_reader :keep_alive, :ping_clock
74
+
75
+ private
76
+
77
+ attr_reader :io, :session, :client
78
+
79
+ def_delegators :session, :max_packet_id, :generate_client_id, :retry_packets
80
+ def_delegators :client, :build_packet, :push_packet, :deserialize
81
+
82
+ def ping_remaining(prev_packet)
83
+ return 0 unless ping_clock
84
+
85
+ # if client is only sending QOS 0 publish, we need to send a ping to
86
+ # prevent the recv loop timing out, and thus proving we have bidirectional connectivity
87
+ prev_packet = pingreq_packet.tap { |ping| send_packet(ping) } if ping_clock.expired?
88
+ ping_clock.start! unless prev_packet.packet_name == :publish && prev_packet.qos.zero?
89
+
90
+ ping_clock.remaining
91
+ end
92
+
93
+ def send_packet(packet)
94
+ log.debug { "SEND: #{packet}" }
95
+ client.handle_event(:send, packet == :eof ? nil : packet)
96
+ return false if packet == :eof
97
+
98
+ packet.serialize(io)
99
+
100
+ # cause loop to stop after disconnect is sent
101
+ return false if packet.packet_name == :disconnect
102
+
103
+ packet
104
+ end
105
+
106
+ def receive_packet
107
+ deserialize(io)
108
+ .tap { |p| log.debug { "RECV: #{p || :eof}" } }
109
+ .tap { |p| client.handle_event(:receive, p) }
110
+ end
111
+
112
+ # Allow version-specific subclass to handle additional connection flow (eg auth in 5.0)
113
+ def complete_connection(received_packet)
114
+ raise EOFError unless received_packet
115
+
116
+ received_packet.tap do |connack|
117
+ handle_connack(connack)
118
+ end
119
+ end
120
+
121
+ def connect_packet(**connect)
122
+ build_packet(:connect, **connect_data(**connect)).tap do |p|
123
+ self.keep_alive = p.keep_alive
124
+ end
125
+ end
126
+
127
+ def keep_alive=(val)
128
+ @keep_alive = val
129
+ @ping_clock = (val&.positive? ? ConcurrentMonitor::TimeoutClock.timeout(val) : nil)
130
+ io.timeout = (val&.positive? ? val * 2.0 : nil)
131
+ end
132
+
133
+ def connect_data(**connect)
134
+ connect.merge!(session.connect_data(**connect))
135
+ end
136
+
137
+ def pingreq_packet
138
+ @pingreq_packet ||= build_packet(:pingreq)
139
+ end
140
+
141
+ def disconnect_packet(**disconnect)
142
+ build_packet(:disconnect, **disconnect_data(**disconnect))
143
+ end
144
+
145
+ def disconnect_data(**disconnect)
146
+ disconnect.merge!(session.disconnect_data(**disconnect))
147
+ end
148
+
149
+ def handle_packet(packet)
150
+ # an empty packet means end of stream in the read loop, we'll try and gracefully inform the send loop
151
+ return handle_eof if !packet && !io.closed? && io.eof?
152
+ return nil unless packet
153
+
154
+ send("handle_#{packet.packet_name}", packet)
155
+ true
156
+ end
157
+
158
+ def handle_connack(packet)
159
+ raise ProtocolError, 'Unexpected packet type' unless packet.packet_name == :connack
160
+
161
+ packet.success!
162
+ end
163
+
164
+ def handle_pingresp(_packet)
165
+ # nothing to handle
166
+ end
167
+
168
+ # QOS 1/2 flow - when this client has sent a message via PUBLISH
169
+ def_delegators :session, :handle_puback, :handle_pubrec, :handle_pubcomp
170
+
171
+ # QOS 1/2 flow - when this client has received a message
172
+ def_delegators :session, :handle_publish, :handle_pubrel
173
+
174
+ # Other acks
175
+ def_delegators :session, :handle_suback, :handle_unsuback
176
+
177
+ # This is if the server explicitly sends a disconnect, which would always expect to be an error
178
+ def handle_disconnect(packet)
179
+ packet.success!
180
+ ensure
181
+ io.close
182
+ end
183
+
184
+ # rubocop:disable Naming/PredicateMethod
185
+ def handle_eof
186
+ client.receive_eof
187
+ false
188
+ end
189
+ # rubocop:enable Naming/PredicateMethod
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'subscription'
4
+
5
+ module MQTT
6
+ module Core
7
+ class Client
8
+ # Enumerable subscription supporting iteration over received messages
9
+ #
10
+ # Method variants
11
+ #
12
+ # - methods with aliases suffixed `messages` yield deconstructed topic, payload, and attributes
13
+ # - methods suffixed with `packets` yield raw `PUBLISH` packets
14
+ # - methods prefixed with `async` perform enumeration in a new thread
15
+ # - methods suffixed with bang `!` ensure {#unsubscribe}
16
+ # - methods prefixed with `lazy` return lazy enumerators for advanced chaining
17
+ #
18
+ class EnumerableSubscription < Subscription
19
+ include Enumerable
20
+
21
+ # @!visibility private
22
+ def put(packet)
23
+ handler.enqueue(packet)
24
+ end
25
+
26
+ # @!macro [new] yield_messages
27
+ # @yield [topic, payload, **attributes]
28
+ # @yieldparam [String] topic the message topic.
29
+ # @yieldparam [String] payload the message payload.
30
+ # @yieldparam [Hash<Symbol>] attributes additional `PUBLISH` packet attributes.
31
+ # @yieldreturn [$0]
32
+
33
+ # @!macro [new] yield_packets
34
+ # @yield [packet]
35
+ # @yieldparam [Packet] packet a `PUBLISH` packet.
36
+ # @yieldreturn [$0]
37
+
38
+ # @!macro [new] enum_return
39
+ # @return [void] when block given
40
+ # @return [Enumerator] an enumerator when no block given.
41
+
42
+ # @!macro [new] lazy_enum_return
43
+ # @return [void] when block given
44
+ # @return [Enumerator::Lazy] a lazy enumerator when no block given.
45
+
46
+ # @!macro [new] async_return
47
+ # @return [self, ConcurrentMonitor::Task]
48
+ # a pair containing self and the task that is iterating over the messages.
49
+
50
+ # @!macro [new] qos_note
51
+ # @note QoS 1/2 packets are marked as completely handled in the session store when the given block completes.
52
+ # If no block is given, completion is marked *before* the packet is returned.
53
+
54
+ # Get one packet, blocking until available
55
+ #
56
+ # @!macro qos_note
57
+ # @!macro yield_packets(Object)
58
+ # @return [Packet] a `PUBLISH` packet when no block given
59
+ # @return [Object] the block result when block given
60
+ # @return [nil] when unsubscribed or disconnected
61
+ def get_packet(&)
62
+ handle(handler.dequeue, &)
63
+ end
64
+
65
+ # Get one message, blocking until available
66
+ #
67
+ # @!macro qos_note
68
+ # @!macro yield_messages(Object)
69
+ # @return [String, String, Hash<Symbol>] topic, payload, and attributes when no block given
70
+ # @return [Object] the block result when block given
71
+ # @return [nil] when unsubscribed or disconnected
72
+ def get(&)
73
+ get_packet { |pkt| pkt.deconstruct_message(&) }
74
+ end
75
+ alias get_message get
76
+
77
+ # Read one packet, blocking until available, for use in loops
78
+ # @!macro yield_packets(Object)
79
+ # @return [Packet] a `PUBLISH` packet when no block given
80
+ # @return [Object] the block result when block given
81
+ # @raise [StopIteration] when unsubscribed or disconnected
82
+ def read_packet(&)
83
+ get_packet do |packet|
84
+ raise StopIteration unless packet
85
+
86
+ block_given? ? yield(packet) : packet
87
+ end
88
+ end
89
+
90
+ # Read one message, blocking until available, for use in loops
91
+ # @!macro yield_messages(Object)
92
+ # @return [String, String, Hash<Symbol>] topic, payload, and attributes when no block given
93
+ # @return [Object] the block result when block given
94
+ # @raise [StopIteration] when unsubscribed or disconnected
95
+ def read(&)
96
+ read_packet { |pkt| pkt.deconstruct_message(&) }
97
+ end
98
+ alias read_message read
99
+
100
+ # Enumerate packets
101
+ # @!macro yield_packets(void)
102
+ # @!macro enum_return
103
+ def each_packet(&)
104
+ return enum_for(__method__) unless block_given?
105
+
106
+ loop { read_packet(&) }
107
+ end
108
+
109
+ # Enumerate packets, ensuring {#unsubscribe}
110
+ # @!macro yield_packets(void)
111
+ # @!macro enum_return
112
+ def each_packet!(&) = enum_for!(__method__, &)
113
+
114
+ # Enumerate messages
115
+ # @!macro yield_messages(void)
116
+ # @!macro enum_return
117
+ def each
118
+ return enum_for(__method__) unless block_given?
119
+
120
+ each_packet { |pkt| yield(*pkt.deconstruct_message) }
121
+ end
122
+
123
+ alias each_message each
124
+
125
+ # Enumerate messages, ensuring {#unsubscribe}
126
+ # @!macro yield_messages(void)
127
+ # @!macro enum_return
128
+ def each!(&) = enum_for!(__method__, &)
129
+ alias each_message! each!
130
+
131
+ # Return a lazy enumerator for advanced chaining
132
+ # @return [Enumerator::Lazy<String, String, Hash>] lazy enumerator yielding [topic, payload, **attributes]
133
+ # @example
134
+ # sub.lazy.select { |t, p| p.size > 100 }.map { |t, p| JSON.parse(p) }.take(5)
135
+ def lazy
136
+ each.lazy
137
+ end
138
+ alias lazy_messages lazy
139
+
140
+ # Return a lazy enumerator ensuring {#unsubscribe}
141
+ # @return [Enumerator::Lazy<String, String, Hash>] lazy enumerator yielding [topic, payload, **attributes]
142
+ # @example
143
+ # sub.lazy!.select { |t, p| p.size > 100 }.map { |t, p| JSON.parse(p) }.take(5)
144
+ def lazy!
145
+ each!.lazy
146
+ end
147
+ alias lazy_messages! lazy!
148
+
149
+ # Return a lazy packet enumerator for advanced chaining
150
+ # @return [Enumerator::Lazy<Packet>] lazy enumerator yielding PUBLISH packets
151
+ def lazy_packets
152
+ each_packet.lazy
153
+ end
154
+
155
+ # Return a lazy packet enumerator with auto-unsubscribe
156
+ # @return [Enumerator::Lazy<Packet>] lazy enumerator yielding PUBLISH packets
157
+ def lazy_packets!
158
+ each_packet!.lazy
159
+ end
160
+
161
+ # Enumerate messages in a new thread
162
+ # @overload async(&)
163
+ # @!macro yield_messages(void)
164
+ # @!macro async_return
165
+ # @see each
166
+ def async(method = :each, &)
167
+ raise ArgumentError, 'block is required for async enumeration' unless block_given?
168
+
169
+ [self, client.async("sub.#{method}") { send(method, &) }]
170
+ end
171
+ alias async_messages async
172
+
173
+ # Enumerate messages in a new thread, ensuring {#unsubscribe}
174
+ # @!macro yield_messages(void)
175
+ # @!macro async_return
176
+ # @see each!
177
+ def async!(&) = async(:each!, &)
178
+ alias async_messages! async!
179
+
180
+ # Enumerate packets in a new thread
181
+ # @!macro yield_packets(void)
182
+ # @!macro async_return
183
+ # @see each_packet
184
+ def async_packets(&) = async(:each_packet, &)
185
+
186
+ # Enumerate packets in a new thread, ensuring {#unsubscribe}
187
+ # @!macro yield_packets(void)
188
+ # @!macro async_return
189
+ # @see each_packet!
190
+ def async_packets!(&) = async(:each_packet!, &)
191
+
192
+ # Delegates Enumerable methods ending in `!` to {#each!}, ensuring {#unsubscribe}
193
+ #
194
+ # @note Methods that require consuming the entire enumerable (e.g., `map`, `select`)
195
+ # will block indefinitely on an infinite subscription stream unless combined with
196
+ # limiting methods like `take` or terminated by a `break` condition.
197
+ #
198
+ # @example
199
+ # sub.take!(5) # => Array of 5 messages, then unsubscribes
200
+ # sub.first! # => First message, then unsubscribes
201
+ # sub.find! { |t, p| ... } # => Matching message, then unsubscribes
202
+ def method_missing(method, *, &)
203
+ if method.end_with?('!') && Enumerable.public_instance_methods.include?(method[..-2].to_sym)
204
+ each!.public_send(method[..-2], *, &)
205
+ else
206
+ super
207
+ end
208
+ end
209
+
210
+ private
211
+
212
+ def respond_to_missing?(method, include_private = false)
213
+ (method.end_with?('!') && Enumerable.public_instance_methods.include?(method[..-2].to_sym)) || super
214
+ end
215
+
216
+ def enum_for!(method, &)
217
+ return enum_for(method) unless block_given?
218
+
219
+ with! { send(method[..-2], &) }
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'tmpdir'
5
+ require 'tempfile'
6
+ require 'fileutils'
7
+ require_relative 'qos2_session_store'
8
+ module MQTT
9
+ module Core
10
+ class Client
11
+ # A Session Store that holds packets in the filesystem.
12
+ class FilesystemSessionStore < Qos2SessionStore
13
+ attr_reader :client_dir, :base_dir, :session_expiry_file
14
+
15
+ # @param [String] base_dir the base directory to store session files in
16
+ # @param [String|nil] client_id
17
+ # empty string is not permitted, but nil can be used to force the generation of a random id.
18
+ # @param [Integer|nil] expiry_interval
19
+ # zero is not permitted, but nil represents never expire (server may negotiate a lower value)
20
+ def initialize(client_id:, expiry_interval:, base_dir: Dir.mktmpdir('mqtt'))
21
+ @base_dir = Pathname.new(base_dir)
22
+ @client_dir = (base_dir + client_id)
23
+ super(client_id:, expiry_interval:)
24
+
25
+ @session_expiry_file = (base_dir + "#{client_id}.expiry")
26
+ cleanup_tmp
27
+ log.info { "client_dir: #{@client_dir}, clean?: #{clean?}" }
28
+ end
29
+
30
+ def restart_clone
31
+ self.class.new(base_dir, client_id:, expiry_interval:)
32
+ end
33
+
34
+ def clean?
35
+ !client_dir.exist?
36
+ end
37
+
38
+ def connected!
39
+ client_dirs.each(&:mkpath)
40
+
41
+ # record the previous session expiry duration so we can check it on a future restart
42
+ session_expiry_file.open('w') { |f| f.write(expiry_interval.to_s) }
43
+ end
44
+
45
+ def disconnected!
46
+ session_expiry_file.utime(nil, nil) # now
47
+ end
48
+
49
+ def expired?
50
+ return false unless session_expiry_file.exist?
51
+
52
+ # choose the most recent of...
53
+ # * the directory modification times (updated each time a packet file is added or removed),
54
+ # * the session_expiry_file modification time (updated on disconnect)
55
+ # A hard crash without a clean disconnect will potentially expire a session earlier than the server
56
+ Time.now - (client_dirs + [session_expiry_file]).map(&:mtime).max > session_expiry_file.read.to_i
57
+ end
58
+
59
+ def store_packet(packet, replace: false)
60
+ raise KeyError, 'packet id already exists' if !replace && stored_packet?(packet.id)
61
+
62
+ packet_file(packet.id).open('wb') { |f| packet.serialize(f) }
63
+ end
64
+
65
+ def delete_packet(id)
66
+ packet_file(id).delete
67
+ end
68
+
69
+ def stored_packet?(id)
70
+ packet_file(id).exist?
71
+ end
72
+
73
+ def retry_packets(&)
74
+ @client_dir.glob('pkt.*').sort_by(&:mtime).map { |f| f.open('r', &) }
75
+ end
76
+
77
+ def packet_file(id)
78
+ @client_dir + format('pkt/%04x.mqtt', id)
79
+ end
80
+
81
+ # QOS Receive
82
+ # Unique ID is sortable (fixed width timestamp)
83
+ # qos1 live: `/#{client_id}/qos1/#{unique_id}_#{packet_id}.live` write on PUBLISH, deleted on handled.
84
+ # qos2 live: `/#{client_id}/qos2/#{unique_id}_#{packet_id}.live` (unhandled, unreleased)
85
+ # qos2 handled: `/#{client_id}/qos2/#{unique_id}_#{packet_id}.handled` (handled, unreleased)
86
+ # qos2 released: `/#{client_id}/qos2/#{unique_id}_#{packet_id}.released` (unhandled, released)
87
+ # qos2 replay: '/#{client_id}/qos2/#{unique_id}_#{packet_id}.replay_[live|handled]
88
+
89
+ # TODO: Recover utility
90
+ # * cleanup_tmp
91
+ # * qos2/*.live - rename to .replay_live or .handled
92
+ # * qos2/*.released - rename to .replay_released or delete
93
+
94
+ def store_qos_received(packet, unique_id)
95
+ client_dir + qos_path(packet.qos, packet.id, unique_id).tap do |live_file|
96
+ tmp_file = live_file.sub_ext('live', 'tmp')
97
+ tmp_file.open('wb') { |f| packet.serialize(f) }
98
+ tmp_file.rename(live_file)
99
+ end
100
+ end
101
+
102
+ # Release the pending qos2 (return true if we had previously seen it)
103
+ def qos2_release(id)
104
+ qos2_live = find_qos2_file(id)
105
+
106
+ if qos2_live&.extname == '.live'
107
+ qos2_live.rename(qos2_live.sub_ext('.live', '.released'))
108
+ else
109
+ qos2_live&.delete
110
+ end
111
+
112
+ super
113
+ rescue Errno::ENOENT
114
+ retry
115
+ end
116
+
117
+ def qos_handled(packet, unique_id)
118
+ if packet.qos == 1
119
+ qos1_handled(packet, unique_id)
120
+ elsif packet.qos == 2
121
+ qos2_handled(packet, unique_id)
122
+ end
123
+ end
124
+
125
+ # Called once at initialize.
126
+ # rubocop:disable Metrics/AbcSize
127
+ def qos2_recover
128
+ # Abort if there are unmarked files to potentially replay
129
+ if (client_dir.glob('qos2/*.live') + client_dir.glob('qos2/*.released')).any?
130
+ raise SessionNotRecoverable, "Unhandled QOS2 messages in #{"#{client_dir}/qos2"}. Run recover utility"
131
+ end
132
+
133
+ client_dir.glob('qos2/*.replay_live').each { |q2| q2.rename(q2.sub_ext('.live')) }
134
+ client_dir.glob('qos2/*.replay_released').each { |q2| q2.rename(q2.sub_ext('.released')) }
135
+
136
+ client_dir.glob(%w[qos2/*.live qos2/*.handled]).map { |f| f.basename.to_s.split('_').last.to_i(16) }
137
+ end
138
+ # rubocop:enable Metrics/AbcSize
139
+
140
+ # Load the unhandled packets with their unique id, only called once per session store
141
+ def qos_unhandled_packets(&)
142
+ client_dir.glob(%w[qos?/*.live qos2/*.released]).sort_by(&:basename)
143
+ .to_h { |f| [f.open('r', &), f.basename.to_s.split('_').first] }
144
+ end
145
+
146
+ private
147
+
148
+ def cleanup_tmp
149
+ # Cleanup crashed .tmp files
150
+ client_dir.glob('qos?/*.tmp').each(&:delete)
151
+ end
152
+
153
+ # Make directories.
154
+ # pkt - packets we are sending, waiting to be acked
155
+ # qos1 - qos1 packets received, waiting to be handled
156
+ # qos2 - qos2 packets received, waiting to be released and handled
157
+ def client_dirs
158
+ %w[pkt qos1 qos2].map { |d| client_dir + d }
159
+ end
160
+
161
+ def qos2_handled(packet, unique_id)
162
+ live_file = client_dir + qos_path(2, packet.id, unique_id)
163
+ rel_file = client_dir + qos_path(2, packet.id, unique_id, 'released')
164
+
165
+ live_file.rename(live_file.sub_ext('.handled')) if live_file.exist?
166
+ rel_file.unlink if rel_file.exist?
167
+ rescue Errno::ENOENT
168
+ retry
169
+ end
170
+
171
+ def qos1_handled(packet, unique_id)
172
+ live_file = (client_dir + qos_path(1, packet.id, unique_id))
173
+ live_file.unlink
174
+ rescue Errno::ENOENT
175
+ log.warn { "qos_handled: #{live_file} unexpectedly not exists" }
176
+ end
177
+
178
+ # @return [String]
179
+ def qos_path(qos, packet_id, unique_id, ext = 'live')
180
+ format('qos%<qos>i/%<unique_id>s_%<packet_id>05x.%<ext>s', qos:, unique_id:, packet_id:, ext:)
181
+ end
182
+
183
+ def find_qos2_file(id)
184
+ # search live and handled separately to avoid race while renaming
185
+ live_files = client_dir.glob(qos_path(2, id, '*', 'live'))
186
+ raise ProtocolError, "QOS(#{id}): more than one packet: #{live_files}" if live_files.size > 1
187
+ return live_files.first if live_files.size == 1
188
+
189
+ handled_files = client_dir.glob(qos_path(2, id, '*', 'handled'))
190
+ raise ProtocolError, "QOS(#{id}): more than one packet: #{handled_files}" if handled_files.size > 1
191
+
192
+ handled_files.first
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end