fix: MQTT 连接生命周期竞态与事件处理

连接句柄(mqtt.rs):
- ConnHandle 增加代数 generation,事件循环退出只在仍是自己这一代时
  才移除句柄——旧循环无条件按 id 删除会把重连后的新句柄删掉,
  导致连接显示在线却无法从 UI 断开、发布/订阅报连接不存在
- connect() 同步插入占位句柄(client=None),建连期间也可被取消;
  建连完成后仅当句柄仍是本代才升级为可用客户端
- 重连 2 秒退避改为可取消的 select:原先 close() 的取消信号在退避
  期间不可见,会对用户已关闭的连接继续真实重连
- 重连成功后清零计数(max_reconnect_times 限流的是连续失败次数);
  v4 CONNACK 拒绝上报真实返回码;test_handshake 断开后再 poll 冲出
  DISCONNECT 报文;建连的证书文件读取移入 spawn_blocking

消息记录与事件(model.rs / app.rs / connection_view.rs):
- 非文本负载保留原始字节(MqttRecord.raw_bytes,仅非法 UTF-8 时
  占用,纯文本零开销),详情 Hex/Base64 不再展示 lossy 失真字节
- EngineEvent::Published 补齐 response_topic/correlation_data/
  message_expiry;correlation_data 两侧统一「优先文本、回退 hex」
- 事件入口加连接存在性守卫:删除连接后排队的引擎事件不再复活
  状态/消息(内存泄漏)或为已删连接写孤儿订阅
- SUBACK 失败时移除乐观插入的订阅行;成功事件只刷新已有订阅,
  不再用默认字段复活 SUBACK 前被删除的订阅
- Connecting 期间侧栏电源按钮防抖;分组名恰为 all/ungrouped 时
  chip 元素 id 改前缀避免共享交互状态
- 「清空」按钮按消息总数而非过滤命中数判断禁用;搜索匹配的
  大小写快速路径限定 ASCII(U+1E9B 等小写映射非自身的字符)
- 应用发布预设后同步模板变量面板,残留行不再写回全局变量
- 日志文件套 BufWriter,高频消息流不再逐条落盘 syscall
This commit is contained in:
2026-09-29 10:33:59 +08:00
parent 6ff05f7216
commit ab13f485e0
4 changed files with 395 additions and 125 deletions
+83 -15
View File
@@ -84,7 +84,7 @@ impl TransportKind {
/// MQTT 5 遗嘱消息(3.1.1 下忽略 v5 专属属性)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LastWill {
pub topic: String,
pub payload: String,
@@ -97,7 +97,7 @@ pub struct LastWill {
}
/// SSL/TLS 自定义配置(CA、双向认证、跳过校验)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SslConfig {
/// 自定义 CA 文件路径(PEM),作为系统根证书的补充
#[serde(default)]
@@ -250,15 +250,21 @@ impl ConnectionConfig {
/// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。
/// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。
/// WS 路径、遗嘱、TLS 配置、keep_alive 同样随 CONNECT 报文一次性生效,
/// 必须纳入比较。
pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool {
self.host != other.host
|| self.port != other.port
|| self.path != other.path
|| self.transport != other.transport
|| self.protocol != other.protocol
|| self.client_id != other.client_id
|| self.username != other.username
|| self.password != other.password
|| self.clean_start != other.clean_start
|| self.keep_alive != other.keep_alive
|| self.last_will != other.last_will
|| self.ssl != other.ssl
}
}
@@ -398,6 +404,11 @@ impl PayloadFormat {
if !cleaned.len().is_multiple_of(2) {
return Err("Hex 长度必须为偶数(每两个字符一个字节)".into());
}
// 必须先做全字节校验:长度检查按字节计数,多字节字符(如中文)
// 可凑成偶数字节,直接按字节索引切片会落在字符边界内而 panic。
if !cleaned.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("Hex 解码失败: 含非十六进制字符".into());
}
(0..cleaned.len())
.step_by(2)
.map(|i| {
@@ -435,6 +446,9 @@ pub struct MqttRecord {
pub subscription_identifier: Option<u32>,
/// payload 超过 [`MAX_PAYLOAD_RETAIN`] 被截断
pub payload_truncated: bool,
/// 非文本负载的原始字节(合法 UTF-8 时为 None)。文本化会失真
/// (非法字节被替换为 U+FFFD),详情面板的 Hex/Base64 需要真实报文。
pub raw_bytes: Option<Arc<[u8]>>,
}
/// 消息历史中单条 payload 的保留上限。超限截断,避免个别大报文
@@ -442,20 +456,27 @@ pub struct MqttRecord {
pub const MAX_PAYLOAD_RETAIN: usize = 256 * 1024;
/// 把原始 payload 字节转成用于历史记录的共享文本,超限截断。
/// 返回 (Arc<str>, 是否截断)。截断时回退到 UTF-8 字符边界。
pub fn retained_payload(raw: &[u8]) -> (Arc<str>, bool) {
if raw.len() <= MAX_PAYLOAD_RETAIN {
(Arc::from(String::from_utf8_lossy(raw).as_ref()), false)
} else {
/// 返回 (文本, 非文本时的原始字节, 是否截断)。截断时回退到 UTF-8 字符边界。
///
/// 仅当文本化会失真(非合法 UTF-8,字节被替换为 U+FFFD)时才保留原始
/// 字节(同样受 [`MAX_PAYLOAD_RETAIN`] 限制);纯文本负载零额外开销。
pub fn retained_payload(raw: &[u8]) -> (Arc<str>, Option<Arc<[u8]>>, bool) {
let truncated = raw.len() > MAX_PAYLOAD_RETAIN;
let text_part = if truncated {
let mut end = MAX_PAYLOAD_RETAIN;
while end > 0 && (raw[end] & 0xC0) == 0x80 {
end -= 1;
}
(
Arc::from(String::from_utf8_lossy(&raw[..end]).as_ref()),
true,
)
}
&raw[..end]
} else {
raw
};
let raw_bytes = match std::str::from_utf8(text_part) {
Ok(_) => None,
Err(_) => Some(Arc::from(text_part.to_vec())),
};
let text: Arc<str> = Arc::from(String::from_utf8_lossy(text_part).as_ref());
(text, raw_bytes, truncated)
}
// ─── 发布参数 / 发布预设 ──────────────────────────────────────────────────────
@@ -897,13 +918,14 @@ mod tests {
#[test]
fn retained_payload_caps_and_preserves_utf8() {
let small = b"hello";
let (p, trunc) = retained_payload(small);
let (p, raw, trunc) = retained_payload(small);
assert_eq!(&*p, "hello");
assert!(raw.is_none(), "合法 UTF-8 不保留原始字节");
assert!(!trunc);
// 中文每字符 3 字节,让上限落在字符中间(256K 不是 3 的倍数)
let big: Vec<u8> = "中".repeat(MAX_PAYLOAD_RETAIN).into_bytes();
let (p, trunc) = retained_payload(&big);
let (p, _, trunc) = retained_payload(&big);
assert!(trunc, "超过上限必须截断");
assert!(p.len() <= MAX_PAYLOAD_RETAIN);
assert_eq!(p.chars().last(), Some('中'), "不能切坏 UTF-8 字符");
@@ -914,7 +936,53 @@ mod tests {
// 恰好上限不截断
let exact = vec![b'a'; MAX_PAYLOAD_RETAIN];
let (_, trunc) = retained_payload(&exact);
let (_, raw, trunc) = retained_payload(&exact);
assert!(!trunc);
assert!(raw.is_none());
}
// 非文本负载:保留原始字节供 Hex/Base64 详情,文本侧 lossy 展示
#[test]
fn retained_payload_keeps_raw_bytes_for_binary() {
let binary = vec![0x00, 0xff, 0xfe, 0x01];
let (p, raw, trunc) = retained_payload(&binary);
assert!(!trunc);
assert_eq!(&*p, String::from_utf8_lossy(&binary));
let raw = raw.expect("非法 UTF-8 必须保留原始字节");
assert_eq!(&*raw, &binary);
}
// Hex 编码:多字节字符不得 panic(曾按字节切片落入字符边界内而崩溃)
#[test]
fn hex_encode_rejects_non_hex_without_panic() {
assert!(PayloadFormat::Hex.encode("deadBEEF").is_ok());
assert!(PayloadFormat::Hex.encode("de ad be ef").is_ok());
assert!(PayloadFormat::Hex.encode("abc").is_err(), "奇数长度报错");
assert!(PayloadFormat::Hex.encode("zz").is_err(), "非法字符报错");
// 偶数字节长度的多字节字符:修复前此处在字符边界内切片 panic
assert!(PayloadFormat::Hex.encode("中文").is_err());
assert!(PayloadFormat::Hex.encode("😀").is_err());
}
// 会话参数比较必须覆盖所有随 CONNECT 报文一次性生效的字段
#[test]
fn session_params_changed_covers_connect_time_fields() {
let a = ConnectionConfig::default();
let mut b = a.clone();
assert!(!a.session_params_changed(&b));
b.keep_alive = a.keep_alive + 1;
assert!(a.session_params_changed(&b), "keep_alive 改动应触发重连提示");
b = a.clone();
b.last_will = Some(LastWill {
topic: "w/t".into(),
..Default::default()
});
assert!(a.session_params_changed(&b), "遗嘱改动应触发重连提示");
b = a.clone();
b.ssl.ca_file = "/tmp/ca.pem".into();
assert!(a.session_params_changed(&b), "TLS 配置改动应触发重连提示");
}
}