mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
fix(gui): 401 重试无限递归 —— 我上一个优化放大的 bug
## 真机实测发现的
Xvfb + Electron + CDP 真跑,发现 `api()` 的 401 分支**无限自我递归**:
第1次: lock=false → 重登 → lock=false → return api() ← 递归
第2次: lock=false → 重登 → lock=false → return api() ← 又递归
…
那把锁的语义本该是「已经重登过一次,别再登」,但它在递归**之前**就被
清掉了 ⇒ 每层递归看到的都是 `false`。真机实测 **fetch 被调 13 次、
重登 12 次**才被我的探针上限截断。
## 为什么这与我的上一个提交直接相关
`1ef1998` 把 401 分支的固定等待从 800ms 降到 30ms(修「认证过期时每个
请求白等 0.8s」)。但重试**没有次数上限** ⇒
- 改前:每 800ms 慢速空转
- 改后:每 30ms 快速烧 CPU + 反复打服务端
**我的优化把这个 bug 放大了。** 真机上探针调用 `api()` 直接挂死,
我起初还以为是我的测试写法问题。
## 修法
两处递归点(真 401 / 门户返回 200 但内容是登录页)都改成:
try {
return await api(p, o);
} finally {
window._haReloginLock = false;
}
`finally` 保证递归抛错时也释放锁 —— 否则会把后续所有请求都锁死成直接 401。
## 判据 retry-guard.test.mjs
在 `node:vm` 沙箱里跑**真实抽出的 `api()`**,用恒回 401 的 `fetch` 驱动,
统计真实调用次数。
### 写这条判据时踩的四个坑(都记在文件里)
1. **先给了假绿灯**:把 401 分支当独立函数体执行,但那块以
`return api(p,o)` 结尾、外面没有调用它的上下文 ⇒ 我从未真正进入那个
`if` ⇒ `api 递归=0` ⇒ 什么都没测到。改成跑**真实 api()** 才对。
2. **递归时忘了保持 `r.status===401`**:真实场景是「重登后凭据仍是错的」。
漏了它 ⇒ 递归那层不进 401 分支 ⇒ 又一次假绿灯。
3. **沙箱 `setTimeout` 只记录不执行**:`api()` 用它做超时控制
(`setTimeout(() => ctl.abort(), to)`)⇒ AbortController 永不被 abort
⇒ 表现为「fetch 只调 1 次、8000ms 被当成 401 等待」。
4. **沙箱缺 `clearTimeout`** ⇒ 抛 `clearTimeout is not defined` ⇒
整段在 fetch 之后就断了 ⇒ 永远走不到 401 分支。
★ 共同点:**沙箱不完整 ⇒ 静默地什么都没测 ⇒ 假绿灯**。
判据自己给假绿灯比没有判据更危险。
### 变异测试(精确锚点,验证判据真能抓)
把第一处 try/finally 退回「递归前清锁」⇒ 判据立刻红
(`fetch 被调 13 次`);恢复后全绿。
★ 第一次做这个变异时我误判「判据漏抓」—— 实际是我的变异脚本用了模糊
锚点、**压根没改到文件**(`grep` 显示 return await 从 2 变 1,但 `sed`
命中的是另一处)。两个信号矛盾时先坐实文件状态,别急着改判据。
## 顺带把 `make test` 的门禁修好(9c3d285 / f59a3cf 之外)
新判据已接入 `npm test`,实测 14 项通过、`make test-gui` 全绿。
This commit is contained in:
@ -536,9 +536,22 @@ async function api(p, o) {
|
||||
// 留 30ms 让 setAuth 的 cookie 落盘,避免极端情况下仍用旧凭据。
|
||||
await new Promise((res2) => setTimeout(res2, 30));
|
||||
} catch (e2) {}
|
||||
window._haReloginLock = false;
|
||||
// 重试一次
|
||||
return api(p, o);
|
||||
// ★★ 锁必须在**递归返回之后**才释放。
|
||||
//
|
||||
// 原写法在递归前 `window._haReloginLock = false` ⇒ 每层递归进来看到的
|
||||
// 都是「没人在重登」⇒ 无限自我递归。真机实测(Electron + CDP):
|
||||
// fetch 被调 13 次、重登 12 次才被上限截断。
|
||||
//
|
||||
// 而且这与「800ms → 30ms」那次优化直接相关:原来只是每 800ms 慢速空转,
|
||||
// 改完变成每 30ms 快速烧 CPU 并反复打服务端 —— **优化放大了这个 bug**。
|
||||
//
|
||||
// try/finally 保证:递归正常返回后锁才释放(下次真实 401 仍可重登),
|
||||
// 递归抛错时也一定释放(不会把后续所有请求都锁死成直接 401)。
|
||||
try {
|
||||
return await api(p, o);
|
||||
} finally {
|
||||
window._haReloginLock = false;
|
||||
}
|
||||
}
|
||||
if (r.status === 408 || r.status === 504) {
|
||||
// 网关超时:API 层直接抛错,调用方可选择提示用户重试或自动降级
|
||||
@ -572,8 +585,12 @@ async function api(p, o) {
|
||||
// 留 30ms 让 setAuth 的 cookie 落盘,避免极端情况下仍用旧凭据。
|
||||
await new Promise((res2) => setTimeout(res2, 30));
|
||||
} catch (e2) {}
|
||||
window._haReloginLock = false;
|
||||
return api(p, o);
|
||||
// ★★ 同上:锁在递归返回后才释放(递归前清锁 = 无限重试)
|
||||
try {
|
||||
return await api(p, o);
|
||||
} finally {
|
||||
window._haReloginLock = false;
|
||||
}
|
||||
}
|
||||
// 非 2xx 状态码:解包 server error + 以 ApiError 抛出,调用方按 status 分支处理
|
||||
if (!r.ok) {
|
||||
|
||||
195
cmd/gui/retry-guard.test.mjs
Normal file
195
cmd/gui/retry-guard.test.mjs
Normal file
@ -0,0 +1,195 @@
|
||||
// 401 恢复路径的**行为**判据 —— 抓「无限重试」这个真缺陷。
|
||||
//
|
||||
// ## 要判的是什么
|
||||
//
|
||||
// 2026-09-28 真机实测(Xvfb + Electron + CDP)时发现:
|
||||
//
|
||||
// api() 的 401 分支:每次重试前都把 _haReloginLock 设回 false
|
||||
// ⇒ 那把锁**永远拦不住自我递归**
|
||||
//
|
||||
// 第1次: lock=false → 重登 → lock=false → return api() ← 递归
|
||||
// 第2次: lock=false → 重登 → lock=false → return api() ← 又递归
|
||||
// …
|
||||
//
|
||||
// 锁的语义本该是「已经重登过一次,别再登」。但它在递归**之前**就被清掉了,
|
||||
// 于是每层递归看到的都是 false。
|
||||
//
|
||||
// 后果与我的另一处改动直接相关:401 分支的固定等待从 800ms 降到 30ms
|
||||
// (commit 1ef1998,那是「认证过期时每个请求白等 0.8s」的修复)。
|
||||
// 但重试**没有次数上限** ⇒ 改前是「每 800ms 慢速空转」,
|
||||
// 改后变成「每 30ms 快速烧 CPU + 反复打服务端」。**我的优化放大成了 bug。**
|
||||
//
|
||||
// 真机上表现为:探针调用 api() 后永不返回(我实测时探针直接挂死,
|
||||
// 得靠封掉第二次 fetch 才能测出耗时)。
|
||||
//
|
||||
// ## 为什么用「真实执行」而不是检查文本
|
||||
//
|
||||
// 判据在 node:vm 沙箱里跑**从源码抽出的** 401 分支代码,
|
||||
// 验证真实调用次数,而不是 grep `_haReloginLock` 出现过几次。
|
||||
//
|
||||
// 运行:node cmd/gui/retry-guard.test.mjs
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, "renderer/app.js"), "utf8");
|
||||
|
||||
let failures = 0;
|
||||
const check = (name, ok, detail) => {
|
||||
if (ok) console.log(` ✓ ${name}`);
|
||||
else {
|
||||
failures++;
|
||||
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 抽取 401 分支的真实代码 ─────────────────────────────────────────
|
||||
// 取 `if (r.status === 401) { ... }` 那一整块(含嵌套的 return api())
|
||||
const start401 = src.indexOf("if (r.status === 401) {");
|
||||
if (start401 < 0) {
|
||||
console.log(" ✗ 源码里找不到 401 分支");
|
||||
process.exit(1);
|
||||
}
|
||||
// 用括号配平取整块
|
||||
let depth = 0, end401 = -1;
|
||||
for (let i = src.indexOf("{", start401); i < src.length; i++) {
|
||||
if (src[i] === "{") depth++;
|
||||
else if (src[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end401 = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const block401 = src.slice(start401, end401);
|
||||
check("能抽出 401 分支整块", true);
|
||||
console.log(` · 抽取长度 ${block401.length} 字符`);
|
||||
|
||||
// ── 在沙箱里真跑 ──────────────────────────────────────────────────
|
||||
//
|
||||
// ★ 修一次假绿灯:先前我把 401 分支整块当成**独立函数体**执行,
|
||||
// 但那块以 `return api(p, o)` 结尾、外面没有调用它的上下文
|
||||
// ⇒ 我从未真正「进入」那个 if ⇒ api 递归=0 ⇒ 判据一路绿灯,
|
||||
// 压根没测到无限重试。
|
||||
//
|
||||
// 正确做法:造一个**会返回 401 的 fetch**,让真实的 api() 走完整路径。
|
||||
// 沙箱提供:state / window / fetch(恒 401) / syncConnAuth / setTimeout /
|
||||
// ApiError / __ / performance。
|
||||
let reloginCalls = 0;
|
||||
let fetchCalls = 0;
|
||||
const waitLog = [];
|
||||
const MAX_FETCH = 12; // 超过就判定为「无上限」
|
||||
|
||||
const sandbox = {
|
||||
state: { currentConn: { apiKey: "wrong" } },
|
||||
window: { _haReloginLock: false },
|
||||
__: (zh) => zh,
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(msg, status) {
|
||||
super(msg);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
// ★ setTimeout 必须**执行回调**:api() 用它做超时控制
|
||||
// (setTimeout(() => ctl.abort(), to))。先前只记录不执行 ⇒
|
||||
// AbortController 永远不被 abort、异常清理路径走不到,
|
||||
// 表现为「fetch 只调 1 次、8000ms 被当成 401 等待」。
|
||||
// ⇒ 判据测的根本不是 401 路径。
|
||||
setTimeout: (fn, ms) => {
|
||||
// 只把**小延迟**记进 waitLog:8000ms 那个是 api() 的整体超时控制,
|
||||
// 与 401 恢复无关,混进来会让判据误判。
|
||||
if (ms <= 100) waitLog.push(ms);
|
||||
if (typeof fn === "function") {
|
||||
// 不真等 8 秒:微任务里立刻执行,等价于「超时立刻触发」
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
fn();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
syncConnAuth: () => {
|
||||
reloginCalls++;
|
||||
return Promise.resolve();
|
||||
},
|
||||
performance: { now: () => Date.now() },
|
||||
// ★ clearTimeout 也必须提供:api() 在 fetch 之后调它。
|
||||
// 缺了会抛 "clearTimeout is not defined" ⇒ 整段在 fetch 之后就断了
|
||||
// ⇒ 永远走不到 401 分支 ⇒ 判据静默地什么都没测。
|
||||
// 这就是「沙箱不完整 ⇒ 假绿灯」的又一处。
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
AbortController: class {
|
||||
constructor() {
|
||||
this.signal = {};
|
||||
}
|
||||
abort() {}
|
||||
},
|
||||
// 恒回 401 的 fetch —— 真实场景:重登后凭据仍是错的
|
||||
fetch: () => {
|
||||
fetchCalls++;
|
||||
if (fetchCalls > MAX_FETCH) {
|
||||
return Promise.reject(new Error(`NO-RETRY-LIMIT: fetch 被调 ${fetchCalls} 次`));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { get: () => null },
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
// 抽出**真实的 api 函数**(不是 401 分支),让它自己走到那个 if
|
||||
const apiStart = src.indexOf("async function api(p, o) {");
|
||||
let ad = 0, ae = -1;
|
||||
for (let i = src.indexOf("{", apiStart); i < src.length; i++) {
|
||||
if (src[i] === "{") ad++;
|
||||
else if (src[i] === "}") {
|
||||
ad--;
|
||||
if (ad === 0) {
|
||||
ae = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const apiSrc = src.slice(apiStart, ae);
|
||||
check("能抽出真实 api() 函数", apiSrc.includes("r.status === 401"), "抽到的函数里没有 401 分支");
|
||||
|
||||
await vm
|
||||
.runInNewContext(`(async () => { ${apiSrc}; return await api("/status", {}); })()`, sandbox, { timeout: 3000 })
|
||||
.catch((e) => {
|
||||
if (String(e.message).startsWith("NO-RETRY-LIMIT")) fetchCalls = MAX_FETCH + 1;
|
||||
});
|
||||
|
||||
// ── 判据 ──────────────────────────────────────────────────────────
|
||||
|
||||
// ① 无限重试:重试必须有次数上限
|
||||
console.log(` · fetch 被调 ${fetchCalls} 次,重登 ${reloginCalls} 次`);
|
||||
check(
|
||||
"401 重试有次数上限(不会无限递归)",
|
||||
fetchCalls <= 2,
|
||||
`fetch 被调 ${fetchCalls} 次 ⇒ 每轮 401 都重新登录并重试,没有上限。` +
|
||||
`改前是 800ms/轮慢速空转,改后(30ms)变成快速烧 CPU + 反复打服务端。`,
|
||||
);
|
||||
|
||||
// ② 每次重试的固定等待不能太长(那是 1ef1998 的修复,别回退)
|
||||
const waits = waitLog.filter((n) => typeof n === "number");
|
||||
if (waits.length === 0) {
|
||||
check("能观察到 401 分支的固定等待", false, "没抓到 setTimeout —— 分支形态可能变了");
|
||||
} else {
|
||||
const maxWait = Math.max(...waits);
|
||||
console.log(` · 观察到的固定等待: ${waits.join(", ")}ms`);
|
||||
check("401 恢复无长固定阻塞", maxWait <= 100, `最大等 ${maxWait}ms,超过 100ms`);
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user