zzq 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +13 -0
- data/README.md +153 -0
- data/lib/zzq/broker.rb +34 -0
- data/lib/zzq/client.rb +51 -0
- data/lib/zzq/engine/connection_lifecycle.rb +139 -0
- data/lib/zzq/engine/keepalive.rb +75 -0
- data/lib/zzq/engine/socket_lifecycle.rb +79 -0
- data/lib/zzq/engine.rb +163 -0
- data/lib/zzq/error.rb +42 -0
- data/lib/zzq/message.rb +113 -0
- data/lib/zzq/monitor_event.rb +14 -0
- data/lib/zzq/options.rb +94 -0
- data/lib/zzq/persistence/interface.rb +43 -0
- data/lib/zzq/persistence/memory.rb +37 -0
- data/lib/zzq/persistence/pstore.rb +66 -0
- data/lib/zzq/routing/broker.rb +380 -0
- data/lib/zzq/routing/client.rb +327 -0
- data/lib/zzq/routing/packet_id_allocator.rb +54 -0
- data/lib/zzq/routing/qos_tracker.rb +108 -0
- data/lib/zzq/routing/retained_store.rb +67 -0
- data/lib/zzq/routing/topic_match.rb +49 -0
- data/lib/zzq/routing/topic_trie.rb +232 -0
- data/lib/zzq/socket.rb +146 -0
- data/lib/zzq/subscription.rb +70 -0
- data/lib/zzq/transport/ipc.rb +92 -0
- data/lib/zzq/transport/mqtt.rb +83 -0
- data/lib/zzq/transport/mqtts.rb +173 -0
- data/lib/zzq/version.rb +5 -0
- data/lib/zzq.rb +26 -0
- metadata +128 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4ecbbe3094e3570604ee7b7b1b69d45d4a088c4da2dffb9c04a79d8b07475ac8
|
|
4
|
+
data.tar.gz: 9daf51c7c1b2c7e30c33b306b5e4aad5440ef0af129f261951a13b74e30163af
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: be9e2fa7e9159f333122b40b8776dfc659527d3c37153f7b94bc88eb3732e4fd11d4093e5cfb4366b105032a97bf9cda76334d019126d537dbb767b490586bd5
|
|
7
|
+
data.tar.gz: c1d8ae054e97e5e9a5ec90c3a403933e59b5ae55e67b29a5bc65bd3df126719c990770e8f8563e29a76fe728ae14c27b0c2b7a6c6311a885ee2dc4d82a713b53
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.1.0 — 2026-04-20
|
|
4
|
+
|
|
5
|
+
- First release. Async-native MQTT 3.1.1 + 5.0 client and broker in
|
|
6
|
+
one gem, built on `async`, `io-stream`, and `protocol-mqtt`.
|
|
7
|
+
- Transports: `mqtt://` (plain TCP, port 1883), `mqtts://` (TLS,
|
|
8
|
+
port 8883), `ipc://` (Unix-domain sockets, filesystem and Linux
|
|
9
|
+
abstract namespace).
|
|
10
|
+
- `mqtts://` accepts any `OpenSSL::SSL::SSLContext` and drops the
|
|
11
|
+
connection at 128 GiB of application data so reconnects force
|
|
12
|
+
fresh keys before AEAD nonce counters approach their unsafe
|
|
13
|
+
range.
|
|
14
|
+
- Retained-message persistence: in-memory (default) or stdlib
|
|
15
|
+
`PStore` with `ultra_safe = true` for fsync-on-commit durability.
|
|
16
|
+
- Broker-to-broker bridging via `Client` + `Broker` composition;
|
|
17
|
+
MQTT v5 `no_local` loop guard supported.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2026, Patrik Wenger
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# ZZQ — async-native MQTT for Ruby
|
|
2
|
+
|
|
3
|
+
> Codename **Zanzara** — Italian for "mosquito", in the same spirit as
|
|
4
|
+
> the Mosquitto broker that popularised MQTT. `zzq` keeps the
|
|
5
|
+
> three-letter gem-name symmetry with its siblings `omq` and `nnq`.
|
|
6
|
+
|
|
7
|
+
[](https://github.com/paddor/zzq/actions/workflows/ci.yml)
|
|
8
|
+
[](https://rubygems.org/gems/zzq)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](https://www.ruby-lang.org)
|
|
11
|
+
|
|
12
|
+
ZZQ is a pure-Ruby MQTT 3.1.1 + 5.0 implementation — **client and
|
|
13
|
+
broker in one gem** — built on [async](https://github.com/socketry/async),
|
|
14
|
+
[io-stream](https://github.com/socketry/io-stream), and
|
|
15
|
+
[protocol-mqtt](https://github.com/paddor/protocol-mqtt). It applies
|
|
16
|
+
the [omq](https://github.com/paddor/omq) / [nnq](https://github.com/paddor/nnq)
|
|
17
|
+
architecture to MQTT.
|
|
18
|
+
|
|
19
|
+
Status: pre-alpha. See [the design plan](../../.claude/plans/omq-and-nnq-s-design-robust-dolphin.md) for scope and roadmap.
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "zzq"
|
|
25
|
+
require "async"
|
|
26
|
+
|
|
27
|
+
# Client
|
|
28
|
+
Async do
|
|
29
|
+
client = ZZQ::Client.new(client_id: "sensor-42", version: 5, keepalive: 30)
|
|
30
|
+
client.connect("mqtt://broker.example:1883") do |task|
|
|
31
|
+
sub = client.subscribe("cmd/#", qos: 1)
|
|
32
|
+
task.async do
|
|
33
|
+
sub.each { |msg| puts "#{msg.topic}: #{msg.payload}" }
|
|
34
|
+
end
|
|
35
|
+
client.publish("status/sensor-42", "online", qos: 1, retain: true)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Broker
|
|
40
|
+
Async do
|
|
41
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
42
|
+
ctx.cert = OpenSSL::X509::Certificate.new(File.read("server.crt"))
|
|
43
|
+
ctx.key = OpenSSL::PKey.read(File.read("server.key"))
|
|
44
|
+
# Recommended: TLS 1.3 with DJB-family AEAD.
|
|
45
|
+
ctx.min_version = OpenSSL::SSL::TLS1_3_VERSION
|
|
46
|
+
ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION
|
|
47
|
+
ctx.ciphersuites = "TLS_CHACHA20_POLY1305_SHA256"
|
|
48
|
+
|
|
49
|
+
broker = ZZQ::Broker.new
|
|
50
|
+
broker.bind("mqtt://0.0.0.0:1883")
|
|
51
|
+
broker.bind("mqtts://0.0.0.0:8883", tls_context: ctx)
|
|
52
|
+
# ... bind more transports on the same broker
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Transports
|
|
57
|
+
|
|
58
|
+
- `mqtt://` — plain MQTT over TCP (default port 1883).
|
|
59
|
+
- `mqtts://` — MQTT over TLS (default port 8883). Takes any
|
|
60
|
+
`OpenSSL::SSL::SSLContext` via `tls_context:`; the recommended
|
|
61
|
+
configuration is TLS 1.3 pinned to `TLS_CHACHA20_POLY1305_SHA256`
|
|
62
|
+
as shown above. The transport drops the connection at 128 GiB
|
|
63
|
+
of application data — well below ChaCha20-Poly1305's ~256 GiB
|
|
64
|
+
per-key integrity bound — so reconnects force fresh keys before
|
|
65
|
+
AEAD nonce counters approach their unsafe range.
|
|
66
|
+
- `ws://` / `wss://` — MQTT over WebSockets.
|
|
67
|
+
- `ipc:///path` — Unix-domain socket, filesystem path.
|
|
68
|
+
- `ipc://@abstract` — Linux abstract-namespace Unix socket.
|
|
69
|
+
|
|
70
|
+
## Persistence
|
|
71
|
+
|
|
72
|
+
By default the broker keeps retained messages in process memory — fast,
|
|
73
|
+
zero-config, and lost when the process exits. That's the right choice
|
|
74
|
+
for tests and ephemeral workloads.
|
|
75
|
+
|
|
76
|
+
For durability across restarts, pass a `PStore`-backed persistence
|
|
77
|
+
object. It uses stdlib [`PStore`](https://docs.ruby-lang.org/en/master/PStore.html)
|
|
78
|
+
with `ultra_safe = true` so every commit fsyncs the data file and its
|
|
79
|
+
parent directory — retained state survives power loss, not just clean
|
|
80
|
+
shutdowns.
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# Default: in-memory, ephemeral
|
|
84
|
+
broker = ZZQ::Broker.new
|
|
85
|
+
|
|
86
|
+
# Explicit in-memory (same as default)
|
|
87
|
+
broker = ZZQ::Broker.new persistence: ZZQ::Persistence::Memory.new
|
|
88
|
+
|
|
89
|
+
# Durable: retained messages persist to ./zzq-data/
|
|
90
|
+
persistence = ZZQ::Persistence::PStore.new data_dir: "./zzq-data"
|
|
91
|
+
broker = ZZQ::Broker.new persistence: persistence
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
PStore is single-process only — file locking doesn't coordinate across
|
|
95
|
+
processes. For HA deployments, a future external adapter (Redis,
|
|
96
|
+
SQLite) will ship as a separate gem.
|
|
97
|
+
|
|
98
|
+
## Bridging two brokers
|
|
99
|
+
|
|
100
|
+
ZZQ does not ship a `Bridge` class — broker-to-broker forwarding is a
|
|
101
|
+
short composition of a `Client` and a local `Broker`. Subscribe on one
|
|
102
|
+
side, publish to the other:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
require "zzq"
|
|
106
|
+
require "async"
|
|
107
|
+
|
|
108
|
+
Async do |task|
|
|
109
|
+
upstream = ZZQ::Client.new(client_id: "site-42-bridge", version: 5)
|
|
110
|
+
local = ZZQ::Broker.new
|
|
111
|
+
|
|
112
|
+
upstream.connect("mqtts://cloud.example:8883")
|
|
113
|
+
local.bind("mqtt://0.0.0.0:1883")
|
|
114
|
+
|
|
115
|
+
# local → upstream: forward telemetry out.
|
|
116
|
+
task.async do
|
|
117
|
+
local.subscribe("telemetry/#", qos: 1).each do |m|
|
|
118
|
+
upstream.publish(m.topic, m.payload, qos: m.qos, retain: m.retain)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# upstream → local: pull commands in. `Broker#<<` fans the Message
|
|
123
|
+
# straight into the local broker's delivery path — no need to pick
|
|
124
|
+
# apart and re-pack fields (alias of `#ingest`).
|
|
125
|
+
task.async do
|
|
126
|
+
upstream.subscribe("cmd/#", qos: 1, no_local: true).each do |m|
|
|
127
|
+
local << m
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Loop guard for two-way routes:
|
|
134
|
+
|
|
135
|
+
* MQTT v5: pass `no_local: true` on the subscribe call — the spec
|
|
136
|
+
forbids the broker from echoing back to the publisher of the
|
|
137
|
+
subscription, so messages originating on one side don't bounce.
|
|
138
|
+
* MQTT v3.1.1: no `no_local`. Use non-overlapping filters on each
|
|
139
|
+
direction, or tag topics with a side-specific prefix.
|
|
140
|
+
|
|
141
|
+
## When MQTT, when OMQ/NNQ
|
|
142
|
+
|
|
143
|
+
MQTT bridging makes sense only when the remote speaks MQTT and nothing
|
|
144
|
+
else (AWS IoT Core, HiveMQ Cloud, partner Mosquitto). For inter-service
|
|
145
|
+
comm where both ends are under your control, reach for
|
|
146
|
+
[omq](https://github.com/paddor/omq) or [nnq](https://github.com/paddor/nnq)
|
|
147
|
+
— cleaner wire format, no protocol overhead. ZZQ exposes
|
|
148
|
+
`ZZQ::Message#to_wire` / `.from_wire` so you can ship MQTT messages
|
|
149
|
+
across an OMQ/NNQ socket without re-encoding.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
ISC. See [LICENSE](LICENSE).
|
data/lib/zzq/broker.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "socket"
|
|
4
|
+
|
|
5
|
+
module ZZQ
|
|
6
|
+
# MQTT broker. Owns the session map (by client id), retained-message
|
|
7
|
+
# store, topic trie, and shared-subscription groups.
|
|
8
|
+
#
|
|
9
|
+
# M3 stub: constructor + {#build_routing}. CONNECT handling,
|
|
10
|
+
# subscription matching, and retained store come in M7.
|
|
11
|
+
class Broker < Socket
|
|
12
|
+
def build_routing(engine)
|
|
13
|
+
Routing::Broker.new(engine)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Number of currently-connected MQTT sessions.
|
|
18
|
+
def session_count = @engine.routing.session_count
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Inject a {ZZQ::Message} into the broker's delivery path as if it
|
|
22
|
+
# had arrived via a PUBLISH packet. Fans out to every matching
|
|
23
|
+
# subscriber. Use this to bridge non-MQTT inputs (NNQ, OMQ, a
|
|
24
|
+
# file) into broker topics.
|
|
25
|
+
def ingest(message)
|
|
26
|
+
raise NotInAsyncContext unless Async::Task.current?
|
|
27
|
+
@engine.routing.dispatch_message(message)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
alias_method :<<, :ingest
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
require_relative "routing/broker"
|
data/lib/zzq/client.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "socket"
|
|
4
|
+
|
|
5
|
+
module ZZQ
|
|
6
|
+
# MQTT client. Owns exactly one MQTT session at a time (possibly
|
|
7
|
+
# persisted across reconnects), subscriptions, and a QoS tracker for
|
|
8
|
+
# inflight PUB flows.
|
|
9
|
+
#
|
|
10
|
+
# M3 stub: only the constructor and {#build_routing} stand. The
|
|
11
|
+
# CONNECT/CONNACK handshake and publish/subscribe plumbing come in
|
|
12
|
+
# M4 / M5.
|
|
13
|
+
class Client < Socket
|
|
14
|
+
def build_routing(engine)
|
|
15
|
+
Routing::Client.new(engine)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Most recent CONNACK received from the broker, or nil before the
|
|
20
|
+
# first successful connect.
|
|
21
|
+
def last_connack = @engine.routing.last_connack
|
|
22
|
+
def session_present? = @engine.routing.session_present?
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Publish a message. For QoS 0 returns once the packet is
|
|
26
|
+
# written. For QoS 1 blocks until PUBACK; for QoS 2 blocks until
|
|
27
|
+
# PUBCOMP.
|
|
28
|
+
def publish(topic, payload, qos: 0, retain: false, properties: {})
|
|
29
|
+
raise NotInAsyncContext unless Async::Task.current?
|
|
30
|
+
@engine.routing.publish(
|
|
31
|
+
topic: topic, payload: coerce_binary(payload),
|
|
32
|
+
qos: qos, retain: retain, properties: properties,
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Subscribe to a topic filter. Returns a {ZZQ::Subscription} whose
|
|
38
|
+
# queue receives matching messages until it is closed.
|
|
39
|
+
def subscribe(filter, qos: 0, no_local: false, retain_as_published: false,
|
|
40
|
+
retain_handling: 0)
|
|
41
|
+
raise NotInAsyncContext unless Async::Task.current?
|
|
42
|
+
@engine.routing.subscribe(
|
|
43
|
+
filter, qos: qos, no_local: no_local,
|
|
44
|
+
retain_as_published: retain_as_published,
|
|
45
|
+
retain_handling: retain_handling,
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
require_relative "routing/client"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/barrier"
|
|
4
|
+
require "protocol/mqtt"
|
|
5
|
+
|
|
6
|
+
module ZZQ
|
|
7
|
+
class Engine
|
|
8
|
+
# Owns the full arc of one connection: new → handshaking → ready →
|
|
9
|
+
# closed. Centralises side-effect ordering so routing doesn't have
|
|
10
|
+
# to.
|
|
11
|
+
#
|
|
12
|
+
# The MQTT CONNECT/CONNACK exchange itself is NOT performed here —
|
|
13
|
+
# it's role-asymmetric (client writes CONNECT first, broker reads
|
|
14
|
+
# it first) and so lives in the role's routing layer, which calls
|
|
15
|
+
# +handshake_succeeded!+ once the exchange is done.
|
|
16
|
+
#
|
|
17
|
+
# new → handshaking → ready → closed
|
|
18
|
+
#
|
|
19
|
+
# #lost! / #close! are idempotent.
|
|
20
|
+
class ConnectionLifecycle
|
|
21
|
+
class InvalidTransition < RuntimeError; end
|
|
22
|
+
|
|
23
|
+
STATES = %i[new handshaking ready closed].freeze
|
|
24
|
+
|
|
25
|
+
TRANSITIONS = {
|
|
26
|
+
new: %i[handshaking ready closed].freeze,
|
|
27
|
+
handshaking: %i[ready closed].freeze,
|
|
28
|
+
ready: %i[closed].freeze,
|
|
29
|
+
closed: [].freeze,
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
attr_reader :conn # Protocol::MQTT::Connection
|
|
34
|
+
attr_reader :endpoint
|
|
35
|
+
attr_reader :state
|
|
36
|
+
attr_reader :barrier
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def initialize(engine, endpoint:)
|
|
40
|
+
@engine = engine
|
|
41
|
+
@endpoint = endpoint
|
|
42
|
+
@state = :new
|
|
43
|
+
@conn = nil
|
|
44
|
+
@barrier = Async::Barrier.new(parent: engine.barrier)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Wraps the accepted/connected IO in a Protocol::MQTT::Connection
|
|
49
|
+
# and returns it. The handshake itself (CONNECT/CONNACK exchange)
|
|
50
|
+
# is driven by the routing layer — this is "transport ready,
|
|
51
|
+
# MQTT handshake pending".
|
|
52
|
+
def begin_handshake!(io)
|
|
53
|
+
transition!(:handshaking)
|
|
54
|
+
mqtt = Protocol::MQTT::Connection.new(
|
|
55
|
+
io,
|
|
56
|
+
version: @engine.options.version,
|
|
57
|
+
max_packet_size: @engine.options.max_packet_size,
|
|
58
|
+
)
|
|
59
|
+
@conn = mqtt
|
|
60
|
+
mqtt
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Called by the routing layer after CONNECT/CONNACK succeeds.
|
|
65
|
+
def handshake_succeeded!
|
|
66
|
+
@engine.connections[@conn] = self
|
|
67
|
+
transition!(:ready)
|
|
68
|
+
begin
|
|
69
|
+
@engine.routing.connection_added(@conn) if @engine.routing.respond_to?(:connection_added)
|
|
70
|
+
rescue ConnectionRejected
|
|
71
|
+
@engine.emit_monitor_event(:connection_rejected, endpoint: @endpoint)
|
|
72
|
+
tear_down!(reconnect: false)
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
@engine.lifecycle.peer_connected.resolve(@conn) unless @engine.lifecycle.peer_connected.resolved?
|
|
76
|
+
@engine.emit_monitor_event(:connected, endpoint: @endpoint)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Called on handshake failure — e.g. refused CONNACK, RST mid-
|
|
81
|
+
# CONNECT. Tears down and requests a reconnect if eligible.
|
|
82
|
+
def handshake_failed!(error:)
|
|
83
|
+
@engine.emit_monitor_event(:handshake_failed, endpoint: @endpoint, detail: { error: error })
|
|
84
|
+
@conn&.close rescue nil
|
|
85
|
+
tear_down!(reconnect: true)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Unexpected loss of an established connection.
|
|
90
|
+
def lost!
|
|
91
|
+
tear_down!(reconnect: true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Deliberate close (engine shutdown, kicked by broker, …).
|
|
96
|
+
def close!
|
|
97
|
+
tear_down!(reconnect: false)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Starts a supervisor task that tears down when any pump exits.
|
|
102
|
+
def start_supervisor!
|
|
103
|
+
return if @barrier.empty?
|
|
104
|
+
@engine.barrier.async(transient: true, annotation: "zzq conn supervisor") do
|
|
105
|
+
@barrier.wait { |task| task.wait; break }
|
|
106
|
+
rescue Async::Stop, Async::Cancel
|
|
107
|
+
rescue *CONNECTION_LOST
|
|
108
|
+
ensure
|
|
109
|
+
lost!
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
private def tear_down!(reconnect: false)
|
|
115
|
+
return if @state == :closed
|
|
116
|
+
transition!(:closed)
|
|
117
|
+
if @conn
|
|
118
|
+
@engine.connections.delete(@conn)
|
|
119
|
+
@engine.routing.connection_removed(@conn) if @engine.routing.respond_to?(:connection_removed)
|
|
120
|
+
@conn.close rescue nil
|
|
121
|
+
@engine.emit_monitor_event(:disconnected, endpoint: @endpoint)
|
|
122
|
+
@engine.resolve_all_peers_gone_if_empty
|
|
123
|
+
end
|
|
124
|
+
@engine.maybe_reconnect(@endpoint) if reconnect
|
|
125
|
+
# Stop after cleanup: when tear_down! is called from a task on
|
|
126
|
+
# this barrier (e.g. the read-loop's ensure), stopping earlier
|
|
127
|
+
# would cancel the current fiber mid-cleanup.
|
|
128
|
+
@barrier.stop
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
private def transition!(new_state)
|
|
133
|
+
allowed = TRANSITIONS[@state]
|
|
134
|
+
raise InvalidTransition, "#{@state} → #{new_state}" unless allowed&.include?(new_state)
|
|
135
|
+
@state = new_state
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/clock"
|
|
5
|
+
require "protocol/mqtt"
|
|
6
|
+
|
|
7
|
+
module ZZQ
|
|
8
|
+
class Engine
|
|
9
|
+
# Keepalive driver for a single connection.
|
|
10
|
+
#
|
|
11
|
+
# +role+:
|
|
12
|
+
# * +:sender+ (client default) — sends PINGREQ if locally idle
|
|
13
|
+
# for keep_alive/2; closes if nothing received for 1.5 × keep_alive.
|
|
14
|
+
# * +:watcher+ (broker default) — does not send; just closes if
|
|
15
|
+
# nothing received for 1.5 × keep_alive. MQTT-3.1.2-22 is
|
|
16
|
+
# enforced by the broker.
|
|
17
|
+
#
|
|
18
|
+
# One fiber per connection, attached to the connection's barrier
|
|
19
|
+
# so it tears down with the connection.
|
|
20
|
+
class Keepalive
|
|
21
|
+
attr_accessor :last_sent_at, :last_received_at
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def initialize(conn, keep_alive:, on_timeout:, role: :sender)
|
|
25
|
+
@conn = conn
|
|
26
|
+
@keep_alive = keep_alive
|
|
27
|
+
@on_timeout = on_timeout
|
|
28
|
+
@role = role
|
|
29
|
+
now = Async::Clock.now
|
|
30
|
+
@last_sent_at = now
|
|
31
|
+
@last_received_at = now
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Call this every time a packet is written, to reset the send-idle
|
|
36
|
+
# timer.
|
|
37
|
+
def note_sent
|
|
38
|
+
@last_sent_at = Async::Clock.now
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Call this every time a packet is read, to reset the recv-idle
|
|
43
|
+
# timer.
|
|
44
|
+
def note_received
|
|
45
|
+
@last_received_at = Async::Clock.now
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run
|
|
50
|
+
return if @keep_alive.zero? # 0 disables keepalive
|
|
51
|
+
loop do
|
|
52
|
+
sleep(@keep_alive / 2.0)
|
|
53
|
+
now = Async::Clock.now
|
|
54
|
+
|
|
55
|
+
if (now - @last_received_at) > (@keep_alive * 1.5)
|
|
56
|
+
@on_timeout.call
|
|
57
|
+
break
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
next unless @role == :sender
|
|
61
|
+
|
|
62
|
+
if (now - @last_sent_at) >= (@keep_alive / 2.0)
|
|
63
|
+
begin
|
|
64
|
+
@conn.write_packet(Protocol::MQTT::Packet::Pingreq.new)
|
|
65
|
+
note_sent
|
|
66
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
67
|
+
break
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
rescue Async::Stop
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/barrier"
|
|
4
|
+
require "async/promise"
|
|
5
|
+
|
|
6
|
+
module ZZQ
|
|
7
|
+
class Engine
|
|
8
|
+
# Owns the socket-level state: `:new → :open → :closing → :closed`.
|
|
9
|
+
#
|
|
10
|
+
# Adapted from NNQ::Engine::SocketLifecycle, minus the shared-IO-
|
|
11
|
+
# thread fallback (ZZQ is pure-Async).
|
|
12
|
+
class SocketLifecycle
|
|
13
|
+
class InvalidTransition < RuntimeError; end
|
|
14
|
+
|
|
15
|
+
STATES = %i[new open closing closed].freeze
|
|
16
|
+
|
|
17
|
+
TRANSITIONS = {
|
|
18
|
+
new: %i[open closed].freeze,
|
|
19
|
+
open: %i[closing closed].freeze,
|
|
20
|
+
closing: %i[closed].freeze,
|
|
21
|
+
closed: [].freeze,
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
attr_reader :state
|
|
26
|
+
attr_reader :parent_task
|
|
27
|
+
attr_reader :peer_connected
|
|
28
|
+
attr_reader :all_peers_gone
|
|
29
|
+
attr_reader :barrier
|
|
30
|
+
attr_accessor :reconnect_enabled
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@state = :new
|
|
35
|
+
@parent_task = nil
|
|
36
|
+
@peer_connected = Async::Promise.new
|
|
37
|
+
@all_peers_gone = Async::Promise.new
|
|
38
|
+
@reconnect_enabled = true
|
|
39
|
+
@barrier = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def open? = @state == :open
|
|
44
|
+
def closing? = @state == :closing
|
|
45
|
+
def closed? = @state == :closed
|
|
46
|
+
def alive? = @state == :new || @state == :open
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Must be called from within an Async::Task. Raises
|
|
50
|
+
# NotInAsyncContext otherwise.
|
|
51
|
+
def capture_parent_task
|
|
52
|
+
return false if @parent_task
|
|
53
|
+
task = Async::Task.current? or raise NotInAsyncContext
|
|
54
|
+
@parent_task = task
|
|
55
|
+
@barrier = Async::Barrier.new(parent: @parent_task)
|
|
56
|
+
transition!(:open)
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def start_closing! = transition!(:closing)
|
|
62
|
+
def finish_closing! = transition!(:closed)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_all_peers_gone_if_empty(connections)
|
|
66
|
+
return unless @peer_connected.resolved? && connections.empty?
|
|
67
|
+
return if @all_peers_gone.resolved?
|
|
68
|
+
@all_peers_gone.resolve(true)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
private def transition!(new_state)
|
|
73
|
+
allowed = TRANSITIONS[@state]
|
|
74
|
+
raise InvalidTransition, "#{@state} → #{new_state}" unless allowed&.include?(new_state)
|
|
75
|
+
@state = new_state
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|