修复: 飞书长连接假活自愈——探活持续断流后主动断开重建
看门狗连续 3 个巡检周期(断流约13分钟)判定假活后 Notify 唤醒 run_loop 主动退出,复用外层 backoff 重连循环重建全新 WS 握手 只告警不自愈的盲区已补齐,比 SDK 内部重连更彻底(全新握手)
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -125,6 +125,9 @@ struct Ctx {
|
|||||||
/// D3 在途会话数:消息受理(handle_message 白名单通过)到任务投递完成期间 >0,
|
/// D3 在途会话数:消息受理(handle_message 白名单通过)到任务投递完成期间 >0,
|
||||||
/// 看门狗要求为 0 才告警,防有回复待投递时误报
|
/// 看门狗要求为 0 才告警,防有回复待投递时误报
|
||||||
inflight: Arc<AtomicUsize>,
|
inflight: Arc<AtomicUsize>,
|
||||||
|
/// D5 假活自愈唤醒句柄:看门狗连续告警超阈值后 notify,run_loop 的 channel.start()
|
||||||
|
/// select 到即主动退出,交给外层重连循环重建连接(Notify 生命周期同 Ctx,跨重连复用)
|
||||||
|
selfheal: Arc<tokio::sync::Notify>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。
|
/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。
|
||||||
@@ -182,6 +185,7 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
|
|||||||
debounce: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
debounce: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
last_alive: Arc::new(AtomicI64::new(now_secs())),
|
last_alive: Arc::new(AtomicI64::new(now_secs())),
|
||||||
inflight: Arc::new(AtomicUsize::new(0)),
|
inflight: Arc::new(AtomicUsize::new(0)),
|
||||||
|
selfheal: Arc::new(tokio::sync::Notify::new()),
|
||||||
});
|
});
|
||||||
// D1 僵尸资源修复:client 的 'static 化只做一次(原在 run_loop 内,每次重连都
|
// D1 僵尸资源修复:client 的 'static 化只做一次(原在 run_loop 内,每次重连都
|
||||||
// Box::leak 一份 LarkClient,频繁断线场景内存阶梯上涨);Channel::builder 仅借用
|
// Box::leak 一份 LarkClient,频繁断线场景内存阶梯上涨);Channel::builder 仅借用
|
||||||
@@ -285,10 +289,16 @@ let ctx_msg = ctx.clone();
|
|||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)");
|
tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)");
|
||||||
let result = channel
|
// D5 假活自愈:看门狗持续断流判定后 notify,主动终结本次 channel.start(),
|
||||||
.start()
|
// 交给外层重连循环 backoff 重建连接(比 SDK 内部重连更彻底:全新 WS 握手)
|
||||||
.await
|
let heal = ctx.selfheal.clone();
|
||||||
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"));
|
let result = tokio::select! {
|
||||||
|
r = channel.start() => r
|
||||||
|
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}")),
|
||||||
|
_ = heal.notified() => {
|
||||||
|
Err(anyhow::anyhow!("假活自愈: 看门狗判定持续断流,主动断开重建"))
|
||||||
|
}
|
||||||
|
};
|
||||||
watchdog.abort();
|
watchdog.abort();
|
||||||
result?;
|
result?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -310,24 +320,44 @@ const LIVENESS_TICK: std::time::Duration = std::time::Duration::from_secs(60);
|
|||||||
/// 排查起点(此前 bot 静默死无任何日志可查)。
|
/// 排查起点(此前 bot 静默死无任何日志可查)。
|
||||||
async fn run_liveness_watchdog(ctx: Arc<Ctx>) {
|
async fn run_liveness_watchdog(ctx: Arc<Ctx>) {
|
||||||
let mut warned = false;
|
let mut warned = false;
|
||||||
|
// D5 假活自愈:连续告警计数。每 60s 巡检一次,断流持续 LIVENESS_HEAL_TICKS 次
|
||||||
|
// (即断流 ≥ 600s + 3×60s 确认期)仍无任何事件恢复,判定为协议假活(SDK 读空闲
|
||||||
|
// 自愈覆盖不了),主动 notify 唤醒 run_loop 退出重建连接,复用外层 backoff 重连
|
||||||
|
let mut stale_ticks: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(LIVENESS_TICK).await;
|
tokio::time::sleep(LIVENESS_TICK).await;
|
||||||
let elapsed = now_secs() - ctx.last_alive.load(Ordering::SeqCst);
|
let elapsed = now_secs() - ctx.last_alive.load(Ordering::SeqCst);
|
||||||
if elapsed <= LIVENESS_STALE_SECS {
|
if elapsed <= LIVENESS_STALE_SECS {
|
||||||
warned = false;
|
warned = false;
|
||||||
|
stale_ticks = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 有会话在途不告警(回复投递依赖当前连接);同一段断流只告警一次防刷屏
|
// 有会话在途不告警(回复投递依赖当前连接)
|
||||||
if ctx.inflight.load(Ordering::SeqCst) > 0 || warned {
|
if ctx.inflight.load(Ordering::SeqCst) > 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !warned {
|
||||||
warned = true;
|
warned = true;
|
||||||
tracing::warn!("[feishu] 探活告警: {elapsed}s 无任何事件且无在途会话,疑似连接假活");
|
tracing::warn!("[feishu] 探活告警: {elapsed}s 无任何事件且无在途会话,疑似连接假活");
|
||||||
feishu_log(&format!(
|
feishu_log(&format!(
|
||||||
"探活告警: {elapsed}s 无任何事件且无在途会话(疑似假活,持续出现请重启应用重建连接)"
|
"探活告警: {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 从注册等待桥到投递完成的任意退出路径
|
/// D3 在途会话计数 RAII:handle_message 从注册等待桥到投递完成的任意退出路径
|
||||||
/// (spawn 失败早退/投递完成)都保证计数回落
|
/// (spawn 失败早退/投递完成)都保证计数回落
|
||||||
|
|||||||
Reference in New Issue
Block a user