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.
- checksums.yaml +7 -0
- data/lib/mqtt/core/client/acknowledgement.rb +31 -0
- data/lib/mqtt/core/client/client_id_generator.rb +19 -0
- data/lib/mqtt/core/client/connection.rb +193 -0
- data/lib/mqtt/core/client/enumerable_subscription.rb +224 -0
- data/lib/mqtt/core/client/filesystem_session_store.rb +197 -0
- data/lib/mqtt/core/client/memory_session_store.rb +84 -0
- data/lib/mqtt/core/client/qos0_session_store.rb +56 -0
- data/lib/mqtt/core/client/qos2_session_store.rb +45 -0
- data/lib/mqtt/core/client/qos_tracker.rb +119 -0
- data/lib/mqtt/core/client/retry_strategy.rb +63 -0
- data/lib/mqtt/core/client/session.rb +195 -0
- data/lib/mqtt/core/client/session_store.rb +128 -0
- data/lib/mqtt/core/client/socket_factory.rb +268 -0
- data/lib/mqtt/core/client/subscription.rb +109 -0
- data/lib/mqtt/core/client/uri.rb +77 -0
- data/lib/mqtt/core/client.rb +700 -0
- data/lib/mqtt/core/packet/connect.rb +39 -0
- data/lib/mqtt/core/packet/publish.rb +45 -0
- data/lib/mqtt/core/packet/subscribe.rb +190 -0
- data/lib/mqtt/core/packet/unsubscribe.rb +21 -0
- data/lib/mqtt/core/packet.rb +168 -0
- data/lib/mqtt/core/type/binary.rb +35 -0
- data/lib/mqtt/core/type/bit_flags.rb +89 -0
- data/lib/mqtt/core/type/boolean_byte.rb +48 -0
- data/lib/mqtt/core/type/fixed_int.rb +43 -0
- data/lib/mqtt/core/type/list.rb +41 -0
- data/lib/mqtt/core/type/password.rb +36 -0
- data/lib/mqtt/core/type/properties.rb +124 -0
- data/lib/mqtt/core/type/reason_codes.rb +60 -0
- data/lib/mqtt/core/type/remaining.rb +30 -0
- data/lib/mqtt/core/type/shape.rb +177 -0
- data/lib/mqtt/core/type/sub_type.rb +34 -0
- data/lib/mqtt/core/type/utf8_string.rb +39 -0
- data/lib/mqtt/core/type/utf8_string_pair.rb +29 -0
- data/lib/mqtt/core/type/var_int.rb +56 -0
- data/lib/mqtt/core/version.rb +10 -0
- data/lib/mqtt/core.rb +5 -0
- data/lib/mqtt/errors.rb +39 -0
- data/lib/mqtt/logger.rb +92 -0
- data/lib/mqtt/open.rb +239 -0
- data/lib/mqtt/options.rb +59 -0
- data/lib/mqtt/version.rb +6 -0
- data/lib/mqtt.rb +3 -0
- data/lib/patches/openssl.rb +19 -0
- 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
|