OTA(update/):
- 解压前校验 7z 条目清单:sevenz-rust 0.6.1 的 decompress_file 直接 dest.join(entry.name()),不校验 ".."、绝对路径与盘符,恶意归档可越界写文件;现在要求恰好一个条目且名字与约定二进制一致(fail-closed)
- 无 .sha256 侧车时拒绝安装,不再静默跳过校验而 UI 仍宣称已校验
- 安装前重算暂存文件哈希;同意标记从仅版本号改为绑定「版本 + 哈希」
- 跨卷 rename 失败回退「复制 + fsync + 删源」(暂存在 APPDATA、exe 在安装目录,不同卷时必失败且报错误导)
- .old 回滚备份保留 7 天:原实现新版本首启即删,新版本一旦崩溃用户无任何回退路径
- 新增安装互斥锁:两实例同时安装会互删暂存包、争抢同一个 .old
- platform_triple 用 std::env::consts::ARCH:写死 x86_64 让 ARM 客户端永远挑不到产物
- pick_assets 按命名约定精确后缀匹配:非 Windows 下 ends_with("") 恒真,.tar.gz/.deb 会被当成可执行文件下载并替换自身
引擎(mqtt/):
- 事件通道改有界并分级:高频事件(消息/日志)满即丢并计数、每 1000 条提示一次;状态/订阅结果/发布回执异步补投。原无界通道在到达速率超过消费速率时内存无上界(单条最长 128KB)
- 重连改指数退避(1s→30s 封顶)+ 0~500ms 抖动,替代固定 2 秒的惊群式重试
- 测试连接的建连(读证书,阻塞 IO)移入 spawn_blocking,与 connect 一致
正确性与性能:
- session_params_changed 补齐 4 个 v5 CONNECT 属性(会话过期/接收上限/最大报文/主题别名),漏掉会让"重连后生效"提示静默失效;测试改逐字段防漏式断言
- ConnectionConfig / AliyunPreset 手写脱敏 Debug:password 与 AccessKeySecret/DeviceSecret 输出 <redacted>,杜绝一次 {:?} 把凭据送进 stderr 与 Sentry
- MqttRecord.user_properties 改 Arc<[(String,String)]>、应用日志改 Arc<LogEntry>:消息面板每帧 300 条、日志面板每帧 3000 条的深拷贝降为引用计数
- 阿里云 DeviceName 空值校验,避免拼出非法 clientId 后服务端报错难懂
327 lines
16 KiB
Rust
327 lines
16 KiB
Rust
//! MQTT 3.1.1:MqttOptions/last-will 组装与事件循环泵。
|
||
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
use rumqttc::mqttbytes::v5::{
|
||
ConnectReturnCode as V5ReturnCode, Packet as V5Packet,
|
||
};
|
||
use rumqttc::Event as V5Event;
|
||
use rumqttc_v4 as rv4;
|
||
use tokio::sync::watch;
|
||
|
||
use crate::model::{
|
||
ConnectionConfig, ConnectionStatus, Direction, LogLevel, MqttRecord, TransportKind,
|
||
};// ─── 引擎事件 ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
use super::*;
|
||
|
||
pub(super) fn build_v4(
|
||
cfg: &ConnectionConfig,
|
||
) -> Result<(rv4::AsyncClient, rv4::EventLoop), String> {
|
||
use rv4::{Broker as V4Broker, MqttOptions as V4Options, Transport as V4Transport};
|
||
|
||
let custom_tls = resolve_tls_config(cfg)?;
|
||
let mut opts = match cfg.transport {
|
||
TransportKind::Tcp | TransportKind::Tls => {
|
||
V4Options::new(cfg.client_id.clone(), V4Broker::tcp(&cfg.host, cfg.port))
|
||
}
|
||
TransportKind::Ws => {
|
||
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||
V4Options::new(cfg.client_id.clone(), V4Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
|
||
}
|
||
TransportKind::Wss => {
|
||
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||
match &custom_tls {
|
||
Some(tls) => V4Options::websocket_with_tls_config(
|
||
cfg.client_id.clone(),
|
||
url,
|
||
tls.clone(),
|
||
)
|
||
.map_err(|e| format!("{e:?}"))?,
|
||
None => V4Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
|
||
.map_err(|e| format!("{e:?}"))?,
|
||
}
|
||
}
|
||
};
|
||
|
||
if cfg.transport == TransportKind::Tls {
|
||
match &custom_tls {
|
||
Some(tls) => {
|
||
opts.set_transport(V4Transport::tls_with_config(tls.clone()));
|
||
}
|
||
None => {
|
||
opts.set_transport(
|
||
V4Transport::try_tls_with_default_config()
|
||
.map_err(|e| format!("TLS 初始化失败: {e:?}"))?,
|
||
);
|
||
}
|
||
};
|
||
}
|
||
|
||
opts.set_keep_alive(cfg.keep_alive);
|
||
opts.set_clean_session(cfg.clean_start);
|
||
if !cfg.username.is_empty() {
|
||
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
|
||
}
|
||
if let Some(will) = &cfg.last_will
|
||
&& !will.topic.is_empty() {
|
||
// 3.1.1 的遗嘱没有 content_type / response_topic 属性,忽略
|
||
let lw = rv4::mqttbytes::v4::LastWill::new(
|
||
will.topic.clone(),
|
||
will.payload.as_bytes().to_vec(),
|
||
v4_qos(will.qos),
|
||
will.retain,
|
||
);
|
||
opts.set_last_will(lw);
|
||
}
|
||
|
||
let (client, mut eventloop) = rv4::AsyncClient::builder(opts).build();
|
||
// v4 的连接超时(含 CONNACK 等待)由 EventLoop 的 NetworkOptions 控制
|
||
let mut network_options = rv4::NetworkOptions::new();
|
||
network_options.set_connection_timeout(connection_timeout_secs(cfg));
|
||
eventloop.set_network_options(network_options);
|
||
Ok((client, eventloop))
|
||
}
|
||
|
||
pub(super) fn v4_qos(qos: u8) -> rv4::QoS {
|
||
match qos {
|
||
1 => rv4::QoS::AtLeastOnce,
|
||
2 => rv4::QoS::ExactlyOnce,
|
||
_ => rv4::QoS::AtMostOnce,
|
||
}
|
||
}
|
||
|
||
// ─── 测试连接握手 ────────────────────────────────────────────────────────────
|
||
|
||
pub(super) async fn test_handshake(cfg_in: &ConnectionConfig) -> Result<(), String> {
|
||
// 握手测试必须使用独立 client_id,否则会把使用相同 client_id 的现有连接
|
||
// 从 broker 顶下线(MQTT 同 client_id 单会话规则)。
|
||
let mut cfg = cfg_in.clone();
|
||
cfg.client_id = format!("mqttx_test_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||
let cfg = &cfg;
|
||
if cfg.protocol.is_v5() {
|
||
// 建 client 会读 CA/证书文件(阻塞 IO):与 connect 保持一致放 spawn_blocking,
|
||
// 避免占住 2 线程引擎 runtime 的 worker
|
||
let owned = cfg.clone();
|
||
let (client, mut eventloop) = tokio::task::spawn_blocking(move || build_v5(&owned))
|
||
.await
|
||
.map_err(|e| format!("内部任务失败: {e}"))??;
|
||
loop {
|
||
match eventloop.poll().await {
|
||
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
|
||
if ack.code == V5ReturnCode::Success {
|
||
// 测试完成,主动断开避免在 broker 侧残留会话。
|
||
// disconnect() 只是把请求入队,需再 poll 才会把
|
||
// DISCONNECT 真正写上网络。
|
||
let _ = client.disconnect().await;
|
||
for _ in 0..3 {
|
||
if eventloop.poll().await.is_err() {
|
||
break;
|
||
}
|
||
}
|
||
return Ok(());
|
||
}
|
||
return Err(format!("连接被拒绝: {:?}", ack.code));
|
||
}
|
||
Ok(V5Event::Incoming(V5Packet::Disconnect(d))) => {
|
||
return Err(format!("服务端断开: {:?}", d.reason_code));
|
||
}
|
||
Err(e) => return Err(format!("连接失败: {e}")),
|
||
_ => {}
|
||
}
|
||
}
|
||
} else {
|
||
// 同上:v4 分支同样把阻塞的建连放到阻塞线程池
|
||
let owned = cfg.clone();
|
||
let (client, mut eventloop) = tokio::task::spawn_blocking(move || build_v4(&owned))
|
||
.await
|
||
.map_err(|e| format!("内部任务失败: {e}"))??;
|
||
loop {
|
||
match eventloop.poll().await {
|
||
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
|
||
if ack.code == rv4::ConnectReturnCode::Success {
|
||
// 同 v5:disconnect() 只入队,需 poll 把报文冲出
|
||
let _ = client.disconnect().await;
|
||
for _ in 0..3 {
|
||
if eventloop.poll().await.is_err() {
|
||
break;
|
||
}
|
||
}
|
||
return Ok(());
|
||
}
|
||
return Err(format!("连接被拒绝: {:?}", ack.code));
|
||
}
|
||
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
|
||
return Err("服务端主动断开".into());
|
||
}
|
||
Err(e) => return Err(format!("连接失败: {e}")),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── 事件循环 ────────────────────────────────────────────────────────────────
|
||
|
||
pub(super) fn spawn_v4_loop(
|
||
engine: Arc<MqttEngine>,
|
||
id: String,
|
||
mut eventloop: rv4::EventLoop,
|
||
mut cancel_rx: watch::Receiver<bool>,
|
||
connected: Arc<AtomicBool>,
|
||
generation: u64,
|
||
auto_reconnect: bool,
|
||
max_reconnect_times: u32,
|
||
) {
|
||
#![allow(clippy::too_many_arguments)]
|
||
let runtime = engine.runtime.handle().clone();
|
||
runtime.spawn(async move {
|
||
let mut first_connect = true;
|
||
// 已执行的重连次数,用于 max_reconnect_times(0=无限)限流
|
||
let mut reconnect_attempts: u32 = 0;
|
||
loop {
|
||
tokio::select! {
|
||
event = eventloop.poll() => {
|
||
match event {
|
||
Ok(rv4::Event::Incoming(rv4::Packet::Publish(p))) => {
|
||
let (payload, raw_bytes, payload_truncated) =
|
||
crate::model::retained_payload(&p.payload);
|
||
let record = MqttRecord {
|
||
seq: engine.next_seq(),
|
||
connection_id: engine.intern(&id),
|
||
direction: Direction::Received,
|
||
topic: engine.intern(&String::from_utf8_lossy(&p.topic)),
|
||
payload,
|
||
qos: match p.qos {
|
||
rv4::QoS::AtMostOnce => 0,
|
||
rv4::QoS::AtLeastOnce => 1,
|
||
rv4::QoS::ExactlyOnce => 2,
|
||
},
|
||
retain: p.retain,
|
||
timestamp: now_ms(),
|
||
user_properties: Default::default(),
|
||
content_type: None,
|
||
response_topic: None,
|
||
correlation_data: None,
|
||
message_expiry_interval: None,
|
||
subscription_identifier: None,
|
||
payload_truncated,
|
||
raw_bytes,
|
||
};
|
||
engine.log(
|
||
&id, LogLevel::Info, "publish_received",
|
||
format!("收到消息: {} ({} 字节)", record.topic, p.payload.len()), None);
|
||
engine.emit(EngineEvent::Message(record));
|
||
}
|
||
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
|
||
connected.store(ack.code == rv4::ConnectReturnCode::Success, Ordering::SeqCst);
|
||
if ack.code == rv4::ConnectReturnCode::Success {
|
||
first_connect = false;
|
||
// 重连成功后清零计数:max_reconnect_times 限流的是
|
||
// 「连续失败次数」,不能把历史错误累计进来
|
||
reconnect_attempts = 0;
|
||
engine.log(&id, LogLevel::Info, "connack",
|
||
format!("连接成功 (session_present: {})", ack.session_present), None);
|
||
engine.emit(EngineEvent::Status {
|
||
connection_id: id.clone(),
|
||
status: ConnectionStatus::Connected,
|
||
error: None,
|
||
reason_code: Some(0),
|
||
session_present: Some(ack.session_present),
|
||
});
|
||
} else {
|
||
engine.log(&id, LogLevel::Error, "connack",
|
||
format!("连接被拒绝: {:?}", ack.code), None);
|
||
engine.emit(EngineEvent::Status {
|
||
connection_id: id.clone(),
|
||
status: ConnectionStatus::Error,
|
||
error: Some(format!("{:?}", ack.code)),
|
||
// 上报真实的 CONNACK 拒绝码,不再硬编码为 1
|
||
reason_code: Some(ack.code as u8),
|
||
session_present: Some(ack.session_present),
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
|
||
connected.store(false, Ordering::SeqCst);
|
||
engine.emit(EngineEvent::Status {
|
||
connection_id: id.clone(),
|
||
status: ConnectionStatus::Disconnected,
|
||
error: Some("服务端断开".into()),
|
||
reason_code: None,
|
||
session_present: None,
|
||
});
|
||
break;
|
||
}
|
||
Ok(rv4::Event::Incoming(rv4::Packet::SubAck(s))) => {
|
||
engine.log(&id, LogLevel::Info, "suback",
|
||
format!("订阅确认 (packet {})", s.pkid), None);
|
||
}
|
||
Ok(rv4::Event::Incoming(rv4::Packet::UnsubAck(a))) => {
|
||
engine.log(&id, LogLevel::Info, "unsuback",
|
||
format!("取消订阅确认 (packet {})", a.pkid), None);
|
||
}
|
||
Ok(rv4::Event::Incoming(rv4::Packet::PubAck(a))) => {
|
||
engine.log(&id, LogLevel::Debug, "puback",
|
||
format!("发布确认 (packet {})", a.pkid), None);
|
||
}
|
||
Ok(rv4::Event::Outgoing(rv4::Outgoing::Publish(pkid))) => {
|
||
engine.log(&id, LogLevel::Debug, "publish_outgoing",
|
||
format!("消息已发出 (packet {pkid})"), None);
|
||
}
|
||
Ok(_) => {}
|
||
Err(rv4::ConnectionError::RequestsDone) => {
|
||
connected.store(false, Ordering::SeqCst);
|
||
engine.emit(EngineEvent::Status {
|
||
connection_id: id.clone(),
|
||
status: ConnectionStatus::Disconnected,
|
||
error: None, reason_code: None, session_present: None,
|
||
});
|
||
break;
|
||
}
|
||
Err(e) => {
|
||
connected.store(false, Ordering::SeqCst);
|
||
engine.emit(EngineEvent::Status {
|
||
connection_id: id.clone(),
|
||
status: ConnectionStatus::Error,
|
||
error: Some(e.to_string()),
|
||
reason_code: None, session_present: None,
|
||
});
|
||
if !auto_reconnect || first_connect {
|
||
engine.log(&id, LogLevel::Error, "connection_error",
|
||
format!("连接错误: {e}"), None);
|
||
break;
|
||
}
|
||
// max_reconnect_times>0 时按次数停止重试;0 表示无限重连
|
||
if max_reconnect_times > 0 && reconnect_attempts >= max_reconnect_times {
|
||
engine.log(&id, LogLevel::Error, "reconnect_give_up",
|
||
format!("已重连 {reconnect_attempts} 次仍失败,达到最大重连次数 {max_reconnect_times},停止重连: {e}"), None);
|
||
break;
|
||
}
|
||
reconnect_attempts += 1;
|
||
let delay = reconnect_delay(reconnect_attempts);
|
||
engine.log(&id, LogLevel::Warn, "connection_error",
|
||
format!("连接错误,{:.1} 秒后重连(第 {reconnect_attempts} 次): {e}", delay.as_secs_f32()), None);
|
||
// 退避等待也必须可取消:否则 close() 的取消信号要等一整个
|
||
// 退避周期才可见,期间会对用户已关闭的连接继续重连。
|
||
tokio::select! {
|
||
_ = cancel_rx.changed() => break,
|
||
_ = tokio::time::sleep(delay) => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ = cancel_rx.changed() => {
|
||
engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// 仅当 map 中仍是本代句柄时才移除,避免误删重连后的新句柄
|
||
engine.remove_handle_if(&id, generation);
|
||
});
|
||
}
|