Native MQTT 3.1.1/5 client with WS/WSS and bounded recoverable QoS 1 delivery
moon add Strangelight-Merser/moon-mqtt-clientmoon update
moon check --target native
moon test --target native
moon build --target nativepython3 -m venv .venv
.venv/bin/pip install -r tests/integration/requirements.txt
./scripts/moon.sh build --target native
.venv/bin/python examples/mqtt_demo/demo.py./scripts/moon.sh run examples/mqtt_demo/cli --target native -- \
publish --host 127.0.0.1 -t lab/state -m ON --qos 1 --retain --stats
./scripts/moon.sh run examples/mqtt_demo/cli --target native -- \
subscribe --host 127.0.0.1 -t 'lab/#' --count 1 --timeout-ms 3000let config = @mqtt.Config::new(
"broker.example.com", "native-wss-client", port=8084,
transport=@mqtt.WebSocket("/mqtt"), tls=@mqtt.SystemRoots,
)./scripts/moon.sh run examples/mqtt_demo/cli --target native -- \
publish --host broker.example.com --port 8084 --ws-path /mqtt --tls \
-t lab/state -m ON --qos 1async fn main {
let config = @mqtt.Config::new("127.0.0.1", "my-moon-client")
@mqtt.with_client(config, async fn(client) {
let results = client.subscribe([
{ topic: "lab/temperature", qos: @mqtt.AtLeastOnce },
])
if results == [@mqtt.Granted(@mqtt.AtLeastOnce)] {
client.publish("lab/status", @utf8.encode("ready"),
qos=@mqtt.AtLeastOnce, retain=true)
}
// 在这里处理 Connected、Disconnected 和 MessageReceived 事件。
// 长期运行的订阅者必须持续调用 next_event() 消费事件。
})
}let config = @mqtt.Config::new(
"127.0.0.1", "exclusive-stable-client-id",
session_policy=@mqtt.ResumeSession,
)
@mqtt.with_client(config, async fn(client) {
let delivery = client.submit_delivery(
"command-20260918-001", "lab/command", @utf8.encode("ON"),
)
let status = delivery.wait()
// Acknowledged means a matching broker PUBACK was received. Reconcile
// TerminalOutcomeUnknown with application state before issuing a new command.
match status.terminal_cause {
Some(@mqtt.BrokerSessionLost(_)) => () // establish a new logical session
Some(@mqtt.DeliveryAttemptsExhausted(_)) => () // reconcile before retry
_ => ()
}
})let outbox = @mqtt.DurableOutboxOptions::new("./commands.sqlite3")
let config = @mqtt.Config::new(
"127.0.0.1", "exclusive-stable-client-id",
session_policy=@mqtt.ResumeSession,
)
@mqtt.with_durable_client(outbox, config, async fn(client, recovered) {
let delivery = if recovered.is_empty() {
client.submit_durable_delivery(
"command-20260918-002", "lab/command", @utf8.encode("OFF"),
@env.now().reinterpret_as_int64() + 30000L,
)
} else {
recovered[0]
}
ignore(delivery.wait())
})| 结果 | 能确认什么 |
|---|---|
| QoS 0 发布正常返回 | 传输写入完成;没有 broker 确认。 |
| QoS 1 发布正常返回 | 本次连接收到匹配的 PUBACK;不能据此确认下游处理完成或设备执行了动作。 |
| NotSent | 请求在开始写入前失败。 |
| OutcomeUnknown | 写入已经开始,但操作结果未确认,包括部分写入和确认丢失。 |
| 请求返回 Backpressure | 发送队列或在途请求达到上限,无法接受本次请求。 |
| 事件队列或控制队列溢出 | 客户端报错并终止连接。 |
| 配置 | 默认值 |
|---|---|
| 业务发送队列 / 控制槽位 / 事件队列 | 64 / 16 / 128 |
| 待完成操作数 / 单个报文大小 | 32 / 65,536 字节 |
| 连接、操作、写入、PINGRESP 超时 | 各 5 秒 |
| keepalive | 30 秒 |
| 连续重连次数 | 最多 10 次 |
| 重连间隔 | 从 250 ms 增长至 5 秒,并加入每个客户端独立的随机抖动 |
let config = @mqtt.Config::new("127.0.0.1", "requester", protocol=@mqtt.Mqtt5)
@mqtt.with_client(config, async fn(client) {
let receipt = client.publish_detailed(
"service/request", b"status", qos=@mqtt.AtLeastOnce,
properties=@mqtt.PublishProperties::new(
message_expiry_secs=Some(30L),
response_topic=Some("service/reply"),
correlation_data=Some(b"request-1"),
user_properties=[("source", "moonbit")],
),
)
match receipt {
@mqtt.Accepted(reason) => println("PUBACK reason=\{reason.code}")
@mqtt.Written => println("written")
}
})PYTHON=.venv/bin/python ./tests/integration/run.sh
./scripts/check.sh# 独立模拟设备的四种状态同步场景
.venv/bin/python examples/mqtt_demo/demo.py
# 从 Mooncakes 安装到全新的临时模块,并完成 QoS 1 消息往返
.venv/bin/python tests/consumer_smoke.py --registry
# 固定版本的官方 EMQX 镜像:收发、拒绝订阅、重启恢复;需要 Docker
./tests/emqx.sh
# 30 分钟重启压力测试:1 KiB QoS 1、16 个并发任务、100 次断线恢复
# broker 默认仅记录警告和错误,日志大小有上限
MOONBIT_ASYNC_CHECK_FD_LEAK=1 .venv/bin/python tests/soak.py \
--duration 1800 --cycles 100 --artifacts tests/integration/artifacts/soakpub(all) suberror ClientError {
InvalidConfig(String)
ProtocolError(String)
NotConnected
Closed
Backpressure(String)
NotSent(String)
OutcomeUnknown(String)
ConnectionRefused(String)
BrokerRejected(BrokerReason)
ServerDisconnected(BrokerReason)
ReconnectExhausted(String)
DeliveryRejected(String)
BrokerSessionLost(String)
DeliveryAttemptsExhausted(String)
DurableStorage(DurableStoreError)
OperationTimeout(String)
WriteTimeout(String)
PingResponseTimeout(String)
TlsFailure(String)
} derive(Debug)pub(all) suberror DurableStoreError {
DurableStoreBusy(String)
DurableStoreFull(String)
DurableStoreReadOnly(String)
DurableStoreCorrupt(String)
DurableStoreIo(String)
DurableStoreSchema(String)
DurableStoreIdentity(String)
DurableStoreLimit(String)
DurableStoreState(String)
} derive(Debug)type Clientasync fn Client::publish(self : Client, topic : String, payload : Bytes, qos? : QoS, retain? : Bool, properties? : PublishProperties) -> Unitasync fn Client::publish_detailed(self : Client, topic : String, payload : Bytes, qos? : QoS, retain? : Bool, properties? : PublishProperties) -> PublishReceiptfn Client::submit_delivery(self : Client, delivery_id : String, topic : String, payload : Bytes, retain? : Bool, properties? : PublishProperties) -> DeliveryHandle raise ClientErrorasync fn Client::submit_durable_delivery(self : Client, delivery_id : String, topic : String, payload : Bytes, expires_at_ms : Int64, retain? : Bool, properties? : PublishProperties) -> DeliveryHandleasync fn Client::subscribe(self : Client, topics : Array[Subscription]) -> Array[SubscriptionResult]async fn Client::subscribe_detailed(self : Client, topics : Array[Subscription]) -> Array[SubscriptionAcknowledgement]pub(all) struct ClientIdentity {
certificate_chain_file : String
private_key_file : String
}pub(all) struct ClientStats {
generation : Int
connected : Bool
business_queue_depth : Int
control_queue_depth : Int
event_queue_depth : Int
pending_requests : Int
reconnects : Int
disconnects : Int
unknown_outcomes : Int
last_disconnect : String?
}pub(all) struct Config {
host : String
port : Int
client_id : String
protocol : ProtocolVersion
session_policy : SessionPolicy
session_expiry_secs : Int64
transport : NetworkTransport
tls : TlsMode
client_identity : ClientIdentity?
username : String?
password : Bytes?
will : Will?
keep_alive_secs : Int
connect_timeout_ms : Int
operation_timeout_ms : Int
write_timeout_ms : Int
ping_response_timeout_ms : Int
send_capacity : Int
receive_capacity : Int
max_inflight : Int
max_packet_size : Int
reconnect_delay_ms : Int
max_reconnect_delay_ms : Int
reconnect_attempts : Int
control_capacity : Int
}fn Config::new(host : String, client_id : String, protocol? : ProtocolVersion, port? : Int, session_policy? : SessionPolicy, session_expiry_secs? : Int64, transport? : NetworkTransport, tls? : TlsMode, client_identity? : ClientIdentity?, username? : String?, password? : Bytes?, will? : Will?, keep_alive_secs? : Int, connect_timeout_ms? : Int, operation_timeout_ms? : Int, write_timeout_ms? : Int, ping_response_timeout_ms? : Int, send_capacity? : Int, receive_capacity? : Int, max_inflight? : Int, max_packet_size? : Int, reconnect_delay_ms? : Int, max_reconnect_delay_ms? : Int, reconnect_attempts? : Int, control_capacity? : Int) -> Configpub struct DeliveryHandle {
// private fields
}pub(all) enum DeliveryPhase {
Queued
AwaitingAck
AwaitingReconnect(String)
Acknowledged
Rejected(BrokerReason)
TerminalNotSent(String)
TerminalOutcomeUnknown(String)
} derive(Eq, Debug)pub(all) struct DeliveryStatus {
delivery_id : String
phase : DeliveryPhase
terminal_cause : ClientError?
broker_reason : BrokerReason?
packet_id : Int
attempts : Int
generation : Int?
} derive(Debug)pub(all) struct DurableDeliveryInfo {
delivery_id : String
packet_id : Int
sequence : Int64
topic : String
payload : Bytes
properties : PublishProperties
message_expiry_at_ms : Int64?
retain : Bool
expires_at_ms : Int64
attempts : Int
ever_started : Bool
state : DurableDeliveryState
} derive(Debug)pub(all) struct DurableOutboxOptions {
path : String
max_records : Int
max_payload_bytes : Int64
max_pages : Int
}fn DurableOutboxOptions::new(path : String, max_records? : Int, max_payload_bytes? : Int64, max_pages? : Int) -> DurableOutboxOptionspub(all) struct Message {
topic : String
payload : Bytes
qos : QoS
retain : Bool
dup : Bool
generation : Int
properties : PublishProperties
} derive(Debug)fn PublishProperties::new(message_expiry_secs? : Int64?, response_topic? : String?, correlation_data? : Bytes?, user_properties? : Array[(String, String)]) -> PublishPropertiespub(all) struct SubscriptionAcknowledgement {
result : SubscriptionResult
reason : BrokerReason
} derive(Eq, Debug)async fn discard_durable_delivery(options : DurableOutboxOptions, config : Config, delivery_id : String) -> Unitasync fn inspect_durable_outbox(options : DurableOutboxOptions, config : Config) -> Array[DurableDeliveryInfo]async fn[T] with_durable_client(options : DurableOutboxOptions, config : Config, action : async (Client, Array[DeliveryHandle]) -> T) -> TInstall
Download zipNative MQTT 3.1.1/5 client with WS/WSS and bounded recoverable QoS 1 delivery