tina4ruby 3.13.81 → 3.13.82
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 +4 -4
- data/CHANGELOG.md +35 -2
- data/README.md +4 -4
- data/lib/tina4/background.rb +21 -2
- data/lib/tina4/cli.rb +141 -14
- data/lib/tina4/mqtt.rb +800 -0
- data/lib/tina4/mqtt_message.rb +78 -0
- data/lib/tina4/public/css/tina4.css +56 -130
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/rack_app.rb +13 -0
- data/lib/tina4/router.rb +13 -2
- data/lib/tina4/scss/tina4css/_pagination.scss +63 -0
- data/lib/tina4/scss/tina4css/_utilities.scss +11 -0
- data/lib/tina4/scss/tina4css/tina4.scss +1 -0
- data/lib/tina4/scss_compiler.rb +48 -3
- data/lib/tina4/test_client.rb +39 -52
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +16 -1
- metadata +6 -3
- /data/lib/tina4/{sql_translation.rb → sql_translator.rb} +0 -0
data/lib/tina4/mqtt.rb
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "mqtt_message"
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
# Any MQTT protocol / connection failure.
|
|
9
|
+
class MqttError < StandardError; end
|
|
10
|
+
|
|
11
|
+
# The broker did not answer inside the timeout.
|
|
12
|
+
class MqttTimeoutError < MqttError; end
|
|
13
|
+
|
|
14
|
+
# Zero-dependency MQTT 3.1.1 client — the protocol every broker and every
|
|
15
|
+
# IoT device already speaks. Built on `socket` and Array#pack/String#unpack
|
|
16
|
+
# only: no gem, so an app that talks to Mosquitto / EMQX / HiveMQ / AWS IoT
|
|
17
|
+
# adds nothing to its dependency tree.
|
|
18
|
+
#
|
|
19
|
+
# Shaped like Tina4::Queue on purpose — publish / subscribe / consume:
|
|
20
|
+
#
|
|
21
|
+
# mqtt = Tina4::Mqtt.new # TINA4_MQTT_URL
|
|
22
|
+
# mqtt.publish("fleet/meter-42/telemetry", '{"kwh":12.5}', qos: 1)
|
|
23
|
+
#
|
|
24
|
+
# mqtt.consume("fleet/+/telemetry", qos: 1) do |message|
|
|
25
|
+
# next if message.duplicate? # QoS 1 is at-least-once
|
|
26
|
+
# store(message.topic, message.payload)
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# Environment: TINA4_MQTT_URL (default mqtt://127.0.0.1:1883),
|
|
30
|
+
# TINA4_MQTT_CLIENT_ID, TINA4_MQTT_KEEPALIVE (seconds, default 60).
|
|
31
|
+
#
|
|
32
|
+
# No background thread is started by default. When a connection is otherwise
|
|
33
|
+
# idle the broker will drop it past 1.5x keepalive, so opt in to the
|
|
34
|
+
# cooperative keepalive with `start_keepalive` — it registers a
|
|
35
|
+
# Tina4::Background task exactly like the queue consumers do, rather than
|
|
36
|
+
# spawning a thread of its own.
|
|
37
|
+
#
|
|
38
|
+
# Single reader: like every MQTT client (paho included) the socket has ONE
|
|
39
|
+
# network reader. `receive`/`consume` from one thread; `publish` and the
|
|
40
|
+
# keepalive may be called from others (writes are serialised by a mutex).
|
|
41
|
+
class Mqtt
|
|
42
|
+
# Control packet types. The low nibble of SUBSCRIBE/UNSUBSCRIBE is 0x2
|
|
43
|
+
# because MQTT 3.1.1 mandates QoS 1 on those two packets.
|
|
44
|
+
CONNECT = 0x10
|
|
45
|
+
CONNACK = 0x20
|
|
46
|
+
PUBLISH = 0x30
|
|
47
|
+
PUBACK = 0x40
|
|
48
|
+
SUBSCRIBE = 0x82
|
|
49
|
+
SUBACK = 0x90
|
|
50
|
+
PINGREQ = 0xC0
|
|
51
|
+
PINGRESP = 0xD0
|
|
52
|
+
DISCONNECT = 0xE0
|
|
53
|
+
|
|
54
|
+
PROTOCOL_LEVEL = 0x04 # 4 == MQTT 3.1.1
|
|
55
|
+
DEFAULT_PORT = 1883
|
|
56
|
+
DEFAULT_TLS_PORT = 8883
|
|
57
|
+
DEFAULT_URL = "mqtt://127.0.0.1:1883"
|
|
58
|
+
DEFAULT_KEEPALIVE = 60
|
|
59
|
+
SUBSCRIPTION_REFUSED = 0x80
|
|
60
|
+
MAX_REMAINING_LENGTH = 268_435_455 # 4 varint bytes
|
|
61
|
+
# Read granularity. Always reading a full chunk keeps BOTH hidden buffers
|
|
62
|
+
# (Ruby's OpenSSL::Buffering @rbuf and OpenSSL's own SSL_pending) drained, so
|
|
63
|
+
# IO.select never blocks while decrypted plaintext is already waiting.
|
|
64
|
+
READ_CHUNK = 16_384
|
|
65
|
+
|
|
66
|
+
# QoS 2 is refused, never silently downgraded. A caller who asked for
|
|
67
|
+
# exactly-once and quietly got at-least-once would double-process every
|
|
68
|
+
# duplicate forever without ever seeing an error.
|
|
69
|
+
QOS2_REFUSED_MESSAGE =
|
|
70
|
+
"MQTT QoS 2 (exactly-once delivery) is not supported by Tina4 — this " \
|
|
71
|
+
"client speaks QoS 0 and QoS 1 only, and refuses QoS 2 rather than " \
|
|
72
|
+
"silently downgrading it to QoS 1. Use QoS 1 with an idempotent " \
|
|
73
|
+
"consumer keyed on (device_id, device_timestamp): duplicates are then " \
|
|
74
|
+
"harmless, which is what exactly-once was for."
|
|
75
|
+
|
|
76
|
+
# Human-readable CONNACK return codes (MQTT 3.1.1 section 3.2.2.3).
|
|
77
|
+
CONNACK_RETURN_CODES = {
|
|
78
|
+
1 => "unacceptable protocol version",
|
|
79
|
+
2 => "client identifier rejected",
|
|
80
|
+
3 => "server unavailable",
|
|
81
|
+
4 => "bad user name or password",
|
|
82
|
+
5 => "not authorised"
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
attr_reader :host, :port, :client_id, :keepalive, :clean_session, :username
|
|
86
|
+
|
|
87
|
+
# url: mqtt://host:port, or mqtts://host:port for TLS. Credentials
|
|
88
|
+
# may be carried in the userinfo (mqtt://user:pass@host), and
|
|
89
|
+
# are percent-decoded, so a password may contain @ : or /.
|
|
90
|
+
# Falls back to TINA4_MQTT_URL, then localhost.
|
|
91
|
+
# client_id: broker-visible session id (TINA4_MQTT_CLIENT_ID, else generated).
|
|
92
|
+
# A durable session (clean_session: false) needs a STABLE id —
|
|
93
|
+
# the generated one changes every process.
|
|
94
|
+
# username: broker username; overrides the url's userinfo when given
|
|
95
|
+
# password: broker password; MQTT 3.1.1 forbids one without a username
|
|
96
|
+
# ca_file: PEM CA bundle used to verify the broker's certificate
|
|
97
|
+
# (TINA4_MQTT_CA_FILE). Needed for a private or self-signed CA.
|
|
98
|
+
# tls_verify: false disables certificate verification (TINA4_MQTT_TLS_VERIFY).
|
|
99
|
+
# NEVER the default, and it logs a warning naming the risk —
|
|
100
|
+
# an unverified TLS session is encrypted but not authenticated,
|
|
101
|
+
# so a man in the middle can read and rewrite it.
|
|
102
|
+
# keepalive: seconds (TINA4_MQTT_KEEPALIVE, default 60)
|
|
103
|
+
# clean_session: false keeps the subscription + queues QoS 1 messages for us
|
|
104
|
+
# while we are offline, and replays them on reconnect
|
|
105
|
+
# will_*: Last Will, published by the broker when we die WITHOUT a
|
|
106
|
+
# DISCONNECT — dead-device detection that is observed, not
|
|
107
|
+
# inferred from silence
|
|
108
|
+
# timeout: seconds to wait for a control-packet answer (CONNACK / PUBACK / SUBACK)
|
|
109
|
+
# read_timeout: seconds to wait for an application message; nil blocks
|
|
110
|
+
# connect: false builds the client without opening a socket
|
|
111
|
+
def initialize(url: nil, client_id: nil, username: nil, password: nil,
|
|
112
|
+
ca_file: nil, tls_verify: nil, keepalive: nil, clean_session: true,
|
|
113
|
+
will_topic: nil, will_payload: nil, will_qos: 0, will_retain: false,
|
|
114
|
+
timeout: 5, read_timeout: nil, connect: true)
|
|
115
|
+
parsed = self.class.parse_url(url || ENV["TINA4_MQTT_URL"] || DEFAULT_URL)
|
|
116
|
+
@host = parsed[:host]
|
|
117
|
+
@port = parsed[:port]
|
|
118
|
+
@tls = parsed[:tls]
|
|
119
|
+
# Explicit arguments win over the url's userinfo: the more specific source.
|
|
120
|
+
@username = username || parsed[:username]
|
|
121
|
+
@password = password || parsed[:password]
|
|
122
|
+
if @password && (@username.nil? || @username.empty?)
|
|
123
|
+
raise ArgumentError,
|
|
124
|
+
"MQTT password without a username is not allowed by MQTT 3.1.1 — " \
|
|
125
|
+
"supply both (mqtt://user:pass@host, or username:/password:) or neither"
|
|
126
|
+
end
|
|
127
|
+
@ca_file = ca_file || presence(ENV["TINA4_MQTT_CA_FILE"])
|
|
128
|
+
@tls_verify = tls_verify.nil? ? Tina4::Env.is_truthy(ENV.fetch("TINA4_MQTT_TLS_VERIFY", "true")) : tls_verify
|
|
129
|
+
@client_id = client_id || ENV["TINA4_MQTT_CLIENT_ID"]
|
|
130
|
+
@client_id = "tina4-#{SecureRandom.hex(8)}" if @client_id.nil? || @client_id.empty?
|
|
131
|
+
@keepalive = (keepalive || ENV["TINA4_MQTT_KEEPALIVE"] || DEFAULT_KEEPALIVE).to_i
|
|
132
|
+
@clean_session = clean_session
|
|
133
|
+
@will_topic = will_topic
|
|
134
|
+
@will_payload = will_payload
|
|
135
|
+
@will_qos = will_qos
|
|
136
|
+
@will_retain = will_retain
|
|
137
|
+
@timeout = timeout
|
|
138
|
+
@read_timeout = read_timeout
|
|
139
|
+
|
|
140
|
+
refuse_unsupported_qos(will_qos) if will_topic
|
|
141
|
+
|
|
142
|
+
@packet_id = 0
|
|
143
|
+
@inbox = []
|
|
144
|
+
@write_mutex = Mutex.new
|
|
145
|
+
@last_write_at = 0.0
|
|
146
|
+
@socket = nil
|
|
147
|
+
@keepalive_task = nil
|
|
148
|
+
@read_buffer = String.new(capacity: READ_CHUNK, encoding: Encoding::BINARY)
|
|
149
|
+
@read_cursor = 0
|
|
150
|
+
|
|
151
|
+
self.connect if connect
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Split an MQTT url into its parts:
|
|
155
|
+
# { host:, port:, tls:, username:, password: }
|
|
156
|
+
#
|
|
157
|
+
# "mqtt://host:port" and "tcp://host:port" are plain TCP (default port 1883),
|
|
158
|
+
# "mqtts://host:port" is TLS (default port 8883). A bare "host" or
|
|
159
|
+
# "host:port" works too, and an IPv6 literal is bracketed ("mqtt://[::1]:1883").
|
|
160
|
+
# Credentials ride in the userinfo and are percent-decoded, so a password
|
|
161
|
+
# containing @ : or / survives.
|
|
162
|
+
def self.parse_url(url)
|
|
163
|
+
raw = url.to_s.strip
|
|
164
|
+
raise ArgumentError, "MQTT url is empty — set TINA4_MQTT_URL (e.g. #{DEFAULT_URL})" if raw.empty?
|
|
165
|
+
|
|
166
|
+
scheme = raw[%r{\A([A-Za-z][A-Za-z0-9+.-]*)://}, 1]&.downcase
|
|
167
|
+
unless scheme.nil? || %w[mqtt tcp mqtts].include?(scheme)
|
|
168
|
+
raise ArgumentError,
|
|
169
|
+
"unsupported MQTT url scheme #{scheme.inspect} in #{raw.inspect} — " \
|
|
170
|
+
"this client speaks mqtt://, tcp:// or mqtts:// (TLS). " \
|
|
171
|
+
"WebSocket transports are not implemented."
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
tls = scheme == "mqtts"
|
|
175
|
+
rest = scheme ? raw.sub(%r{\A[A-Za-z][A-Za-z0-9+.-]*://}, "") : raw
|
|
176
|
+
|
|
177
|
+
# Split on the LAST "@" so a password containing an un-encoded "@" still
|
|
178
|
+
# leaves the host intact.
|
|
179
|
+
username = password = nil
|
|
180
|
+
if rest.include?("@")
|
|
181
|
+
userinfo, _, rest = rest.rpartition("@")
|
|
182
|
+
raw_username, has_password, raw_password = userinfo.partition(":")
|
|
183
|
+
username = percent_decode(raw_username)
|
|
184
|
+
password = percent_decode(raw_password) unless has_password.empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
match = rest.match(%r{\A(?<host>\[[^\]]+\]|[^:/]+)(?::(?<port>\d+))?(?:/.*)?\z})
|
|
188
|
+
raise ArgumentError, "malformed MQTT url #{raw.inspect} — expected mqtt://host:port" unless match
|
|
189
|
+
|
|
190
|
+
{
|
|
191
|
+
host: match[:host].delete_prefix("[").delete_suffix("]"),
|
|
192
|
+
port: (match[:port] || (tls ? DEFAULT_TLS_PORT : DEFAULT_PORT)).to_i,
|
|
193
|
+
tls: tls,
|
|
194
|
+
username: presence(username),
|
|
195
|
+
password: password
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# %XX in url userinfo. Not CGI.unescape / URI.decode_www_form_component:
|
|
200
|
+
# those also turn "+" into a space, which silently corrupts a password.
|
|
201
|
+
def self.percent_decode(value)
|
|
202
|
+
value.to_s.gsub(/%([0-9A-Fa-f]{2})/) { ::Regexp.last_match(1).hex.chr }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def self.presence(value)
|
|
206
|
+
value.nil? || value.empty? ? nil : value
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Remaining Length: 7 bits per byte, high bit means "another byte follows".
|
|
210
|
+
# A single-byte assumption works for every packet under 128 bytes and then
|
|
211
|
+
# fails, so this is exercised directly at 0 / 127 / 128 / 16383.
|
|
212
|
+
def self.encode_remaining_length(value)
|
|
213
|
+
length = Integer(value)
|
|
214
|
+
if length.negative? || length > MAX_REMAINING_LENGTH
|
|
215
|
+
raise ArgumentError, "remaining length #{length} is outside 0..#{MAX_REMAINING_LENGTH}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
encoded = String.new(capacity: 4, encoding: Encoding::BINARY)
|
|
219
|
+
loop do
|
|
220
|
+
byte = length & 0x7F
|
|
221
|
+
length >>= 7
|
|
222
|
+
encoded << (length.positive? ? (byte | 0x80) : byte)
|
|
223
|
+
break unless length.positive?
|
|
224
|
+
end
|
|
225
|
+
encoded
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Open the socket and complete the CONNECT / CONNACK handshake. Also the
|
|
229
|
+
# reconnect path: an existing socket is closed first, and a durable session
|
|
230
|
+
# (clean_session: false) resumes with the same client_id.
|
|
231
|
+
def connect
|
|
232
|
+
close_socket
|
|
233
|
+
raw_socket = Socket.tcp(@host, @port, connect_timeout: @timeout)
|
|
234
|
+
# Telemetry frames are tiny; Nagle would add latency for no gain.
|
|
235
|
+
raw_socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
236
|
+
@socket = @tls ? wrap_in_tls(raw_socket) : raw_socket
|
|
237
|
+
@inbox.clear
|
|
238
|
+
@read_buffer.clear
|
|
239
|
+
@read_cursor = 0
|
|
240
|
+
|
|
241
|
+
body = String.new(capacity: 64, encoding: Encoding::BINARY)
|
|
242
|
+
body << mqtt_string("MQTT")
|
|
243
|
+
body << [PROTOCOL_LEVEL, connect_flags].pack("C2")
|
|
244
|
+
body << [@keepalive].pack("n")
|
|
245
|
+
# Payload order is FIXED: client id, will topic, will message, username,
|
|
246
|
+
# password. Emitting them in any other order shifts every field after it.
|
|
247
|
+
body << mqtt_string(@client_id)
|
|
248
|
+
if @will_topic
|
|
249
|
+
body << mqtt_string(@will_topic)
|
|
250
|
+
will_bytes = payload_bytes(@will_payload)
|
|
251
|
+
body << [will_bytes.bytesize].pack("n") << will_bytes
|
|
252
|
+
end
|
|
253
|
+
body << mqtt_string(@username) if @username
|
|
254
|
+
body << mqtt_string(@password) if @password
|
|
255
|
+
write_packet(CONNECT, body)
|
|
256
|
+
|
|
257
|
+
header, payload = read_packet(deadline_in(@timeout))
|
|
258
|
+
unless header == CONNACK && payload.bytesize >= 2
|
|
259
|
+
raise MqttError, format("expected CONNACK, got %#04x", header)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
return_code = payload.getbyte(1)
|
|
263
|
+
unless return_code.zero?
|
|
264
|
+
reason = CONNACK_RETURN_CODES.fetch(return_code, "unknown return code")
|
|
265
|
+
raise MqttError, "broker refused the connection: #{reason} (CONNACK return code #{return_code})"
|
|
266
|
+
end
|
|
267
|
+
true
|
|
268
|
+
rescue SystemCallError, IOError => e
|
|
269
|
+
close_socket
|
|
270
|
+
raise MqttError, "could not connect to MQTT broker at #{@host}:#{@port}: #{e.message}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def connected?
|
|
274
|
+
!@socket.nil? && !@socket.closed?
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# True when this connection runs over TLS (mqtts://).
|
|
278
|
+
def tls?
|
|
279
|
+
@tls
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The negotiated cipher suite name, or nil on a plain connection. A real
|
|
283
|
+
# name here is proof the TLS handshake actually completed.
|
|
284
|
+
def cipher
|
|
285
|
+
return nil unless @tls && connected?
|
|
286
|
+
|
|
287
|
+
@socket.cipher&.first
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The negotiated TLS protocol version ("TLSv1.3"), or nil when plain.
|
|
291
|
+
def tls_version
|
|
292
|
+
return nil unless @tls && connected?
|
|
293
|
+
|
|
294
|
+
@socket.ssl_version
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Never dump @password: a client can end up in a log line, an exception
|
|
298
|
+
# report or the debug overlay, and Ruby's default inspect prints every ivar.
|
|
299
|
+
def inspect
|
|
300
|
+
format("#<%s %s://%s:%d client_id=%s%s connected=%s>",
|
|
301
|
+
self.class.name, @tls ? "mqtts" : "mqtt", @host, @port,
|
|
302
|
+
@client_id.inspect, @username ? " username=#{@username.inspect}" : "", connected?)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Publish an application message. Returns the packet identifier for QoS 1
|
|
306
|
+
# (the broker's PUBACK must carry it back) and nil for QoS 0.
|
|
307
|
+
#
|
|
308
|
+
# retain: true tells the broker to keep this as the topic's last known
|
|
309
|
+
# value and hand it to every FUTURE subscriber — how a dashboard shows
|
|
310
|
+
# current state the moment it connects. Publishing an EMPTY payload with
|
|
311
|
+
# retain: true is how a retained value is cleared.
|
|
312
|
+
def publish(topic, payload, qos: 0, retain: false)
|
|
313
|
+
refuse_unsupported_qos(qos)
|
|
314
|
+
bytes = payload_bytes(payload)
|
|
315
|
+
topic_field = mqtt_string(topic)
|
|
316
|
+
# The packet identifier exists ONLY when QoS > 0. Emitting it at QoS 0 (or
|
|
317
|
+
# omitting it at QoS 1) desynchronises the stream and every later packet
|
|
318
|
+
# mis-parses.
|
|
319
|
+
packet_id = qos.positive? ? next_packet_id : nil
|
|
320
|
+
|
|
321
|
+
body = String.new(capacity: topic_field.bytesize + bytes.bytesize + 2,
|
|
322
|
+
encoding: Encoding::BINARY)
|
|
323
|
+
body << topic_field
|
|
324
|
+
body << [packet_id].pack("n") if packet_id
|
|
325
|
+
body << bytes
|
|
326
|
+
write_packet(PUBLISH | (qos << 1) | (retain ? 0x01 : 0x00), body)
|
|
327
|
+
|
|
328
|
+
wait_for_acknowledgement(PUBACK, packet_id, "PUBACK") if qos == 1
|
|
329
|
+
packet_id
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Subscribe to a topic filter ("fleet/+/telemetry", "fleet/#"). Returns the
|
|
333
|
+
# QoS the broker GRANTED, which can be lower than the one requested.
|
|
334
|
+
#
|
|
335
|
+
# A SUBACK carrying 0x80 is a REFUSAL, not a success — treating any SUBACK
|
|
336
|
+
# as success means sitting on a dead subscription receiving nothing, so it
|
|
337
|
+
# raises here.
|
|
338
|
+
def subscribe(topic_filter, qos: 1)
|
|
339
|
+
refuse_unsupported_qos(qos)
|
|
340
|
+
packet_id = next_packet_id
|
|
341
|
+
filter_field = mqtt_string(topic_filter)
|
|
342
|
+
|
|
343
|
+
body = String.new(capacity: filter_field.bytesize + 3, encoding: Encoding::BINARY)
|
|
344
|
+
body << [packet_id].pack("n")
|
|
345
|
+
body << filter_field
|
|
346
|
+
body << qos
|
|
347
|
+
write_packet(SUBSCRIBE, body)
|
|
348
|
+
|
|
349
|
+
payload = wait_for_acknowledgement(SUBACK, packet_id, "SUBACK")
|
|
350
|
+
raise MqttError, "malformed SUBACK: no return code for #{topic_filter.inspect}" if payload.bytesize < 3
|
|
351
|
+
|
|
352
|
+
granted = payload.getbyte(2)
|
|
353
|
+
if granted == SUBSCRIPTION_REFUSED
|
|
354
|
+
raise MqttError,
|
|
355
|
+
"broker refused the subscription to #{topic_filter.inspect} " \
|
|
356
|
+
"(SUBACK return code 0x80) — check the topic filter and the broker ACLs"
|
|
357
|
+
end
|
|
358
|
+
granted
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Read the next application message. Returns a Tina4::MqttMessage.
|
|
362
|
+
#
|
|
363
|
+
# ack: true (the default) acknowledges a QoS 1 delivery immediately, which
|
|
364
|
+
# is right for a synchronous read. Pass ack: false when the message must be
|
|
365
|
+
# stored before the broker is allowed to forget it — an unacknowledged QoS 1
|
|
366
|
+
# message is redelivered with DUP set. `consume` does exactly that.
|
|
367
|
+
def receive(timeout: nil, ack: true)
|
|
368
|
+
message = @inbox.shift || read_publish(deadline_in(timeout || @read_timeout))
|
|
369
|
+
message.acknowledge if ack
|
|
370
|
+
message
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Long-running consumer, mirroring Queue#consume.
|
|
374
|
+
#
|
|
375
|
+
# mqtt.consume("fleet/+/telemetry", qos: 1) { |message| store(message) }
|
|
376
|
+
# mqtt.consume.each { |message| store(message) } # already subscribed
|
|
377
|
+
#
|
|
378
|
+
# The message is acknowledged AFTER the block returns, so a handler that
|
|
379
|
+
# raises leaves the message unacknowledged and the broker redelivers it
|
|
380
|
+
# (with DUP set) — at-least-once, which is the point of QoS 1. The raise
|
|
381
|
+
# propagates: a consumer that swallows storage failures loses data quietly.
|
|
382
|
+
#
|
|
383
|
+
# iterations: > 0 stops after that many messages (bounded runs and tests).
|
|
384
|
+
def consume(topic_filter = nil, qos: 1, iterations: 0, timeout: nil, &block)
|
|
385
|
+
subscribe(topic_filter, qos: qos) if topic_filter
|
|
386
|
+
unless block
|
|
387
|
+
return Enumerator.new do |yielder|
|
|
388
|
+
consume_loop(iterations, timeout) { |message| yielder << message }
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
consume_loop(iterations, timeout, &block)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# PUBACK a QoS 1 delivery. Called by MqttMessage#acknowledge.
|
|
396
|
+
def acknowledge(packet_id)
|
|
397
|
+
write_packet(PUBACK, [packet_id].pack("n"))
|
|
398
|
+
true
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# PINGREQ and wait for the PINGRESP. Use this when nothing else is reading
|
|
402
|
+
# the socket; under a `consume` loop use start_keepalive instead, because
|
|
403
|
+
# the reader there absorbs the PINGRESP.
|
|
404
|
+
def ping(timeout: nil)
|
|
405
|
+
send_keepalive
|
|
406
|
+
deadline = deadline_in(timeout || @timeout)
|
|
407
|
+
loop do
|
|
408
|
+
header, payload = read_packet(deadline)
|
|
409
|
+
return true if header == PINGRESP
|
|
410
|
+
next if stash_publish(header, payload)
|
|
411
|
+
|
|
412
|
+
raise MqttError, format("expected PINGRESP, got %#04x", header)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Write a PINGREQ without waiting for the answer. The PINGRESP is absorbed
|
|
417
|
+
# by whatever is reading the socket (`receive` skips it), which is what
|
|
418
|
+
# makes the background keepalive safe alongside a consume loop.
|
|
419
|
+
def send_keepalive
|
|
420
|
+
write_packet(PINGREQ, "")
|
|
421
|
+
true
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Opt in to the cooperative keepalive. Registers a Tina4::Background task —
|
|
425
|
+
# the same mechanism the queue consumers use — that sends a PINGREQ only
|
|
426
|
+
# when the connection has actually gone quiet, so an actively publishing
|
|
427
|
+
# client costs no extra packets. Without a keepalive the broker drops a
|
|
428
|
+
# silent client past 1.5x keepalive (which is exactly what fires the Last
|
|
429
|
+
# Will).
|
|
430
|
+
def start_keepalive(interval: nil)
|
|
431
|
+
return @keepalive_task if @keepalive_task
|
|
432
|
+
raise MqttError, "keepalive is disabled (keepalive: 0) — nothing to schedule" unless @keepalive.positive?
|
|
433
|
+
|
|
434
|
+
seconds = interval || [@keepalive / 2.0, 1.0].max
|
|
435
|
+
@keepalive_task = Tina4::Background.register(interval: seconds) do
|
|
436
|
+
send_keepalive if connected? && idle_for?(seconds)
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def stop_keepalive
|
|
441
|
+
return false unless @keepalive_task
|
|
442
|
+
|
|
443
|
+
Tina4::Background.stop_task(@keepalive_task)
|
|
444
|
+
@keepalive_task = nil
|
|
445
|
+
true
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Say goodbye properly: DISCONNECT then close. The broker discards the Last
|
|
449
|
+
# Will on a graceful disconnect, and a clean_session: false session survives
|
|
450
|
+
# for the next connect with the same client_id.
|
|
451
|
+
def disconnect
|
|
452
|
+
stop_keepalive
|
|
453
|
+
begin
|
|
454
|
+
write_packet(DISCONNECT, "") if connected?
|
|
455
|
+
rescue MqttError, SystemCallError, IOError
|
|
456
|
+
# Already gone — closing is still the right outcome.
|
|
457
|
+
ensure
|
|
458
|
+
close_socket
|
|
459
|
+
end
|
|
460
|
+
true
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Drop the socket WITHOUT a DISCONNECT — what a crashed or unplugged device
|
|
464
|
+
# looks like to the broker, and therefore what fires the Last Will.
|
|
465
|
+
def kill
|
|
466
|
+
stop_keepalive
|
|
467
|
+
close_socket
|
|
468
|
+
true
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
private
|
|
472
|
+
|
|
473
|
+
# The one delivery loop, shared by the block and Enumerator forms so the
|
|
474
|
+
# acknowledge-after-processing contract cannot drift between them.
|
|
475
|
+
def consume_loop(iterations, timeout)
|
|
476
|
+
consumed = 0
|
|
477
|
+
loop do
|
|
478
|
+
message = receive(timeout: timeout, ack: false)
|
|
479
|
+
yield message
|
|
480
|
+
message.acknowledge
|
|
481
|
+
consumed += 1
|
|
482
|
+
break if iterations.positive? && consumed >= iterations
|
|
483
|
+
end
|
|
484
|
+
consumed
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def connect_flags
|
|
488
|
+
flags = @clean_session ? 0x02 : 0x00
|
|
489
|
+
if @will_topic
|
|
490
|
+
# Will flag 0x04, will QoS at bits 3-4, will retain 0x20.
|
|
491
|
+
flags |= 0x04 | (@will_qos << 3)
|
|
492
|
+
flags |= 0x20 if @will_retain
|
|
493
|
+
end
|
|
494
|
+
flags |= 0x80 if @username
|
|
495
|
+
flags |= 0x40 if @password
|
|
496
|
+
flags
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Wrap the TCP socket in TLS. `openssl` is Ruby stdlib, so mqtts:// costs no
|
|
500
|
+
# dependency — and it is required HERE rather than at the top of the file so a
|
|
501
|
+
# plain mqtt:// app never loads it.
|
|
502
|
+
def wrap_in_tls(raw_socket)
|
|
503
|
+
require "openssl"
|
|
504
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
505
|
+
|
|
506
|
+
if @tls_verify
|
|
507
|
+
# Build our OWN trust store. Deliberately NOT context.ca_file= and NOT
|
|
508
|
+
# SSLContext#set_params: set_params installs the process-wide
|
|
509
|
+
# DEFAULT_CERT_STORE, and a later ca_file= writes our CA INTO that shared
|
|
510
|
+
# store, so every subsequent client in the process would trust it too.
|
|
511
|
+
# Verified 2026-07-23: with that ordering a second client accepted a
|
|
512
|
+
# self-signed broker certificate with no CA configured at all.
|
|
513
|
+
store = OpenSSL::X509::Store.new
|
|
514
|
+
store.set_default_paths # the system CAs, for a publicly-signed broker
|
|
515
|
+
if @ca_file
|
|
516
|
+
unless File.file?(@ca_file)
|
|
517
|
+
raise MqttError,
|
|
518
|
+
"MQTT CA file not found: #{@ca_file} — TINA4_MQTT_CA_FILE (or ca_file:) " \
|
|
519
|
+
"must point at the broker's CA certificate in PEM form"
|
|
520
|
+
end
|
|
521
|
+
store.add_file(@ca_file)
|
|
522
|
+
end
|
|
523
|
+
context.cert_store = store
|
|
524
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
525
|
+
else
|
|
526
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
527
|
+
Tina4::Log.warning(
|
|
528
|
+
"MQTT TLS certificate verification is DISABLED for mqtts://#{@host}:#{@port} — " \
|
|
529
|
+
"the connection is encrypted but the broker's identity is NOT verified, so a " \
|
|
530
|
+
"man in the middle can read and rewrite this traffic. Set TINA4_MQTT_CA_FILE " \
|
|
531
|
+
"(or ca_file:) to the broker's CA and drop TINA4_MQTT_TLS_VERIFY=false."
|
|
532
|
+
)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(raw_socket, context)
|
|
536
|
+
# SNI, but only for a real hostname: an IP literal is not a valid SNI name.
|
|
537
|
+
ssl_socket.hostname = @host unless ip_literal?(@host)
|
|
538
|
+
ssl_socket.sync_close = true
|
|
539
|
+
ssl_socket.sync = true
|
|
540
|
+
ssl_socket.connect
|
|
541
|
+
# Chain verification does not check WHO the certificate is for. Ruby's
|
|
542
|
+
# post_connection_check handles both DNS and IP SANs.
|
|
543
|
+
ssl_socket.post_connection_check(@host) if @tls_verify
|
|
544
|
+
ssl_socket
|
|
545
|
+
rescue OpenSSL::SSL::SSLError, OpenSSL::X509::StoreError => e
|
|
546
|
+
raw_socket.close
|
|
547
|
+
raise MqttError, "MQTT TLS handshake with #{@host}:#{@port} failed: #{e.message}"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def ip_literal?(host)
|
|
551
|
+
host.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/) || host.include?(":")
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def presence(value)
|
|
555
|
+
self.class.presence(value)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def refuse_unsupported_qos(qos)
|
|
559
|
+
raise ArgumentError, QOS2_REFUSED_MESSAGE if qos == 2
|
|
560
|
+
raise ArgumentError, "qos must be 0 or 1 (got #{qos.inspect})" unless [0, 1].include?(qos)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Every MQTT string is a 2-byte big-endian length followed by UTF-8 bytes.
|
|
564
|
+
def mqtt_string(value)
|
|
565
|
+
bytes = binary(value.to_s)
|
|
566
|
+
raise ArgumentError, "MQTT string is longer than 65535 bytes" if bytes.bytesize > 0xFFFF
|
|
567
|
+
|
|
568
|
+
[bytes.bytesize].pack("n") << bytes
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def payload_bytes(payload)
|
|
572
|
+
return "".b if payload.nil?
|
|
573
|
+
|
|
574
|
+
binary(payload.to_s)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# The bytes of a String, without copying one that is already binary — a
|
|
578
|
+
# telemetry payload should not be duplicated on its way to the socket.
|
|
579
|
+
def binary(value)
|
|
580
|
+
value.encoding == Encoding::BINARY ? value : value.b
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Packet identifiers are 1..65535; 0 is invalid.
|
|
584
|
+
def next_packet_id
|
|
585
|
+
@write_mutex.synchronize { @packet_id = (@packet_id % 0xFFFF) + 1 }
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# One buffer per packet. The fixed header (control byte + Remaining Length
|
|
589
|
+
# varint, 5 bytes at most) is written together with the body in a SINGLE
|
|
590
|
+
# write — IO#write with several arguments issues one writev, so the payload
|
|
591
|
+
# is never copied into a second buffer and a small telemetry frame still
|
|
592
|
+
# leaves as one segment.
|
|
593
|
+
def write_packet(header, body)
|
|
594
|
+
raise MqttError, "not connected to an MQTT broker" unless connected?
|
|
595
|
+
|
|
596
|
+
fixed_header = String.new(capacity: 5, encoding: Encoding::BINARY)
|
|
597
|
+
fixed_header << header << self.class.encode_remaining_length(body.bytesize)
|
|
598
|
+
@write_mutex.synchronize do
|
|
599
|
+
@socket.write(fixed_header, body)
|
|
600
|
+
@last_write_at = monotonic
|
|
601
|
+
end
|
|
602
|
+
true
|
|
603
|
+
rescue SystemCallError, IOError => e
|
|
604
|
+
raise MqttError, "MQTT write failed: #{e.message}"
|
|
605
|
+
rescue StandardError => e
|
|
606
|
+
raise MqttError, "MQTT TLS write failed: #{e.message}" if tls_error?(e)
|
|
607
|
+
|
|
608
|
+
raise
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# An OpenSSL error must not leak out of the client as a raw OpenSSL
|
|
612
|
+
# exception. Guarded with defined? because `openssl` is only required when a
|
|
613
|
+
# mqtts:// connection is actually made.
|
|
614
|
+
def tls_error?(error)
|
|
615
|
+
defined?(OpenSSL::SSL::SSLError) && error.is_a?(OpenSSL::SSL::SSLError)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Returns [control_byte, body]. The fixed header is read in exactly 1 + N
|
|
619
|
+
# bytes (N <= 4 for the varint) so the next packet's header is never
|
|
620
|
+
# consumed by a speculative over-read.
|
|
621
|
+
def read_packet(deadline)
|
|
622
|
+
header = read_exact(1, deadline).getbyte(0)
|
|
623
|
+
multiplier = 1
|
|
624
|
+
length = 0
|
|
625
|
+
loop do
|
|
626
|
+
byte = read_exact(1, deadline).getbyte(0)
|
|
627
|
+
length += (byte & 0x7F) * multiplier
|
|
628
|
+
break if (byte & 0x80).zero?
|
|
629
|
+
|
|
630
|
+
multiplier <<= 7
|
|
631
|
+
raise MqttError, "malformed Remaining Length (more than 4 varint bytes)" if multiplier > 0x200000
|
|
632
|
+
end
|
|
633
|
+
[header, length.zero? ? "".b : read_exact(length, deadline)]
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# TCP is a stream: a single read returns SHORT. Loop, or the parse corrupts
|
|
637
|
+
# under load.
|
|
638
|
+
#
|
|
639
|
+
# Reads go through ONE buffer, filled a whole READ_CHUNK at a time. That is
|
|
640
|
+
# both fewer syscalls (a small packet costs one read rather than one per
|
|
641
|
+
# fixed-header byte) and what makes TLS correct: asking for a full chunk
|
|
642
|
+
# drains Ruby's OpenSSL::Buffering @rbuf and OpenSSL's SSL_pending, so a
|
|
643
|
+
# later IO.select can never block on an empty TCP socket while decrypted
|
|
644
|
+
# plaintext is already sitting in a buffer.
|
|
645
|
+
def read_exact(count, deadline)
|
|
646
|
+
raise MqttError, "not connected to an MQTT broker" unless connected?
|
|
647
|
+
|
|
648
|
+
fill_read_buffer(deadline) while buffered_bytes < count
|
|
649
|
+
take_buffered(count)
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def buffered_bytes
|
|
653
|
+
@read_buffer.bytesize - @read_cursor
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def take_buffered(count)
|
|
657
|
+
chunk = @read_buffer.byteslice(@read_cursor, count)
|
|
658
|
+
@read_cursor += count
|
|
659
|
+
# Fully consumed (the common case, once per packet): reset rather than grow.
|
|
660
|
+
if @read_cursor >= @read_buffer.bytesize
|
|
661
|
+
@read_buffer.clear
|
|
662
|
+
@read_cursor = 0
|
|
663
|
+
end
|
|
664
|
+
chunk
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def fill_read_buffer(deadline)
|
|
668
|
+
wait_readable(deadline)
|
|
669
|
+
data =
|
|
670
|
+
begin
|
|
671
|
+
@socket.readpartial(READ_CHUNK)
|
|
672
|
+
rescue EOFError
|
|
673
|
+
raise MqttError, "broker closed the connection"
|
|
674
|
+
rescue SystemCallError, IOError => e
|
|
675
|
+
raise MqttError, "MQTT read failed: #{e.message}"
|
|
676
|
+
rescue StandardError => e
|
|
677
|
+
raise MqttError, "MQTT TLS read failed: #{e.message}" if tls_error?(e)
|
|
678
|
+
|
|
679
|
+
raise
|
|
680
|
+
end
|
|
681
|
+
if @read_buffer.empty?
|
|
682
|
+
@read_buffer.replace(data)
|
|
683
|
+
else
|
|
684
|
+
@read_buffer << data
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def wait_readable(deadline)
|
|
689
|
+
# Bytes already decrypted inside OpenSSL are readable NOW; the underlying
|
|
690
|
+
# socket may have nothing, so selecting on it first would hang.
|
|
691
|
+
return if @socket.respond_to?(:pending) && @socket.pending.to_i.positive?
|
|
692
|
+
|
|
693
|
+
remaining = nil
|
|
694
|
+
if deadline
|
|
695
|
+
remaining = deadline - monotonic
|
|
696
|
+
raise MqttTimeoutError, "timed out waiting for the MQTT broker" if remaining <= 0
|
|
697
|
+
end
|
|
698
|
+
# An SSLSocket is not an IO; select on the socket underneath it.
|
|
699
|
+
io = @socket.respond_to?(:to_io) ? @socket.to_io : @socket
|
|
700
|
+
return if IO.select([io], nil, nil, remaining)
|
|
701
|
+
|
|
702
|
+
raise MqttTimeoutError, "timed out waiting for the MQTT broker"
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Read packets until the next PUBLISH, skipping the PINGRESPs the keepalive
|
|
706
|
+
# generates.
|
|
707
|
+
def read_publish(deadline)
|
|
708
|
+
loop do
|
|
709
|
+
header, payload = read_packet(deadline)
|
|
710
|
+
next if header == PINGRESP
|
|
711
|
+
|
|
712
|
+
message = parse_publish(header, payload)
|
|
713
|
+
return message if message
|
|
714
|
+
|
|
715
|
+
raise MqttError, format("expected PUBLISH, got %#04x", header)
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# A PUBLISH that arrives while we are waiting for a PUBACK/SUBACK/PINGRESP
|
|
720
|
+
# (normal when the same connection both publishes and subscribes) is parked
|
|
721
|
+
# in the inbox so `receive` still delivers it, in order, instead of it being
|
|
722
|
+
# mistaken for the acknowledgement.
|
|
723
|
+
def stash_publish(header, payload)
|
|
724
|
+
message = parse_publish(header, payload)
|
|
725
|
+
return false unless message
|
|
726
|
+
|
|
727
|
+
@inbox << message
|
|
728
|
+
true
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def parse_publish(header, payload)
|
|
732
|
+
return nil unless (header & 0xF0) == PUBLISH
|
|
733
|
+
|
|
734
|
+
qos = (header & 0x06) >> 1
|
|
735
|
+
topic_length = payload.unpack1("n")
|
|
736
|
+
offset = 2 + topic_length
|
|
737
|
+
topic = payload.byteslice(2, topic_length).force_encoding(Encoding::UTF_8)
|
|
738
|
+
packet_id = nil
|
|
739
|
+
# The packet identifier is present ONLY when QoS > 0.
|
|
740
|
+
if qos.positive?
|
|
741
|
+
packet_id = payload.byteslice(offset, 2).unpack1("n")
|
|
742
|
+
offset += 2
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
MqttMessage.new(
|
|
746
|
+
topic: topic,
|
|
747
|
+
payload: payload.byteslice(offset, payload.bytesize - offset) || "".b,
|
|
748
|
+
qos: qos,
|
|
749
|
+
retained: (header & 0x01) == 0x01,
|
|
750
|
+
duplicate: (header & 0x08) == 0x08,
|
|
751
|
+
packet_id: packet_id,
|
|
752
|
+
client: self
|
|
753
|
+
)
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Wait for a specific acknowledgement, tolerating interleaved PUBLISH and
|
|
757
|
+
# PINGRESP packets. A mismatched packet identifier is silent data loss if
|
|
758
|
+
# ignored, so it raises.
|
|
759
|
+
def wait_for_acknowledgement(expected_header, packet_id, name)
|
|
760
|
+
deadline = deadline_in(@timeout)
|
|
761
|
+
loop do
|
|
762
|
+
header, payload = read_packet(deadline)
|
|
763
|
+
next if header == PINGRESP
|
|
764
|
+
next if stash_publish(header, payload)
|
|
765
|
+
|
|
766
|
+
unless header == expected_header
|
|
767
|
+
raise MqttError, format("expected %s, got %#04x", name, header)
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
received_id = payload.unpack1("n")
|
|
771
|
+
unless received_id == packet_id
|
|
772
|
+
raise MqttError,
|
|
773
|
+
"#{name} packet identifier mismatch: broker acknowledged " \
|
|
774
|
+
"#{received_id} but we sent #{packet_id}"
|
|
775
|
+
end
|
|
776
|
+
return payload
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def idle_for?(seconds)
|
|
781
|
+
(monotonic - @last_write_at) >= seconds
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def deadline_in(seconds)
|
|
785
|
+
seconds ? monotonic + seconds : nil
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def monotonic
|
|
789
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def close_socket
|
|
793
|
+
@socket.close if @socket && !@socket.closed?
|
|
794
|
+
rescue IOError, SystemCallError
|
|
795
|
+
nil
|
|
796
|
+
ensure
|
|
797
|
+
@socket = nil
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
end
|