diff --git a/apps/console/src-tauri/src/agent.rs b/apps/console/src-tauri/src/agent.rs index 7cf7222..1a99009 100644 --- a/apps/console/src-tauri/src/agent.rs +++ b/apps/console/src-tauri/src/agent.rs @@ -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"); diff --git a/apps/console/src/stores/agent.ts b/apps/console/src/stores/agent.ts index fda3454..3a63616 100644 --- a/apps/console/src/stores/agent.ts +++ b/apps/console/src/stores/agent.ts @@ -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; } } diff --git a/apps/console/src/views/LocalServiceView.vue b/apps/console/src/views/LocalServiceView.vue index d831960..ecd26f0 100644 --- a/apps/console/src/views/LocalServiceView.vue +++ b/apps/console/src/views/LocalServiceView.vue @@ -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(); });