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:
1 parent
704fb8cfc9
commit
00659165b1
3 files changed
+94
-2
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user