Files
HomeAgent/cmd/gui/sse-backoff.test.mjs
JianFeeeee 1ef1998e72 fix(gui): SSE 退避计数从不重置 —— 消息流不稳的一个共因
## 现象

用户报 GUI「消息流不稳、动画不连贯、看着卡」,四类症状都有:滞后、
卡顿、闪断、资源高。定位到**同一个**共因,不是四个独立问题。

## 根因 1:退避计数只增不减

`state._sseRetryAttempts` 的自增只发生在 `connectFetchSSE` 的 catch 分支
(连接**建立**失败),而 `pump()` 中途断流后的重连**只读它算延迟,
从不清零**:

    Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000)

后果:只要历史上累计过 5 次,**之后每次断连都固定等 32s** —— 哪怕这次刚
成功连上、说明服务端和网络都好好的。而"成功连上"恰恰是最该重置的信号。

修:建连成功后清零(reader 取流之前),断流重连前再清一次。

## 根因 2:外层 60000 上限是死代码

`2^5 = 32s < 60s` ⇒ `Math.min(..., 60000)` 永远达不到,**真实封顶是 32s**。
改为 32000,让声明值与实际一致(判据会验这一条)。

catch 分支那处保留 60000:它语义不同(连接根本没建起来,attempts 已 +1,
退避本就该更长),上限放宽无害 —— 判据按各自语义分别判定,不一刀切。

## 根因 3:401 重试固定空等 800ms

`api()` 里每个 401 都走 `syncConnAuth()` + 固定 `setTimeout(800)` 才重试。
认证过期时**每个**请求白等 0.8s,并发几个就叠加成明显的「卡」。
`syncConnAuth` 本身就是 await 的,返回即代表凭据就绪 ⇒ 降到 30ms
(留一点让 setAuth 的 cookie 落盘)。

真 401 与「门户返回 200 但内容是登录页」两个分支都有这处等待,两处都改。

## 判据:cmd/gui/sse-backoff.test.mjs

`cmd/gui` 无测试框架(package.json 只有 start/dev),app.js 是 203KB 单文件。
判据从**真实源码**提取退避表达式并求值,而不是抄一份逻辑重写 ——
抄写的那份会和真实代码漂移,而漂移本身就是这个判据要防的东西。

3 项 + 变异测试(删清零 / 改回 60000 / 改回 800ms,三次全部被抓到)。

### 判据本身踩的三个坑(都记在文件注释里)

1. **正则两种形态括号数不同**:`Math.pow(2, x)` 比 `2 ** x` 多一层括号。
   早先只按 pow 写,biome 规范化成 `**` 后**静默失配**。
2. **不能用 String.raw 拼接正则**:`\\.` 保持双反斜杠字面量(去找字面的
   "\."),且拼接后**捕获组编号不可控** —— 实测 cap 解出 NaN。
3. **一个正则兼容两种形态会取错捕获组**(组数差 1)⇒ 改为
   **定位与取值分离**:正则只负责定位(不捕获数字),数字单独取。

### 只认 pump 那处上限自洽

catch 那处 `60000` 不可达但无害(语义是"最多等一分钟")。
判据对两处**分别**判:pump 要自洽,catch 只要有有限上限。

## 排查时坐实的两件事

- **`go test ./cmd/gui` 报 `[setup failed]` 不是仓库问题**:`cmd/gui` 有
  **0 个 `.go` 文件**(纯 Electron),Go 通配会跳过它,只有显式点名才报。
  `go test ./...` 实测退出码 0、43 包 ok、0 处提及 cmd/gui。
- **biome 会顺手把 `function () {}` 改成箭头函数**(本次混入 12 行)。
  与本次修复无关,已从 HEAD 干净重放,最终 diff 只有 3 处实质改动
  (24 增 3 删,零无关格式化)。
2026-09-28 07:59:15 +08:00

149 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SSE 重连退避的判据。
//
// ## 要判的是什么
//
// 2026-09-27 定位到 GUI「消息流不稳、看着卡」的一个共因:
// `state._sseRetryAttempts` **只增不减,从不重置**。
//
// 它的自增只发生在 `connectFetchSSE` 的 catch 分支(连接**建立**失败),
// 而 `pump()` 里 stream 正常读完/断开后的重连**只读它算延迟,却从不清零**:
//
// reconnectTimer = setTimeout(() => { connectSSE(); },
// Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000));
//
// 后果:只要历史上累计过 5 次失败,**之后每次断连都固定等 32s**,
// 无论中间成功连上过没有。而"成功连上"恰恰说明网络/服务端已恢复 ——
// 那时还按历史累计退避,就是纯粹的空等。
//
// 这解释了用户报告的全部四类症状(滞后/卡顿/闪断/看着不稳),
// 它们不是四个独立问题,而是同一条链。
//
// ## 为什么用「源码文本」做判据
//
// cmd/gui 无测试框架、无构建校验(package.json 只有 start/dev),
// app.js 是 203KB 单文件。判据从**真实源码**里提取退避表达式并求值,
// 而不是抄一份逻辑重写 —— 抄写的那份会和真实代码漂移,
// 而漂移本身就是这个判据要防的东西。
//
// 运行:node cmd/gui/sse-backoff.test.mjs
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, "renderer/app.js"), "utf8");
let failures = 0;
function check(name, ok, detail) {
if (ok) {
console.log(` ✓ ${name}`);
} else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
}
// ── 1) 退避必须被重置 ──────────────────────────────────────────────
//
// 判据是「成功建立连接后有清零动作」。取连接成功之后的那段代码
// (resp.ok && resp.body 之后、读流之前)作为观察窗口。
const okWindow = src.slice(
src.indexOf("if (!resp.ok || !resp.body)"),
src.indexOf("var reader = resp.body.getReader()")
);
check(
"连接成功后重置 _sseRetryAttempts",
/_sseRetryAttempts\s*=\s*0/.test(okWindow),
"成功建连后没有清零 ⇒ 断连重连会一直按历史累计退避(封顶 32s)"
);
// ── 2) 退避上限是 32s 而不是 60s ──────────────────────────────────
//
// 代码里写的是 Math.min(..., 60000),但指数被 Math.min(attempts, 5) 夹住,
// 2^5 = 32s < 60s ⇒ **60s 那个上限永远达不到**。
// 判据把两条 clamp 都提取出来算一遍,而不是相信注释或字面量。
// 退避有**两处**,自变量不同,一处都不能漏:
// · pump() 断流重连 → Math.min(…, (state._sseRetryAttempts || 0), 5), <ceil>)
// · catch 建连失败 → Math.min(…, attempts - 1, 5), <ceil>)
// 早先只认第一处,于是 catch 那处(上限仍是 60000)根本不在检查范围内。
//
// ★ 正则写成**字面量**,不用字符串拼接:
// `String.raw` 拼接正则时,`\s` 保持双反斜杠字面量 ⇒ 去找字面的 "\s" 而非空白,
// 静默失配。逐步 add 写法反而更脆。字面量能被编辑器/linter 检查。
//
// 两种幂运算形态都认:Math.pow(2, x) 与 2 ** x(biome 会规范化前者)。
// ★ 这里刻意**不**用"一个大正则兼容两种形态"——
// 两种形态的捕获组数不同(pow 多一层括号 ⇒ 组数差 1),
// 实测按 r[length-1] 取值会把 cap 解成 NaN。
//
// 改为**定位与取值分离**:
// · 正则只负责找到那一处退避表达式(不捕获数字)
// · 数字用两次 replace 从命中的子串里取
// 两者各自简单,且加一种新形态时只需改正则的"外形",不用动取值逻辑。
const SHAPE_PUMP = /Math\.min\(\s*1000\s*\*\s*(?:Math\.pow\(\s*2\s*,|2\s*\*\*)\s*Math\.min\([\s\S]{0,80}?_sseRetryAttempts[\s\S]{0,40}?,\s*(\d+)\s*\)\s*\)?\s*,\s*(\d+)\s*\)/;
const SHAPE_CATCH = /Math\.min\(\s*1000\s*\*\s*(?:Math\.pow\(\s*2\s*,|2\s*\*\*)\s*Math\.min\(\s*attempts\s*-\s*1\s*,\s*(\d+)\s*\)\s*\)?\s*,\s*(\d+)\s*\)/;
const hits = [
["pump 断流重连", SHAPE_PUMP.exec(src)],
["catch 建连失败", SHAPE_CATCH.exec(src)],
];
const m = hits.find(([, r]) => r);
if (!m) {
check("能提取退避表达式", false, "两处退避都没匹配到(正则或代码形态变了)");
} else {
// 两处退避的**语义不同**,不能用同一把尺子量:
//
// · pump 断流重连 —— 刚成功建连过(上面已清零),退避从 1s 起步。
// 上限写 32000(= 2^5,实测封顶就是 32s,一致)。
//
// · catch 建连失败 —— 连接都没建起来,attempts 已 +1,退避本就该更长。
// 它的 60000 达不到(封顶 32s),但**无害**:意图是"最多等一分钟",
// 写 60000 只是把上限放得比实际封顶更宽,不影响任何一次重连的时刻。
//
// ⇒ 判据只要求「pump 那处上限自洽」;catch 那处只要求「有上限、不是无限重连」。
const [, pumpHit] = hits[0];
if (pumpHit) {
const cap = Number(pumpHit[1]);
const ceilMs = Number(pumpHit[2]);
const realCeil = Math.min(1000 * 2 ** cap, ceilMs);
console.log(` · pump 断流重连: cap=${cap} → 封顶 ${realCeil / 1000}s;声明上限 ${ceilMs / 1000}s`);
check(
"pump 退避上限自洽(非死代码)",
realCeil === ceilMs,
`实际封顶 ${realCeil / 1000}s,声明 ${ceilMs}ms ⇒ 声明值不可达`,
);
}
const [, catchHit] = hits[1];
if (catchHit) {
const cap = Number(catchHit[1]);
const ceilMs = Number(catchHit[2]);
const realCeil = Math.min(1000 * 2 ** cap, ceilMs);
console.log(` · catch 建连失败: cap=${cap} → 封顶 ${realCeil / 1000}s;声明上限 ${ceilMs / 1000}s`);
check(
"catch 退避有有限上限(不会无限重连)",
Number.isFinite(realCeil) && realCeil <= 60000,
`封顶 ${realCeil}ms,超出预期`,
);
}
}
// ── 3) 401 重试不该有固定长阻塞 ────────────────────────────────────
//
// api() 的 401 分支里有一句固定的 setTimeout(800)。认证过期时
// **每个**请求都白等 0.8s;并发几个请求就叠加成明显的「卡」。
// 判据:401 分支里那个 setTimeout 的时长不得超过 100ms。
const apiStart = src.indexOf("async function api(p, o)");
const apiEnd = src.indexOf("\nasync function", apiStart + 10);
const apiBody = apiStart > 0 ? src.slice(apiStart, apiEnd > 0 ? apiEnd : apiStart + 4000) : "";
const w401 = apiBody.match(/r\.status === 401[\s\S]{0,600}?setTimeout\(\s*(?:res2\s*,\s*)?(\d+)\s*\)/);
if (!w401) {
check("找到 401 分支的退避时长", false, "未匹配到 401 分支里的 setTimeout");
} else {
const wait = Number(w401[1]);
console.log(` · 401 重试固定等待 ${wait}ms`);
check("401 重试无长固定阻塞", wait <= 100, `固定等 ${wait}ms,认证过期时每个请求都白等这么久`);
}
console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`);
process.exit(failures === 0 ? 0 : 1);