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
+127 -52
View File
@@ -100,7 +100,7 @@ pub struct MqttXApp {
/// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出)
file_dialog_open: bool,
/// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件
log_file: Option<(String, std::fs::File)>,
log_file: Option<(String, std::io::BufWriter<std::fs::File>)>,
/// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活
_appearance_obs: gpui_kit::Subscription,
}
@@ -137,49 +137,85 @@ impl MqttXApp {
let rx = engine.run_blocking(update::check_and_download);
let weak = cx.entity().downgrade();
cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
if let Ok(Ok(Some(staged))) = rx.recv().await {
weak.update_in(cx, |_app, window, cx| {
match rx.recv().await {
Ok(Ok(Some(staged))) => {
weak.update_in(cx, |_app, window, cx| {
window.open_dialog(cx, move |dialog, _, _cx| {
let staged = staged.clone();
let staged_skip = staged.clone();
let ver = staged.version.clone();
dialog
.w(px(440.))
.title("更新就绪")
.child(
v_flex().gap_2().child(
div().text_sm().child(format!(
"新版本 v{} 已下载完成(已通过 sha256 校验)。",
staged.version
)),
),
)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("upd-later")
.label("下次启动安装")
.outline()
.on_click(|_, window, cx| {
window.close_dialog(cx)
}),
)
.child(
Button::new("upd-now")
.label("立即安装")
.primary()
.on_click(move |_, window, cx| {
window.close_dialog(cx);
if update::install_staged(&staged).is_ok() {
cx.quit();
}
}),
.w(px(440.))
.title("更新就绪")
.child(
v_flex().gap_2().child(
div().text_sm().child(format!(
"新版本 v{} 已下载完成(已通过 sha256 校验)。",
staged.version
)),
),
)
});
})
.ok();
)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("upd-skip")
.label("暂不更新")
.outline()
.on_click(move |_, window, cx| {
// 拒绝本次更新:删除暂存包,
// 下次启动不会被自动安装
update::discard_staged(&staged_skip);
window.close_dialog(cx);
}),
)
.child(
Button::new("upd-later")
.label("下次启动安装")
.outline()
.on_click(move |_, window, cx| {
// 同意标记:启动时据此自动安装;
// 用户跳过对话框则不会强制升级
update::mark_install_consent(&ver);
window.close_dialog(cx);
}),
)
.child(
Button::new("upd-now")
.label("立即安装")
.primary()
.on_click(move |_, window, cx| {
window.close_dialog(cx);
if let Err(e) =
update::install_staged(&staged)
{
window.push_notification(
Notification::error(format!(
"安装失败:{e}"
)),
cx,
);
} else {
cx.quit();
}
}),
),
)
});
})
.ok();
}
// 检查/下载失败不打扰用户,但也不能完全无声
Ok(Err(e)) => {
eprintln!("[app] 启动更新检查失败: {e}");
sentry::capture_message(
&format!("startup update check failed: {e}"),
sentry::Level::Warning,
);
}
_ => {}
}
})
.detach();
@@ -289,6 +325,12 @@ impl MqttXApp {
error,
..
} => {
// 引擎 close 与事件循环退出之间有窗口,删除连接后其排队
// 事件仍会送达:为已删除的连接重建状态/消息属于泄漏,
// 一律丢弃(删除时已主动 close)。
if !self.connections.iter().any(|c| c.id == connection_id) {
return;
}
self.statuses.insert(connection_id.clone(), status);
match error {
Some(e) => {
@@ -308,6 +350,9 @@ impl MqttXApp {
}
EngineEvent::Message(mut record) => {
let id = record.connection_id.clone();
if !self.connections.iter().any(|c| c.id == id.as_ref()) {
return;
}
record.seq = self.next_seq();
self.push_message(id.clone(), record);
if let Some(view) = self.views.get(id.as_ref()) {
@@ -322,8 +367,14 @@ impl MqttXApp {
retain,
content_type,
user_properties,
response_topic,
correlation_data,
message_expiry_interval,
} => {
let (payload, payload_truncated) = retained_payload(&payload);
if !self.connections.iter().any(|c| c.id == connection_id) {
return;
}
let (payload, raw_bytes, payload_truncated) = retained_payload(&payload);
let record = MqttRecord {
seq: self.next_seq(),
connection_id: Arc::from(connection_id.as_str()),
@@ -335,11 +386,12 @@ impl MqttXApp {
timestamp: chrono::Local::now().timestamp_millis(),
user_properties,
content_type,
response_topic: None,
correlation_data: None,
message_expiry_interval: None,
response_topic,
correlation_data,
message_expiry_interval,
subscription_identifier: None,
payload_truncated,
raw_bytes,
};
self.push_message(Arc::from(connection_id.as_str()), record);
if let Some(view) = self.views.get(connection_id.as_str()) {
@@ -353,13 +405,29 @@ impl MqttXApp {
ok,
error,
} => {
if !self.connections.iter().any(|c| c.id == connection_id) {
return;
}
if ok {
self.add_subscription(Subscription::new(connection_id.clone(), topic, qos));
} else if let Some(e) = error {
window.push_notification(
Notification::error(format!("订阅失败: {e}")),
cx,
);
// 乐观更新已在发送前入库;这里只在订阅仍存在时同步 QoS,
// 避免 SUBACK 前被删除的订阅被此事件用默认字段复活。
if let Some(existing) = self.subscriptions.iter_mut().find(|s| {
s.connection_id == connection_id && s.topic == topic
}) {
existing.qos = qos;
}
} else {
if let Some(e) = error {
window.push_notification(
Notification::error(format!("订阅失败: {e}")),
cx,
);
}
// broker 拒绝订阅:移除乐观插入的订阅行,与引擎状态保持一致
// (不发 UNSUBSCRIBE——本就未订阅成功)
self.subscriptions
.retain(|s| !(s.connection_id == connection_id && s.topic == topic));
self.storage.save_subscriptions(&self.subscriptions);
}
if let Some(view) = self.views.get(&connection_id) {
view.update(cx, |_, cx| cx.notify());
@@ -448,6 +516,8 @@ impl MqttXApp {
}
/// 打开(或复用按日缓存的)日志文件并追加一行。
/// 文件套 BufWriter:高频消息流下每条日志不再触发多次落盘 syscall;
/// 跨日重开/出错关句柄时随 drop 自动 flush。
fn append_log_line(&mut self, entry: &LogEntry) -> std::io::Result<()> {
use std::io::Write as _;
use chrono::TimeZone as _;
@@ -469,7 +539,7 @@ impl MqttXApp {
.create(true)
.append(true)
.open(path)?;
self.log_file = Some((day, file));
self.log_file = Some((day, std::io::BufWriter::new(file)));
}
// 连接名查不到时回退为 id,保证行格式恒定
let conn = self
@@ -547,6 +617,11 @@ impl MqttXApp {
}
pub fn toggle_connection(&self, cfg: &ConnectionConfig) {
// Connecting 期间 is_connected 恒为 false:防抖,双击/误触不会
// 并发两次 connect(会互相顶掉句柄并在 broker 侧形成重连风暴)
if self.statuses.get(&cfg.id) == Some(&ConnectionStatus::Connecting) {
return;
}
if self.engine.is_connected(&cfg.id) {
self.engine.close(&cfg.id, true);
} else {
@@ -1273,7 +1348,7 @@ impl MqttXApp {
.filter(|c| c.group.as_deref().map(str::trim) == Some(name.as_str()))
.count();
row = row.child(self.render_group_chip(
SharedString::from(format!("chip-{}", name)),
SharedString::from(format!("chip-g-{name}")),
format!("{name} {count}"),
GroupFilter::Named(name),
cx,
+28 -10
View File
@@ -699,6 +699,9 @@ impl ConnectionView {
s.set_selected_index(Some(IndexPath::new(fmt_idx)), window, cx)
});
self.retain = params.retain;
// 同步模板变量面板:预设替换了模板,旧占位符行必须随之清理,
// 否则残留行仍会写回全局变量
self.sync_var_rows(window, cx);
cx.notify();
}
}
@@ -1352,7 +1355,7 @@ impl ConnectionView {
if expanded {
let fmt = self.detail_format;
let detail_text = format_payload_detail(&record.payload, fmt);
let detail_text = format_payload_detail(&record.payload, record.raw_bytes.as_deref(), fmt);
let mut details = v_flex().gap_1p5().pt_1();
// 格式切换 + 复制按钮;stop_propagation 避免点击时又收起整行
@@ -1535,7 +1538,12 @@ impl ConnectionView {
let dir = self.msg_dir;
let sub_filter = self.sub_filter.clone();
// 引用过滤 + 计数命中总数,仅克隆前 MAX_RENDERED_MESSAGES 条,避免全量拷贝
let (records, subs, matched_total): (Vec<MqttRecord>, Vec<Subscription>, usize) = self
let (records, subs, matched_total, total): (
Vec<MqttRecord>,
Vec<Subscription>,
usize,
usize,
) = self
.with_app(cx, |app| {
let subs: Vec<Subscription> = app
.subscriptions
@@ -1545,6 +1553,7 @@ impl ConnectionView {
.collect();
let mut matched = 0usize;
let mut records: Vec<MqttRecord> = Vec::new();
let total = app.messages.get(conn.as_str()).map_or(0, |m| m.len());
if let Some(m) = app.messages.get(conn.as_str()) {
for r in m.iter().rev() {
// 方向 / 搜索 / 订阅过滤叠加,过滤后再截断保证最新消息优先
@@ -1567,7 +1576,7 @@ impl ConnectionView {
}
}
}
(records, subs, matched)
(records, subs, matched, total)
})
.unwrap_or_default();
@@ -1722,7 +1731,9 @@ impl ConnectionView {
.child({
let weak = cx.weak_entity();
let clear_id = self.conn_id.clone();
let empty = records.is_empty();
// 清空针对连接的全部消息(非过滤结果):
// 禁用与否依据总数,过滤命中 0 条不应禁用
let empty = total == 0;
Button::new(SharedString::from(format!("msg-clear-{}", self.conn_id)))
.icon(IconName::Eraser)
.label("清空")
@@ -2577,22 +2588,24 @@ fn pick_subscription_color(subs: &[Subscription], topic: &str) -> Option<f32> {
}
/// 展开详情里的 payload 渲染:自动 = 合法 JSON 美化,否则原文。
fn format_payload_detail(payload: &str, format: DetailFormat) -> String {
/// Hex/Base64 优先使用入库保留的原始字节——非文本负载的 lossy 文本
/// 已把非法字节替换为 U+FFFD,按文本字节展示会与线上报文不符。
fn format_payload_detail(payload: &str, raw: Option<&[u8]>, format: DetailFormat) -> String {
match format {
DetailFormat::Auto => serde_json::from_str::<serde_json::Value>(payload)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| payload.to_string()),
DetailFormat::Text => payload.to_string(),
DetailFormat::Hex => payload
.as_bytes()
DetailFormat::Hex => raw
.unwrap_or(payload.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" "),
DetailFormat::Base64 => {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(payload.as_bytes())
base64::engine::general_purpose::STANDARD.encode(raw.unwrap_or(payload.as_bytes()))
}
}
}
@@ -2663,8 +2676,13 @@ fn contains_ignore_case(hay: &str, needle: &str) -> bool {
if hay.contains(needle) {
return true;
}
// 原文没有任何大写字符时,小写化不会改变匹配结果,直接判否
if !hay.chars().any(|c| c.is_uppercase()) {
// 纯 ASCII 快速路径:双方均为 ASCII 且原文无 ASCII 大写时,
// 小写化恒等,可直接判否。不能用 is_uppercase 判断——个别
// 小写字符(如 U+1E9B)的小写映射并不等于自身。
if needle.is_ascii()
&& hay.is_ascii()
&& !hay.bytes().any(|b| b.is_ascii_uppercase())
{
return false;
}
hay.to_lowercase().contains(needle)