fix(console): 状态永远停在「启动中」—— refresh 被 catch 跳过

## 真机症状
被控端明明在跑(点「启动」后端答「**被控端已在运行**」、端口在听、验收 15/15 全通),
界面却一直显示「启动中」,「停止 / 重启」永久灰着 —— 用户完全无法操作。

## 根因(两条更新通道同时失效)
1. `run()` 把 `await refresh()` 写在 **try 里**:
   ```ts
   try { const r = await fn(); await refresh(); return r; }
   catch (e) { ... }        // ← 动作一抛错,refresh 就被跳过
   finally { busy = false }
   ```
   那一次 `start()` 抛了「已在运行」,于是 refresh 被跳过,界面停在旧值。
2. `na-agent-state` 推送在 **WebView 就绪前**发出,前端 `bind()` 的监听还没挂上 → 事件丢失。
3. `LocalServiceView` 的 `onMounted` 只调 `agent.bind()`,而 `bind()` 有 `bound`
   守卫 —— 第二次进页面直接 return,**不会刷新**。所以切页也救不回来。

后端状态其实是对的(`inner.state` 已是 running),纯前端未同步。

## 修法
- `run()`:`refresh()` 移入 **finally** —— 成败都同步状态。
  `refresh()` 自身已有 try/catch,不会掩盖原始错误。
- `LocalServiceView.onMounted`:显式再 `await agent.refresh()`,
  绕过 `bind()` 的 bound 守卫,每次进页面都拉一次(最便宜的状态自愈)。
- `snapshot()`:加状态自愈兜底 —— `STARTING` 且已有 PID 且超 2 秒,就按客观事实
  校正为 `RUNNING`(拿得到 PID 是进程已起的硬证据);`STOPPING` 且无 PID 则校正为 `STOPPED`。

## 自愈的三条单测(含反向守卫)
- 已起 + 超 2 秒 → 必须 `running`
- 刚起(<2 秒)→ **不得**提前自愈(否则真实启动失败会被伪装成运行中)
- 无 PID → 不得自愈(进程根本没起来,不能装成 running)

验证:`cargo build` / `cargo test --lib`(17 项全绿)/ `vue-tsc --noEmit` 均通过。
This commit is contained in:
jkooe committed 2026-10-17 21:09:13 +08:00
1 parent 704fb8cfc9
commit 00659165b1
3 files changed
+94 -2

No files matched your search

+79 -1
View File
@@ -443,7 +443,32 @@ impl AgentManager {
/// 当前状态快照(不探测端口,纯读内存 + 已记录的路径)。
pub async fn snapshot(&self) -> Value {
let inner = self.inner.lock().await;
let mut inner = self.inner.lock().await;
// ── 状态自愈(2026-10-17 真机缺陷)────────────────────────────
// 真机现象:被控端明明已在监听、验收全通,界面却一直停在「启动中」,
// 「停止 / 重启」按钮因此永久禁用(它们依赖 running),用户无从操作。
//
// 成因:`start()` 里 `set_state(STARTING)` 与 `set_state(RUNNING)` 之间
// 有多处 `.await`(拿锁、spawn、日志泵),并发调用或事件丢失会让
// 状态停在中间态。与其继续猜是哪一次 `.await` 被吞掉,
// **不如按客观事实校正**:只要已经拿到 PID、且距启动时刻超过 2 秒,
// 它就不可能还在「启动中」—— 这是进程已经起来的硬证据。
//
// 为什么用 2 秒而不是立即:给正常的 set_state(RUNNING) 留出执行窗口,
// 避免和它抢(正常路径仍由 start() 自己置位)。
if inner.state == state::STARTING {
if let (Some(t), Some(_)) = (inner.started_at, inner.pid) {
if now_ms().saturating_sub(t) > 2_000 {
inner.state = state::RUNNING.to_string();
}
}
}
// 反向同理:STOPPING 但已无 pid,说明停完了却没收尾
if inner.state == state::STOPPING && inner.pid.is_none() {
inner.state = state::STOPPED.to_string();
}
let paths = self.paths.lock().unwrap().clone();
let (node, script, data_dir) = match paths {
Some((n, s, d)) => (
@@ -884,6 +909,59 @@ mod tests {
d
}
/// 状态自愈:真机上曾出现「进程已起、界面永远显示启动中」,
/// 导致停止/重启按钮永久禁用。这条钉住校正逻辑,防止被"顺手简化"掉。
#[tokio::test]
async fn 启动中但已有pid且超两秒应自愈为running() {
let m = AgentManager::new(Arc::new(|_| {}));
{
let mut inner = m.inner.lock().await;
inner.state = state::STARTING.to_string();
inner.pid = Some(12345);
inner.started_at = Some(now_ms().saturating_sub(5_000)); // 5 秒前
}
assert_eq!(
m.snapshot().await["state"],
json!(state::RUNNING),
"拿得到 PID 且已过 2 秒,就不该还停在启动中"
);
}
/// 反向守卫:刚启动(<2 秒)时**不要**抢在正常路径前面置位,
/// 否则真实启动失败会被掩盖成「运行中」。
#[tokio::test]
async fn 刚启动不足两秒不得提前自愈() {
let m = AgentManager::new(Arc::new(|_| {}));
{
let mut inner = m.inner.lock().await;
inner.state = state::STARTING.to_string();
inner.pid = Some(12345);
inner.started_at = Some(now_ms()); // 刚刚
}
assert_eq!(
m.snapshot().await["state"],
json!(state::STARTING),
"刚启动的窗口期内应由 start() 自己置位,自愈不得抢先"
);
}
/// 没有 PID 的「启动中」不满足自愈条件(进程根本没起来)。
#[tokio::test]
async fn 启动中但无pid不得自愈() {
let m = AgentManager::new(Arc::new(|_| {}));
{
let mut inner = m.inner.lock().await;
inner.state = state::STARTING.to_string();
inner.pid = None;
inner.started_at = Some(now_ms().saturating_sub(9_000));
}
assert_eq!(
m.snapshot().await["state"],
json!(state::STARTING),
"没有 PID 说明没起来,不能伪装成 running"
);
}
#[tokio::test]
async fn 启动应捕获日志且状态为running() {
let dir = tmp_dir("start");
+10 -1
View File
@@ -58,12 +58,21 @@ export const useAgentStore = defineStore("agent", () => {
error.value = null;
try {
const r = await fn();
await refresh();
return r;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
// ⚠️ 刷新必须放在 finally(2026-10-17 真机缺陷):
// 原来 `await refresh()` 写在 try 里,一旦动作**抛错**就被跳过,
// 界面状态会永远停在动作发生前的旧值。
//
// 真机症状:点「启动」→ 后端答「被控端已在运行」(说明它其实是 running)
// → 界面却一直显示「启动中」,「停止 / 重启」永久灰着,用户无从操作。
// 根因就是那次 start 抛了错,refresh 被跳过;而 `na-agent-state` 推送
// 在 WebView 就绪前发出,前端没收到 —— 两条更新通道同时失效。
// refresh() 自身已 try/catch,放这里不会掩盖原始错误。
await refresh();
busy.value = false;
}
}
@@ -106,6 +106,11 @@ async function doConnectLocal() {
onMounted(async () => {
await agent.bind();
// `bind()` 内部有 `bound` 守卫:第二次进入本页会直接 return,**不会刷新**。
// 而 `na-agent-state` 推送可能在前端监听就绪前就发出(WebView 尚未 ready),
// 于是状态会一直停在旧值(真机症状:后端已是 running,界面永远「启动中」)。
// 故每次进入本页都显式拉一次 —— 这是最便宜的状态自愈。
await agent.refresh();
await logs.bind();
});