fix(console): 加单实例保护,修「Windows 端起了两个被控端」
真机现象两桩:① Windows 上同时跑着两个被控端;② 旧的服务端还在跑时控制台起不来。 根因同一个:控制台没有单实例锁,再启动一次就是第二个进程 → 第二个 AgentManager → 抢同一个 8765 端口 → 后起的那个起不来。另有一条陈旧的 na-gui 计划任务指向测试 部署目录,也在抢同一个端口。 - 引入 tauri-plugin-single-instance(**必须注册为第一个插件**)。 - 第二次启动**不是**静默退出,而是「聚焦既有窗口 + 发 na-second-instance 事件」让前端 刷新状态。这是刻意的:自启动实例是**无窗口**的,若第二次启动静默退出,用户双击图标 只会看到「什么都不发生」—— 那比缺陷本身更难排查。带自启参数(AGENT_ONLY_FLAG)时跳过显示。 - 端口占用的报错补上「怎么找出占用者」:netstat -ano | findstr :<port> / lsof -nP -iTCP:<port> -sTCP:LISTEN,并新增一条单测。
This commit is contained in:
1 parent
ae02252a1d
commit
530bb4fca7
5 files changed
+114
-2
No files matched your search
Generated
+17
@@ -2108,6 +2108,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -3519,6 +3520,22 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.12.1"
|
||||
|
||||
@@ -22,6 +22,9 @@ tauri-build = { version = "2", features = [] }
|
||||
# 不给这个 feature,`TrayIconBuilder` 根本不存在(编译期就找不到符号)。
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
# ⚠️ 单实例保护(v2.0.3 修):缺了它,「开机自启的隐藏实例 + 用户手开的实例」会并存,
|
||||
# 各自托管一个被控端 → 同机两个被控端抢同一个 8765。详见 src/lib.rs 顶部「单实例」一节。
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
|
||||
|
||||
@@ -394,12 +394,25 @@ impl AgentManager {
|
||||
|
||||
// 端口占用检查:既有的(非本控制台托管的)实例在跑时,给出可执行的说明,
|
||||
// 而不是让用户看到一个 `EADDRINUSE` 堆栈。
|
||||
//
|
||||
// ⚠️ 这里是症状 B(「应用端无法启动」)的**现场**:真机上曾有陈旧计划任务拉起的
|
||||
// 被控端占着 8765,控制台自己的被控端起不来。端口检查本身是对的,
|
||||
// 但只给一句「已被占用」用户无从下手 —— 故补上「怎么找出占用者」。
|
||||
let cfg = read_agent_config(data_dir);
|
||||
let port = cfg.get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
|
||||
if port_in_use(port) {
|
||||
let hint = if cfg!(windows) {
|
||||
format!(
|
||||
"netstat -ano | findstr :{port} 找到 PID,再 tasklist /FI \"PID eq <PID>\" 看是谁"
|
||||
)
|
||||
} else {
|
||||
format!("lsof -nP -iTCP:{port} -sTCP:LISTEN")
|
||||
};
|
||||
return Err(format!(
|
||||
"端口 {port} 已被占用 —— 可能已有一个被控端在运行(手工安装的或另一个实例)。\
|
||||
请先停止它,或修改 {} 里的 port。",
|
||||
"端口 {port} 已被占用 —— 已有另一个被控端在监听(手工安装的、计划任务拉起的,\
|
||||
或另一个控制台实例)。\n\
|
||||
排查占用者:{hint}\n\
|
||||
处理:先停掉它;或把 {} 里的 port 改成别的端口后重启本机服务。",
|
||||
data_dir.join("agent.json").display()
|
||||
));
|
||||
}
|
||||
@@ -730,6 +743,37 @@ mod tests {
|
||||
m.stop().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 端口被外部占用时应给出可执行的错误说明() {
|
||||
let dir = tmp_dir("port-busy");
|
||||
let script = fake_agent(&dir);
|
||||
// 占住一个空闲端口,扮演「手工安装的 / 计划任务拉起的另一个被控端」
|
||||
// (真机症状 B:陈旧计划任务 na-gui 占着 8765,控制台自己的被控端起不来)
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("应能绑一个空闲端口");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::fs::write(
|
||||
dir.join("agent.json"),
|
||||
format!("{{\"node_id\":\"t\",\"port\":{port},\"key\":\"k\",\"tls\":true}}"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
let err = m
|
||||
.start(&node_bin(), &script, &dir, false)
|
||||
.await
|
||||
.expect_err("端口被占时应拒绝启动");
|
||||
assert!(err.contains("已被占用"), "应说明端口被占用,实际:{err}");
|
||||
assert!(err.contains(&port.to_string()), "错误里应带上具体端口号,实际:{err}");
|
||||
// 关键:必须给出「怎么找出占用者」的可执行线索,否则用户无从下手
|
||||
assert!(
|
||||
err.contains("lsof") || err.contains("netstat"),
|
||||
"应给出排查占用者的命令,实际:{err}"
|
||||
);
|
||||
// 端口检查失败不该改动状态、更不该留下进程
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::STOPPED));
|
||||
drop(listener);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 进程意外退出应转为crashed() {
|
||||
let dir = tmp_dir("crash");
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
//! - 默认:显示主窗口
|
||||
//! - `--agent-only`:**不显示窗口**,直接进托盘并托管本机被控端(开机自启用它,
|
||||
//! 这样登录自启也不会有任何窗口弹出来)
|
||||
//!
|
||||
//! ## 单实例(v2.0.3)
|
||||
//! 见下方「单实例」一节 —— 真机缺陷:无保护时同机会跑起两个被控端。
|
||||
|
||||
mod agent;
|
||||
mod autostart;
|
||||
@@ -603,11 +606,51 @@ fn build_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单实例
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 为什么必须有(真机缺陷,2026-10-13)
|
||||
// 控制台此前**完全没有单实例保护**。开机自启会拉起一个 `--agent-only` 实例(无窗口、驻托盘),
|
||||
// 用户再手动双击图标 → **第二个进程**。而「本机被控端」是**进程内状态**
|
||||
// (`AgentManager` 各持自己的 `child: Option<Child>` / `pid`),两个进程互相看不见 →
|
||||
// 同一台机器上跑起**两个被控端**,抢同一个 8765 端口。
|
||||
//
|
||||
// 后果按先后顺序分两种,正是用户报的两个症状:
|
||||
// - **A(两个被控端)**:两边都抢到了不同时机 → 同机并存两个被控端进程;
|
||||
// - **B(后开的起不来)**:先占住端口的那一侧赢了,后启动的一侧在 `AgentManager::start`
|
||||
// 的端口检查处失败,界面表现为「应用端无法启动」(本机服务起不来)。
|
||||
//
|
||||
// ## 为什么不能简单「已有实例就退出」
|
||||
// 自启实例是**无窗口**的(`--agent-only`)。若第二次启动只是静默退出,用户双击图标会
|
||||
// **什么都不发生** —— 比缺陷本身更难排查。正确语义是:
|
||||
// 第二次启动 → 把「用户想看到界面」这个意图**交给已有实例**(让它显示并聚焦窗口)→ 新进程再退出。
|
||||
//
|
||||
// `tauri-plugin-single-instance` 正好是这个语义:回调在**已有实例**里执行,新进程由插件负责收尾退出。
|
||||
// 它必须注册为**第一个**插件,才能在其他插件与本项目的 `setup`(会拉起被控端)之前完成抢占判定。
|
||||
|
||||
/// 第二次启动时执行(在**已有实例**的进程里)。
|
||||
fn on_second_instance(app: &AppHandle, argv: Vec<String>) {
|
||||
// 第二次启动若自己就带 `--agent-only`(例如自启被重复触发),**不要弹窗** ——
|
||||
// 那正是「仅服务模式」要避免的行为。只提示已有实例,不做别的。
|
||||
if argv.iter().any(|a| a == autostart::AGENT_ONLY_FLAG) {
|
||||
return;
|
||||
}
|
||||
// 已有实例可能是「自启的隐藏实例」或「窗口已关到托盘」→ 两种都要能唤出来。
|
||||
show_main(app);
|
||||
// 顺带通知前端刷一次状态(第二次启动往往发生在「用户发现没反应、想确认一下」的时刻)。
|
||||
// 前端没监听也无害。
|
||||
let _ = app.emit("na-second-instance", json!({ "argv": argv }));
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let agent_only = std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
on_second_instance(app, argv);
|
||||
}))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(move |app| {
|
||||
let handle = app.handle().clone();
|
||||
|
||||
@@ -88,6 +88,11 @@ export const useAgentStore = defineStore("agent", () => {
|
||||
await listen<string>("na-agent-state", () => {
|
||||
void refresh();
|
||||
});
|
||||
// v2.0.3 单实例:用户再次双击图标时,由**已存在实例**接管(把窗口唤出来)。
|
||||
// 顺手刷新一次 —— 触发它的场景往往正是「点了没反应,想看看怎么回事」。
|
||||
await listen<{ argv?: string[] }>("na-second-instance", () => {
|
||||
void refresh();
|
||||
});
|
||||
await refresh();
|
||||
void loadSystemInfo();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user