修复: 飞书长连接假活自愈——探活持续断流后主动断开重建

看门狗连续 3 个巡检周期(断流约13分钟)判定假活后 Notify 唤醒
run_loop 主动退出,复用外层 backoff 重连循环重建全新 WS 握手
只告警不自愈的盲区已补齐,比 SDK 内部重连更彻底(全新握手)
This commit is contained in:
lxy
2026-10-17 22:19:57 +08:00
parent f74d51b905
commit 45f0560f85
3 changed files with 84 additions and 11 deletions
+10
View File
@@ -0,0 +1,10 @@
# 探测本机正在运行的 DevFlow 进程及其版本(只读)
$p = Get-Process devflow -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Path
if ($p) {
$v = (Get-Item $p).VersionInfo
Write-Host "PATH=$p"
Write-Host "FileVersion=$($v.FileVersion)"
Write-Host "ProductVersion=$($v.ProductVersion)"
} else {
Write-Host "NO_DEVFLOW_PROCESS"
}
+33
View File
@@ -0,0 +1,33 @@
# 校验 updater 验签密钥配对(终版): 比对 keyid(KEYNUM 8字节) —— minisign 格式对齐
# conf.pubkey blob(42B): SIGALG"Ed"[0..2] + KEYNUM[2..10] + PUBKEY[10..42]
# 私钥文件 blob(116B): SIGALG"Rs"[0..2] + KDFALG[2..4] + CKSUM[4..12] + SALT[12..28] + OPS[28..36] + MEM[36..44] + KEYNUM[44..52] + SIG[52..116]
$confPath = "E:\wk-lab\devflow\src-tauri\tauri.conf.json"
$keyPath = Join-Path $env:USERPROFILE ".tauri\devflow.key"
# 1. conf 侧: 提取 pubkey → 解码 → 取 KEYNUM
$raw = Get-Content $confPath -Raw
if ($raw -match '"pubkey"\s*:\s*"([A-Za-z0-9+/=]{40,400})"') { $confPubB64 = $Matches[1] } else { Write-Host "CONF_PUBKEY_NOT_FOUND"; exit 1 }
# conf.pubkey 是 base64(minisign .pub 文件文本),需二次解码取末行
$confText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($confPubB64))
$pubLines = @($confText -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
if ($pubLines.Count -lt 1) { Write-Host "CONF_PUB_FORMAT_UNEXPECTED"; exit 1 }
$confBlob = [Convert]::FromBase64String($pubLines[0])
$confKeynum = ($confBlob[2..9] | ForEach-Object { $_.ToString("x2") }) -join ""
Write-Host "conf keyid : $confKeynum (blob $($confBlob.Length)B)"
# 2. key 侧: 私钥文件 = 注释行 + 1 条 b64 blob(KEYNUM 未加密,明文可读)
if (-not (Test-Path $keyPath)) { Write-Host "KEY_NOT_FOUND: $keyPath"; exit 1 }
$b64Lines = @(Get-Content $keyPath | Where-Object { $_ -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
if ($b64Lines.Count -lt 1) { Write-Host "KEY_FORMAT_UNEXPECTED"; exit 1 }
$secBlob = [Convert]::FromBase64String($b64Lines[0])
$secKeynum = ($secBlob[44..51] | ForEach-Object { $_.ToString("x2") }) -join ""
Write-Host "key keyid : $secKeynum (blob $($secBlob.Length)B)"
# 3. 配对判定
if ($confKeynum -eq $secKeynum) {
Write-Host "MATCH - updater 验签密钥配对正确(keyid $confKeynum), B3 安装验签可通过"
exit 0
} else {
Write-Host "MISMATCH - tauri.conf.json 的 pubkey 与签名私钥不配对, B3 安装验签将失败!"
exit 1
}
+41 -11
View File
@@ -125,6 +125,9 @@ struct Ctx {
/// D3 在途会话数:消息受理(handle_message 白名单通过)到任务投递完成期间 >0,
/// 看门狗要求为 0 才告警,防有回复待投递时误报
inflight: Arc<AtomicUsize>,
/// D5 假活自愈唤醒句柄:看门狗连续告警超阈值后 notify,run_loop 的 channel.start()
/// select 到即主动退出,交给外层重连循环重建连接(Notify 生命周期同 Ctx,跨重连复用)
selfheal: Arc<tokio::sync::Notify>,
}
/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。
@@ -182,6 +185,7 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
debounce: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
last_alive: Arc::new(AtomicI64::new(now_secs())),
inflight: Arc::new(AtomicUsize::new(0)),
selfheal: Arc::new(tokio::sync::Notify::new()),
});
// D1 僵尸资源修复:client 的 'static 化只做一次(原在 run_loop 内,每次重连都
// Box::leak 一份 LarkClient,频繁断线场景内存阶梯上涨);Channel::builder 仅借用
@@ -285,10 +289,16 @@ let ctx_msg = ctx.clone();
})
.build();
tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)");
let result = channel
.start()
.await
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"));
// D5 假活自愈:看门狗持续断流判定后 notify,主动终结本次 channel.start(),
// 交给外层重连循环 backoff 重建连接(比 SDK 内部重连更彻底:全新 WS 握手)
let heal = ctx.selfheal.clone();
let result = tokio::select! {
r = channel.start() => r
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}")),
_ = heal.notified() => {
Err(anyhow::anyhow!("假活自愈: 看门狗判定持续断流,主动断开重建"))
}
};
watchdog.abort();
result?;
Ok(())
@@ -310,25 +320,45 @@ const LIVENESS_TICK: std::time::Duration = std::time::Duration::from_secs(60);
/// 排查起点(此前 bot 静默死无任何日志可查)。
async fn run_liveness_watchdog(ctx: Arc<Ctx>) {
let mut warned = false;
// D5 假活自愈:连续告警计数。每 60s 巡检一次,断流持续 LIVENESS_HEAL_TICKS 次
// (即断流 ≥ 600s + 3×60s 确认期)仍无任何事件恢复,判定为协议假活(SDK 读空闲
// 自愈覆盖不了),主动 notify 唤醒 run_loop 退出重建连接,复用外层 backoff 重连
let mut stale_ticks: u32 = 0;
loop {
tokio::time::sleep(LIVENESS_TICK).await;
let elapsed = now_secs() - ctx.last_alive.load(Ordering::SeqCst);
if elapsed <= LIVENESS_STALE_SECS {
warned = false;
stale_ticks = 0;
continue;
}
// 有会话在途不告警(回复投递依赖当前连接);同一段断流只告警一次防刷屏
if ctx.inflight.load(Ordering::SeqCst) > 0 || warned {
// 有会话在途不告警(回复投递依赖当前连接)
if ctx.inflight.load(Ordering::SeqCst) > 0 {
continue;
}
warned = true;
tracing::warn!("[feishu] 探活告警: {elapsed}s 无任何事件且无在途会话,疑似连接假活");
feishu_log(&format!(
"探活告警: {elapsed}s 无任何事件且无在途会话(疑似假活,持续出现请重启应用重建连接)"
));
if !warned {
warned = true;
tracing::warn!("[feishu] 探活告警: {elapsed}s 无任何事件且无在途会话,疑似连接假活");
feishu_log(&format!(
"探活告警: {elapsed}s 无任何事件且无在途会话(疑似假活)"
));
}
stale_ticks += 1;
if stale_ticks >= LIVENESS_HEAL_TICKS {
tracing::error!(
"[feishu] 假活自愈触发: 断流持续 {} 个巡检周期,主动断开重建长连接",
stale_ticks
);
feishu_log("假活自愈触发: 持续断流,主动断开重建长连接");
ctx.selfheal.notify_waiters();
return;
}
}
}
/// D5 自愈确认期:首个告警后再等 N 个巡检周期仍断流才重建(防把正常空闲+慢恢复误杀)
const LIVENESS_HEAL_TICKS: u32 = 3;
/// D3 在途会话计数 RAII:handle_message 从注册等待桥到投递完成的任意退出路径
/// (spawn 失败早退/投递完成)都保证计数回落
struct InflightGuard(Arc<AtomicUsize>);