From 5cc67b725b8ab871794e2b74b8346b44a620c5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 5 May 2026 22:32:17 +0800 Subject: [PATCH] fix: isolate desktop IM adapter sidecars Telegram polling stopped consuming updates when all IM adapters were loaded into the same desktop sidecar process. Launch each IM platform as its own sidecar process so Telegram keeps an isolated long-polling consumer while Feishu, WeChat, and DingTalk manage their own SDK loops independently. Constraint: Telegram Bot API long polling needs one healthy consumer per token and should not share lifecycle with other IM SDKs Rejected: Keep one combined adapter process | reproduced pending Telegram updates until Telegram was launched alone Confidence: high Scope-risk: moderate Directive: Keep IM adapter process ownership isolated unless cross-platform lifecycle tests prove a merged process is safe Tested: cargo fmt --check Tested: cargo check Tested: bun run check:native Tested: ./desktop/scripts/build-macos-arm64.sh Tested: Computer Use launched built app and sent Telegram smoke message with pending_update_count=0 Not-tested: Automated Feishu, WeChat, or DingTalk live message delivery after process split --- desktop/src-tauri/src/lib.rs | 124 +++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 58 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 84537cfa..a7a1d320 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -188,12 +188,12 @@ struct StoredWindowState { /// 与 ServerState 平级的 adapter 子进程状态。 /// -/// adapter sidecar(claude-sidecar adapters --feishu --telegram --wechat --dingtalk)的生命周期 +/// adapter sidecar(claude-sidecar adapters --telegram 等)的生命周期 /// 跟 server 不同:它没有 HTTP 端口可探活,没配凭据时会自己干净退出, -/// 而且需要支持运行时热重启 —— 用户在设置页保存飞书 / Telegram 凭据后, +/// 而且需要支持运行时热重启 —— 用户在设置页保存 IM 凭据后, /// 前端会通过 invoke('restart_adapters_sidecar') 来重启它,让新凭据生效。 #[derive(Default)] -struct AdapterState(Mutex>); +struct AdapterState(Mutex>); #[derive(Default)] struct TerminalState { @@ -1081,9 +1081,9 @@ fn stop_server_sidecar(app: &AppHandle) { } } -/// 启动 adapter sidecar。返回 Result 主要为了把"无法 spawn"和"spawn 后立刻 -/// 退出(凭据缺失)"区分开 —— 后者不算错误,是正常 default 状态。 -fn start_adapters_sidecar(app: &AppHandle) -> Result { +/// 启动 adapter sidecars。每个平台单独一个进程,避免某个平台的 SDK / long polling +/// 影响其它平台(Telegram 尤其要求同一个 Bot Token 只有一个活跃 consumer)。 +fn start_adapters_sidecars(app: &AppHandle) -> Result, String> { #[cfg(unix)] kill_stale_unix_adapter_sidecars(); @@ -1117,65 +1117,73 @@ fn start_adapters_sidecar(app: &AppHandle) -> Result { server_http_url.clone() }; - let mut sidecar = app - .shell() - .sidecar("claude-sidecar") - .map_err(|err| format!("resolve sidecar: {err}"))?; - for (key, value) in terminal_environment(&default_shell()) { - sidecar = sidecar.env(key, value); - } - let sidecar = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url).args([ - "adapters", - "--app-root", - &app_root_arg, - "--feishu", - "--telegram", - "--wechat", - "--dingtalk", - ]); - - let (mut rx, child) = sidecar - .spawn() - .map_err(|err| format!("spawn adapter sidecar: {err}"))?; - - // 用一个 async task 把 sidecar 的 stdout/stderr 转发出来。它退出时 - // 整个 task 也会自然结束。 - tauri::async_runtime::spawn(async move { - while let Some(event) = rx.recv().await { - match event { - CommandEvent::Stdout(line) => { - let line = String::from_utf8_lossy(&line); - println!("[claude-adapters] {}", line.trim_end()); - } - CommandEvent::Stderr(line) => { - let line = String::from_utf8_lossy(&line); - eprintln!("[claude-adapters] {}", line.trim_end()); - } - CommandEvent::Terminated(payload) => { - // exit code != 0 是常态:用户没配凭据时 sidecar 内部会 - // warn + skip + process.exit(1)。这里只 info 一行, - // 不要当错误冒泡。 - println!( - "[claude-adapters] sidecar exited (code={:?}, signal={:?})", - payload.code, payload.signal - ); - } - _ => {} - } + let mut children = Vec::new(); + for (label, flag) in [ + ("feishu", "--feishu"), + ("telegram", "--telegram"), + ("wechat", "--wechat"), + ("dingtalk", "--dingtalk"), + ] { + let mut sidecar = app + .shell() + .sidecar("claude-sidecar") + .map_err(|err| format!("resolve {label} adapter sidecar: {err}"))?; + for (key, value) in terminal_environment(&default_shell()) { + sidecar = sidecar.env(key, value); } - }); + let sidecar = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url).args([ + "adapters", + "--app-root", + &app_root_arg, + flag, + ]); - Ok(child) + let (mut rx, child) = sidecar + .spawn() + .map_err(|err| format!("spawn {label} adapter sidecar: {err}"))?; + let label = label.to_string(); + + // 用一个 async task 把 sidecar 的 stdout/stderr 转发出来。它退出时 + // 整个 task 也会自然结束。 + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(line) => { + let line = String::from_utf8_lossy(&line); + println!("[claude-adapters:{label}] {}", line.trim_end()); + } + CommandEvent::Stderr(line) => { + let line = String::from_utf8_lossy(&line); + eprintln!("[claude-adapters:{label}] {}", line.trim_end()); + } + CommandEvent::Terminated(payload) => { + // exit code != 0 是常态:用户没配凭据时 sidecar 内部会 + // warn + skip + process.exit(1)。这里只 info 一行, + // 不要当错误冒泡。 + println!( + "[claude-adapters:{label}] sidecar exited (code={:?}, signal={:?})", + payload.code, payload.signal + ); + } + _ => {} + } + } + }); + + children.push(child); + } + + Ok(children) } -/// spawn adapter sidecar 并把 child handle 存进 AdapterState。 +/// spawn adapter sidecars 并把 child handles 存进 AdapterState。 /// 在启动 + 重启路径里复用,集中处理"无法 spawn"的日志。 fn spawn_and_track_adapters_sidecar(app: &AppHandle) { - match start_adapters_sidecar(app) { - Ok(child) => { + match start_adapters_sidecars(app) { + Ok(children) => { if let Some(state) = app.try_state::() { if let Ok(mut guard) = state.0.lock() { - *guard = Some(child); + *guard = children; } } } @@ -1192,7 +1200,7 @@ fn stop_adapters_sidecar(app: &AppHandle) { let Ok(mut guard) = state.0.lock() else { return; }; - if let Some(child) = guard.take() { + for child in guard.drain(..) { let _ = child.kill(); } }