From ee5cb75e00736b6da6c23737d58c447cdc715969 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 09:11:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20401=20=E9=87=8D=E8=AF=95=E6=97=A0?= =?UTF-8?q?=E9=99=90=E9=80=92=E5=BD=92=20=E2=80=94=E2=80=94=20=E6=88=91?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E4=B8=AA=E4=BC=98=E5=8C=96=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E7=9A=84=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 真机实测发现的 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` 全绿。 --- cmd/gui/renderer/app.js | 27 ++++- cmd/gui/retry-guard.test.mjs | 195 +++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 cmd/gui/retry-guard.test.mjs diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index c27a86d..9de7d13 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -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) { diff --git a/cmd/gui/retry-guard.test.mjs b/cmd/gui/retry-guard.test.mjs new file mode 100644 index 0000000..f28facb --- /dev/null +++ b/cmd/gui/retry-guard.test.mjs @@ -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);