Files
nodeagent/apps/console/src-tauri/src/agent.rs
T
jkooe eec76ed91e fix(console): 交给 node 的路径去掉 Windows verbatim 前缀(真机 sidecar 秒退根因)
真机现象(Windows 11 ARM64,控制台 v2.0.3):点「检查更新」立刻报
「sidecar 已退出(stdout 关闭),请重试」—— sidecar 在请求进行中真的退出了。

真机 A/B(同一个 node.exe、同一份 sidecar.mjs,只换脚本参数写法):
  C:\…\sidecar.mjs        → 2.5 秒返回结果,进程常驻
  \\?\C:\…\sidecar.mjs    → 0.47 秒退出,exit 1,零 JS 执行

根因:Tauri 的 resource_dir() 在 Windows 上返回 canonicalize 后的 verbatim 路径,
把它当参数交给 node,node 启动期 realpathSync 把 `\\?\C:` 拆成 `C:` →
EISDIR ... lstat 'C:' → exit 1,一行 JS 都来不及执行。
本地/CI/开发模式全测不出:开发期走 CARGO_MANIFEST_DIR 的普通路径,只有打包后才暴露。

修法:
- 新增 unverbatim():剥 \\?\UNC\ → \\、剥 \\?\ → 无前缀,纯字符串处理,全平台可调用
- 新增 sidecar_command():唯一把路径交给 node 的地方,单测断言其最终 program/args
- resolve_node / resolve_sidecar / resolve_bundled_agent 全部出口加闸
- Sidecar::spawn 与 AgentManager::start 进程边界对 node/script/data_dir 三条路径净化
- autostart::app_exe() 净化(该路径进计划任务 -Execute)

回归:新增 3 条守卫;注入故障(去掉 sidecar_command 的 unverbatim)实测第三条如期变红,
还原后 sha256 逐字节一致。pnpm test:rust 17/17(14 → +3),clippy 零告警。
2026-10-13 20:11:03 +08:00

870 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 本机被控端(agent)进程管理器 —— 让控制台能「启动 / 停止 / 重启」本机被控端,
//! 用户全程**不必碰命令行**。
//!
//! ## 关键设计(每条都是踩出来的)
//!
//! 1. **脚本必须在数据目录里跑,不能在 app bundle 里跑。**
//! macOS 的 `.app` 是签名保护的只读资源 —— 自更新写不进去;且开机自启需要**稳定路径**
//! (用户挪动 / 重装 / 删除 app 不该带走被控端)。故首次运行时把内置的 `agent.mjs`
//! **播种**到 `<数据目录>/runtime/agent.mjs`,此后一律跑这一份。
//!
//! 2. **绝不覆盖「已自更新」的副本。**
//! 用 `<数据目录>/runtime/agent.seed.json` 记「播种时的应用版本」;只有**应用版本变了**
//! 才重新播种。否则控制台每次启动都会把被控端(可能已自更新到更新版)**降级**回随包版本 ——
//! 这类「悄悄降级」不会报任何错,只是功能莫名回退。
//!
//! 3. **Windows 必须 `CREATE_NO_WINDOW`。**
//! 否则每次 spawn `node.exe` 都会弹一个黑色控制台窗口。本项目的目标之一正是
//! 「不再打开命令行窗口」,故这一步不可省。
//!
//! 4. **退出监控用轮询 `try_wait`,不把 `Child` 移出去。**
//! 若把 `Child` 交给 `wait()` 的 task,`stop()` 就再也拿不到它去 kill 了。
//! 轮询 800ms 代价可忽略,换来 stop 随时可用。
//!
//! ## 可测试性
//! 本模块**不依赖 Tauri GUI** —— 单测直接拉起真实的 node 子进程,验证
//! 「启动 → 捕获日志 → 停止 → 状态收敛」全链路。
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
/// 日志环形缓冲上限(行)。够回看一次启动/一次故障,又不至于吃内存。
const LOG_CAP: usize = 4000;
/// 被控端状态机取值(前后端共用字面量)。
pub mod state {
pub const STOPPED: &str = "stopped";
pub const STARTING: &str = "starting";
pub const RUNNING: &str = "running";
pub const STOPPING: &str = "stopping";
pub const CRASHED: &str = "crashed";
}
/// 一行日志。
#[derive(Debug, Clone, Serialize)]
pub struct LogLine {
/// 全局单调递增序号(环形缓冲淘汰旧行后仍然递增 —— 前端靠它做增量拉取)
pub seq: u64,
/// 毫秒时间戳(UTC)
pub ts: u64,
/// `stdout` / `stderr` / `system`(本管理器自己产生的说明行)
pub stream: String,
pub line: String,
}
impl LogLine {
fn new(seq: u64, stream: &str, line: impl Into<String>) -> Self {
Self {
seq,
ts: now_ms(),
stream: stream.to_string(),
line: line.into(),
}
}
}
/// 管理器向外推送的事件。
#[derive(Debug, Clone)]
pub enum AgentEvent {
Log(LogLine),
/// 状态变化(取值见 `state::*`)
State(String),
}
pub type AgentEmit = Arc<dyn Fn(AgentEvent) + Send + Sync>;
fn now_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// 数据目录:优先 `NODEAGENT_HOME`(跨平台统一),否则 `~/.nodeagent`。
///
/// ⚠️ Windows 认 `USERPROFILE`、不认 `HOME` —— 与被控端 `apps/agent/src/config.ts`
/// 的 `agentDir()` 必须保持一致,否则「控制台显示的数据目录」与「被控端实际用的」
/// 会是两个地方。
pub fn agent_data_dir() -> PathBuf {
if let Ok(p) = std::env::var("NODEAGENT_HOME") {
if !p.trim().is_empty() {
return PathBuf::from(p);
}
}
let home = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".nodeagent")
}
/// 被控端入口脚本(数据目录里的那一份)。
pub fn agent_script_path(data_dir: &Path) -> PathBuf {
data_dir.join("runtime").join("agent.mjs")
}
/// 播种标记文件。
fn seed_marker_path(data_dir: &Path) -> PathBuf {
data_dir.join("runtime").join("agent.seed.json")
}
/// 比较 `x.y.z` 版本号(数值逐段比,不做字符串比 —— 否则 `2.0.10 < 2.0.9`)。
pub fn cmp_version(a: &str, b: &str) -> i32 {
let pa: Vec<u32> = a.split('.').map(|x| x.parse().unwrap_or(0)).collect();
let pb: Vec<u32> = b.split('.').map(|x| x.parse().unwrap_or(0)).collect();
for i in 0..pa.len().max(pb.len()) {
let d = pa.get(i).copied().unwrap_or(0) as i64 - pb.get(i).copied().unwrap_or(0) as i64;
if d != 0 {
return if d > 0 { 1 } else { -1 };
}
}
0
}
/// 读出「数据目录里当前脚本所对应的版本」(无标记则视为 `0.0.0`)。
pub fn installed_agent_version(data_dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(seed_marker_path(data_dir)).ok()?;
let v: Value = serde_json::from_str(&text).ok()?;
v.get("version").and_then(Value::as_str).map(str::to_string)
}
/// 记录「数据目录里当前脚本的版本」。
///
/// 播种与被控端自更新(**含控制台发起的更新**)都要写它 —— 这是防止
/// 「控制台启动时把已更新的被控端降级回随包版本」的唯一依据。
pub fn mark_installed_version(data_dir: &Path, version: &str, source: &str) -> Result<(), String> {
let marker = seed_marker_path(data_dir);
if let Some(dir) = marker.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("创建 runtime 目录失败:{e}"))?;
}
std::fs::write(
&marker,
format!(
"{{\n \"version\": \"{}\",\n \"source\": \"{}\",\n \"at\": {}\n}}\n",
version,
source,
now_ms()
),
)
.map_err(|e| format!("写入播种标记失败:{e}"))
}
/// 定位随包(或开发期)的内置 `agent.mjs`。
///
/// 优先级:环境变量 `NODEAGENT_CONSOLE_AGENT` → 随包 resource 的 `agent.mjs` →
/// 开发期的 `apps/agent/dist/index.js`。
///
/// ⚠️ 出口一律经 `sidecar::unverbatim`:`resource_dir()` 在 Windows 上给的是
/// verbatim 路径(`\\?\C:\…`)。本函数的结果最终会**交给 node**(播种后作为脚本路径),
/// 带 `\\?\` 的路径会让 node **启动即 EISDIR 秒退** —— 这正是 2026-10-13
/// 「sidecar 秒退」真机缺陷的同类风险,必须在源头堵掉。
pub fn resolve_bundled_agent(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_AGENT") {
if !p.is_empty() && Path::new(&p).exists() {
return Ok(crate::sidecar::unverbatim(PathBuf::from(p)));
}
}
if let Some(dir) = resource_dir {
let bundled = crate::sidecar::unverbatim(dir.join("agent.mjs"));
if bundled.exists() {
return Ok(bundled);
}
}
// 开发期:src-tauri → apps/agent/dist/index.js
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../agent/dist/index.js");
if dev.exists() {
return Ok(dev);
}
Err("找不到内置 agent.mjs(先跑 `pnpm -r build`,或用 NODEAGENT_CONSOLE_AGENT 指定)".to_string())
}
/// 把内置被控端**播种**到数据目录。
///
/// **只在「数据目录里的脚本版本 < 随包版本」时才覆盖** —— 这条规则防的是两类静默故障:
/// - 控制台每次启动都覆盖 → 把**已自更新**的被控端**降级**回随包版本(功能莫名回退,不报错);
/// - 应用被降级安装后,反过来把更新的被控端覆盖掉。
///
/// 返回实际使用的脚本路径。
pub fn seed_agent_script(
bundled: &Path,
data_dir: &Path,
app_version: &str,
) -> Result<PathBuf, String> {
let target = agent_script_path(data_dir);
if target.exists() {
let installed = installed_agent_version(data_dir).unwrap_or_else(|| "0.0.0".to_string());
if cmp_version(app_version, &installed) <= 0 {
// 数据目录里的版本不低于随包版本(可能是自更新来的)→ 不动它
return Ok(target);
}
}
std::fs::create_dir_all(target.parent().unwrap_or(data_dir))
.map_err(|e| format!("创建 runtime 目录失败:{e}"))?;
std::fs::copy(bundled, &target).map_err(|e| {
format!(
"播种被控端脚本失败({} → {}):{e}",
bundled.display(),
target.display()
)
})?;
// 保留可执行位(POSIX;Windows 无此概念)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755));
}
mark_installed_version(data_dir, app_version, "bundled")?;
Ok(target)
}
/// TCP 探测端口是否已被监听(判断「是否已有实例在跑」)。
///
/// 只做一次 TCP 连接,不跑协议 —— 快、无副作用,足够回答「这个端口有没有人在听」。
pub fn port_in_use(port: u16) -> bool {
use std::net::{SocketAddr, TcpStream};
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
TcpStream::connect_timeout(&addr, Duration::from_millis(400)).is_ok()
}
/// 读被控端配置(`<数据目录>/agent.json`)里与控制台有关的部分。
///
/// 只读、不创建 —— 配置由被控端自己在首次启动时生成(含随机密钥)。
pub fn read_agent_config(data_dir: &Path) -> Value {
let p = data_dir.join("agent.json");
let (node_id, port, key, auth_mode, tls, exists) = match std::fs::read_to_string(&p) {
Ok(text) => {
// 剥离 BOM:Windows 上由 PowerShell 写出的 JSON 可能带 BOM
let parsed: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).unwrap_or(json!({}));
(
parsed.get("node_id").and_then(Value::as_str).unwrap_or("").to_string(),
parsed.get("port").and_then(Value::as_u64).unwrap_or(8765),
parsed.get("key").and_then(Value::as_str).unwrap_or("").to_string(),
parsed.get("auth_mode").and_then(Value::as_str).unwrap_or("psk").to_string(),
parsed.get("tls").and_then(Value::as_bool).unwrap_or(true),
true,
)
}
Err(_) => (String::new(), 8765, String::new(), "psk".to_string(), true, false),
};
json!({
"data_dir": data_dir.to_string_lossy(),
"config_path": p.to_string_lossy(),
"config_exists": exists,
"node_id": node_id,
"port": port,
"tls": tls,
"auth_mode": auth_mode,
// 密钥回传给本机 UI 属预期(用户要用它连接自己的机器);
// 它本来就在用户本机的 600 文件里,不构成额外暴露面。
"key": key,
"port_in_use": port_in_use(port as u16),
"url": format!("ws://127.0.0.1:{port}"),
})
}
struct Inner {
state: String,
child: Option<Child>,
pid: Option<u32>,
started_at: Option<u64>,
restarts: u32,
last_exit: Option<String>,
}
/// 本机被控端管理器。
pub struct AgentManager {
inner: tokio::sync::Mutex<Inner>,
/// 环形日志缓冲。用 `Arc<Mutex<..>>` 是为了让日志泵任务能**同时**入缓冲与推事件,
/// 而不必持有整个管理器(否则自引用,Arc 循环)。
logs: Arc<Mutex<VecDeque<LogLine>>>,
seq: Arc<AtomicU64>,
emit: AgentEmit,
/// 脚本与 node 路径(start 时注入,便于 status 展示)
paths: Mutex<Option<(PathBuf, PathBuf, PathBuf)>>, // (node, script, data_dir)
}
impl AgentManager {
pub fn new(emit: AgentEmit) -> Arc<Self> {
Arc::new(Self {
inner: tokio::sync::Mutex::new(Inner {
state: state::STOPPED.to_string(),
child: None,
pid: None,
started_at: None,
restarts: 0,
last_exit: None,
}),
logs: Arc::new(Mutex::new(VecDeque::with_capacity(LOG_CAP))),
seq: Arc::new(AtomicU64::new(1)),
emit,
paths: Mutex::new(None),
})
}
/// 追加一行日志:入环形缓冲 + 推事件。
pub fn push_log(&self, stream: &str, text: impl Into<String>) {
let entry = LogLine::new(self.next_seq(), stream, text);
{
let mut logs = self.logs.lock().unwrap();
if logs.len() >= LOG_CAP {
logs.pop_front();
}
logs.push_back(entry.clone());
}
(self.emit)(AgentEvent::Log(entry));
}
fn next_seq(&self) -> u64 {
self.seq.fetch_add(1, Ordering::SeqCst)
}
async fn set_state(&self, s: &str) {
{
let mut inner = self.inner.lock().await;
if inner.state == s {
return;
}
inner.state = s.to_string();
}
(self.emit)(AgentEvent::State(s.to_string()));
}
/// 当前状态快照(不探测端口,纯读内存 + 已记录的路径)。
pub async fn snapshot(&self) -> Value {
let inner = self.inner.lock().await;
let paths = self.paths.lock().unwrap().clone();
let (node, script, data_dir) = match paths {
Some((n, s, d)) => (
n.to_string_lossy().to_string(),
s.to_string_lossy().to_string(),
d.to_string_lossy().to_string(),
),
None => (String::new(), String::new(), agent_data_dir().to_string_lossy().to_string()),
};
json!({
"state": inner.state,
"pid": inner.pid,
"started_at": inner.started_at,
"uptime_ms": inner.started_at.map(|t| now_ms().saturating_sub(t)),
"restarts": inner.restarts,
"last_exit": inner.last_exit,
"node": node,
"script": script,
"data_dir": data_dir,
})
}
/// 取日志(`since` 为上次拿到的最大 `seq`,0 表示取全部缓冲)。
pub fn logs_since(&self, since: u64) -> Value {
let logs = self.logs.lock().unwrap();
let total = self.seq.load(Ordering::SeqCst).saturating_sub(1);
let lines: Vec<Value> = logs
.iter()
.filter(|l| l.seq > since)
.map(|l| json!({ "seq": l.seq, "ts": l.ts, "stream": l.stream, "line": l.line }))
.collect();
json!({ "lines": lines, "total": total })
}
pub fn clear_logs(&self) {
self.logs.lock().unwrap().clear();
}
/// 启动被控端。
///
/// 接收者写成 `self: &Arc<Self>`(而非 `&self`)是**必需**的:启动后要挂一个
/// **自持引用**的监控任务(崩溃自动重启要能再次调用 `start`),而 `&self` 拿不到 `Arc`。
///
/// `auto_restart`:进程意外退出时是否自动拉起(「后台运行」的语义要求)。
pub async fn start(
self: &Arc<Self>,
node: &Path,
script: &Path,
data_dir: &Path,
auto_restart: bool,
) -> Result<Value, String> {
{
let inner = self.inner.lock().await;
if inner.child.is_some() {
return Err("被控端已在运行".to_string());
}
}
// 端口占用检查:既有的(非本控制台托管的)实例在跑时,给出可执行的说明,
// 而不是让用户看到一个 `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} 已被占用 —— 已有另一个被控端在监听(手工安装的、计划任务拉起的,\
或另一个控制台实例)。\n\
排查占用者:{hint}\n\
处理:先停掉它;或把 {} 里的 port 改成别的端口后重启本机服务。",
data_dir.join("agent.json").display()
));
}
*self.paths.lock().unwrap() = Some((node.to_path_buf(), script.to_path_buf(), data_dir.to_path_buf()));
self.set_state(state::STARTING).await;
self.push_log(
"system",
format!(
"启动被控端:{} {}(数据目录 {})",
node.display(),
script.display(),
data_dir.display()
),
);
// 进程边界:交给 node 的三条路径都先去掉 verbatim 前缀。
// 被控端的脚本路径来自数据目录(本来就是普通路径),但 node 路径来自
// `resource_dir()`(Windows 上是 `\\?\C:\…`)—— 与 2026-10-13「sidecar 秒退」
// 同一类风险,故在此统一兜住,不依赖上游是否记得净化。
let node = crate::sidecar::unverbatim(node.to_path_buf());
let script = crate::sidecar::unverbatim(script.to_path_buf());
let data_dir = crate::sidecar::unverbatim(data_dir.to_path_buf());
let mut cmd = Command::new(&node);
cmd.arg(&script)
.current_dir(&data_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(false); // 由本管理器显式管理生命周期,不留孤儿也不误杀
// 让被控端与控制台用**同一个**数据目录(不设则各按各自规则解析)
cmd.env("NODEAGENT_HOME", &data_dir);
// ⚠️ 消灭黑窗:Windows 上不加这个标志,每 spawn 一次 node 就弹一个控制台窗口
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("启动被控端失败({}):{e}", node.display()))?;
let pid = child.id();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
{
let mut inner = self.inner.lock().await;
inner.child = Some(child);
inner.pid = pid;
inner.started_at = Some(now_ms());
}
self.set_state(state::RUNNING).await;
// 日志泵:既入环形缓冲(供前端「拉取历史」),也推事件(供前端「实时滚动」)
if let Some(out) = stdout {
spawn_line_pump(out, "stdout", Arc::clone(&self.emit), Arc::clone(&self.logs), Arc::clone(&self.seq));
}
if let Some(err) = stderr {
spawn_line_pump(err, "stderr", Arc::clone(&self.emit), Arc::clone(&self.logs), Arc::clone(&self.seq));
}
// 退出监控 + 自动重启
AgentManager::spawn_monitor(
self,
node.to_path_buf(),
script.to_path_buf(),
data_dir.to_path_buf(),
auto_restart,
);
Ok(json!({ "pid": pid, "state": state::RUNNING }))
}
/// 停止被控端(幂等)。
pub async fn stop(&self) -> Result<Value, String> {
self.set_state(state::STOPPING).await;
let child = {
let mut inner = self.inner.lock().await;
inner.child.take()
};
let Some(mut child) = child else {
self.set_state(state::STOPPED).await;
return Ok(json!({ "state": state::STOPPED, "note": "本来就未运行" }));
};
let pid = child.id();
self.push_log("system", format!("停止被控端(pid {})…", pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into())));
// Windows:用 taskkill /T 连子进程树一起收;POSIX:直接 kill 本进程。
let killed = if cfg!(windows) {
match pid {
Some(p) => {
let out = Command::new("taskkill")
.args(["/PID", &p.to_string(), "/T", "/F"])
.output()
.await;
match out {
Ok(o) => o.status.success(),
Err(_) => child.kill().await.is_ok(),
}
}
None => child.kill().await.is_ok(),
}
} else {
child.kill().await.is_ok()
};
let _ = child.wait().await;
{
let mut inner = self.inner.lock().await;
inner.pid = None;
inner.started_at = None;
inner.last_exit = Some(if killed { "stopped by console".into() } else { "stop failed".into() });
}
self.set_state(state::STOPPED).await;
self.push_log("system", "被控端已停止");
// 端口释放确认(如被控端刚触发了「受控重启」,可能留下继承者)
let data_dir = self
.paths
.lock()
.unwrap()
.as_ref()
.map(|(_, _, d)| d.clone())
.unwrap_or_else(agent_data_dir);
let port = read_agent_config(&data_dir).get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
for _ in 0..12 {
if !port_in_use(port) {
break;
}
tokio::time::sleep(Duration::from_millis(400)).await;
}
let still = port_in_use(port);
if still {
self.push_log(
"system",
format!("⚠️ 端口 {port} 仍未释放 —— 可能存在继承进程(如受控重启产生的后继),请检查"),
);
}
Ok(json!({ "state": state::STOPPED, "port_released": !still }))
}
/// 重启 = 停止 + 启动。
pub async fn restart(
self: &Arc<Self>,
node: &Path,
script: &Path,
data_dir: &Path,
auto_restart: bool,
) -> Result<Value, String> {
self.push_log("system", "重启被控端…");
let _ = self.stop().await;
tokio::time::sleep(Duration::from_millis(600)).await;
self.start(node, script, data_dir, auto_restart).await
}
/// 退出监控:轮询 `try_wait`(**不把 Child 移出去**,否则 stop() 就杀不到它了)。
fn spawn_monitor(
me: &Arc<Self>,
node: PathBuf,
script: PathBuf,
data_dir: PathBuf,
auto_restart: bool,
) {
let me = Arc::clone(me);
tauri::async_runtime::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(800)).await;
let mut inner = me.inner.lock().await;
let Some(child) = inner.child.as_mut() else {
return; // 已被 stop() 取走 → 结束监控
};
match child.try_wait() {
Ok(Some(status)) => {
let code = status.code();
inner.child = None;
inner.pid = None;
inner.started_at = None;
inner.last_exit = Some(format!("exit code {:?}", code));
drop(inner);
me.push_log("system", format!("被控端已退出(exit code {:?})", code));
me.set_state(state::CRASHED).await;
if !auto_restart {
return;
}
// 自动重启:退避 2s,超 5 次则放弃并留在 crashed(避免无限重启风暴)
let n = {
let mut inner = me.inner.lock().await;
inner.restarts += 1;
inner.restarts
};
if n > 5 {
me.push_log("system", "自动重启已达上限(5 次),停止拉起。请查看上方日志定位原因。");
return;
}
me.push_log("system", format!("将在 2 秒后自动重启(第 {n} 次)…"));
tokio::time::sleep(Duration::from_secs(2)).await;
let _ = me.stop().await;
if let Err(e) = me.start(&node, &script, &data_dir, true).await {
me.push_log("system", format!("自动重启失败:{e}"));
me.set_state(state::CRASHED).await;
return;
}
// start() 已重新挂上监控,本任务结束
return;
}
Ok(None) => { /* 仍在运行 */ }
Err(e) => {
me.push_log("system", format!("监控被控端失败:{e}"));
return;
}
}
}
});
}
}
/// 逐行读一个管道:入环形缓冲 + 推日志事件。
fn spawn_line_pump<R>(
reader: R,
stream: &'static str,
emit: AgentEmit,
logs: Arc<Mutex<VecDeque<LogLine>>>,
seq: Arc<AtomicU64>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tauri::async_runtime::spawn(async move {
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(text)) = lines.next_line().await {
let entry = LogLine::new(seq.fetch_add(1, Ordering::SeqCst), stream, text);
{
let mut buf = logs.lock().unwrap();
if buf.len() >= LOG_CAP {
buf.pop_front();
}
buf.push_back(entry.clone());
}
emit(AgentEvent::Log(entry));
}
});
}
// ---------------------------------------------------------------------------
// 单测:真跑 node 子进程
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn node_bin() -> PathBuf {
crate::sidecar::resolve_node(None).expect("需要 node 才能跑被控端管理单测")
}
/// 造一个「打印两行、然后常驻」的假被控端。
fn fake_agent(dir: &Path) -> PathBuf {
let p = dir.join("fake-agent.mjs");
std::fs::write(
&p,
"console.log('READY-LINE');\nconsole.error('ERR-LINE');\nsetInterval(()=>{}, 1000);\n",
)
.unwrap();
p
}
fn tmp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("na-agent-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[tokio::test]
async fn 启动应捕获日志且状态为running() {
let dir = tmp_dir("start");
let script = fake_agent(&dir);
let m = AgentManager::new(Arc::new(|_| {}));
m.start(&node_bin(), &script, &dir, false).await.expect("应能启动");
assert_eq!(m.snapshot().await["state"], json!(state::RUNNING));
// 等日志泵读到两行
let mut collected = String::new();
for _ in 0..40 {
tokio::time::sleep(Duration::from_millis(100)).await;
let logs = m.logs_since(0);
collected = logs["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["line"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>()
.join("\n");
if collected.contains("READY-LINE") && collected.contains("ERR-LINE") {
break;
}
}
assert!(collected.contains("READY-LINE"), "应捕获 stdout,实际:{collected}");
assert!(collected.contains("ERR-LINE"), "应捕获 stderr,实际:{collected}");
let r = m.stop().await.expect("应能停止");
assert_eq!(r["state"], json!(state::STOPPED));
assert_eq!(m.snapshot().await["state"], json!(state::STOPPED));
}
#[tokio::test]
async fn 停止后应可再次启动() {
let dir = tmp_dir("restart");
let script = fake_agent(&dir);
let m = AgentManager::new(Arc::new(|_| {}));
m.start(&node_bin(), &script, &dir, false).await.unwrap();
m.stop().await.unwrap();
m.start(&node_bin(), &script, &dir, false).await.expect("停止后应能重新启动");
assert_eq!(m.snapshot().await["state"], json!(state::RUNNING));
m.stop().await.unwrap();
}
#[tokio::test]
async fn 重复启动应被拒绝而不是起两个进程() {
let dir = tmp_dir("dup");
let script = fake_agent(&dir);
let m = AgentManager::new(Arc::new(|_| {}));
m.start(&node_bin(), &script, &dir, false).await.unwrap();
let err = m.start(&node_bin(), &script, &dir, false).await;
assert!(err.is_err(), "已在运行时再次启动应被拒绝");
assert!(err.unwrap_err().contains("已在运行"), "错误信息应说明原因");
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");
let p = dir.join("crash-agent.mjs");
std::fs::write(&p, "console.log('BYE');\nprocess.exit(3);\n").unwrap();
let m = AgentManager::new(Arc::new(|_| {}));
m.start(&node_bin(), &p, &dir, false).await.unwrap();
let mut seen = String::new();
for _ in 0..40 {
tokio::time::sleep(Duration::from_millis(100)).await;
seen = m.snapshot().await["state"].as_str().unwrap_or("").to_string();
if seen == state::CRASHED {
break;
}
}
assert_eq!(seen, state::CRASHED, "进程退出后状态应为 crashed");
assert!(
m.snapshot().await["last_exit"].as_str().unwrap_or("").contains("3"),
"应记录退出码"
);
}
#[test]
fn 播种应只在应用版本变化时覆盖() {
let dir = tmp_dir("seed");
let bundled = dir.join("bundled.mjs");
std::fs::write(&bundled, "// v1\n").unwrap();
let data = dir.join("data");
std::fs::create_dir_all(&data).unwrap();
let t = seed_agent_script(&bundled, &data, "2.0.0").unwrap();
assert!(t.exists());
assert_eq!(std::fs::read_to_string(&t).unwrap(), "// v1\n");
// 模拟「被控端自更新」改了副本
std::fs::write(&t, "// self-updated v9\n").unwrap();
// 同版本再播种 → 不应覆盖
seed_agent_script(&bundled, &data, "2.0.0").unwrap();
assert_eq!(
std::fs::read_to_string(&t).unwrap(),
"// self-updated v9\n",
"同版本播种不得覆盖已自更新的副本(否则是静默降级)"
);
// 应用升级 → 应重新播种
std::fs::write(&bundled, "// v2\n").unwrap();
seed_agent_script(&bundled, &data, "2.1.0").unwrap();
assert_eq!(std::fs::read_to_string(&t).unwrap(), "// v2\n", "应用版本变化后应重新播种");
}
#[test]
fn 播种不得把更新过的被控端降级() {
let dir = tmp_dir("seed-down");
let bundled = dir.join("bundled.mjs");
std::fs::write(&bundled, "// bundled 2.0.3\n").unwrap();
let data = dir.join("data");
std::fs::create_dir_all(&data).unwrap();
// 模拟:被控端已通过控制台更新到 2.1.0
std::fs::create_dir_all(data.join("runtime")).unwrap();
std::fs::write(agent_script_path(&data), "// console-updated 2.1.0\n").unwrap();
mark_installed_version(&data, "2.1.0", "console").unwrap();
seed_agent_script(&bundled, &data, "2.0.3").unwrap();
assert_eq!(
std::fs::read_to_string(agent_script_path(&data)).unwrap(),
"// console-updated 2.1.0\n",
"随包版本更低时**不得**覆盖数据目录里更新的被控端(那是静默降级)"
);
}
#[test]
fn 版本比较应按数值而非字符串() {
assert_eq!(cmp_version("2.0.10", "2.0.9"), 1, "2.0.10 应大于 2.0.9");
assert_eq!(cmp_version("2.0.2", "2.0.2"), 0);
assert_eq!(cmp_version("2.1.0", "2.0.99"), 1);
assert_eq!(cmp_version("2.0", "2.0.1"), -1, "缺段按 0 处理");
}
}