mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
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 删,零无关格式化)。
This commit is contained in:
@ -530,7 +530,11 @@ async function api(p, o) {
|
||||
window._haReloginLock = true;
|
||||
try {
|
||||
await syncConnAuth();
|
||||
await new Promise((res2) => setTimeout(res2, 800));
|
||||
// ★ 原来固定 setTimeout(…, 800):认证过期时**每个**请求都白等 0.8s,
|
||||
// 并发几个请求就叠加成明显的「卡」。syncConnAuth 本身是 await 的,
|
||||
// 它返回即代表凭据已就绪,无需再额外空等。
|
||||
// 留 30ms 让 setAuth 的 cookie 落盘,避免极端情况下仍用旧凭据。
|
||||
await new Promise((res2) => setTimeout(res2, 30));
|
||||
} catch (e2) {}
|
||||
window._haReloginLock = false;
|
||||
// 重试一次
|
||||
@ -562,7 +566,11 @@ async function api(p, o) {
|
||||
window._haReloginLock = true;
|
||||
try {
|
||||
await syncConnAuth();
|
||||
await new Promise((res2) => setTimeout(res2, 800));
|
||||
// ★ 原来固定 setTimeout(…, 800):认证过期时**每个**请求都白等 0.8s,
|
||||
// 并发几个请求就叠加成明显的「卡」。syncConnAuth 本身是 await 的,
|
||||
// 它返回即代表凭据已就绪,无需再额外空等。
|
||||
// 留 30ms 让 setAuth 的 cookie 落盘,避免极端情况下仍用旧凭据。
|
||||
await new Promise((res2) => setTimeout(res2, 30));
|
||||
} catch (e2) {}
|
||||
window._haReloginLock = false;
|
||||
return api(p, o);
|
||||
@ -5287,6 +5295,16 @@ async function connectFetchSSE(url) {
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
// ★ 建连成功 ⇒ 退避计数清零。
|
||||
//
|
||||
// 为什么要在这里清:_sseRetryAttempts 只在下面 catch(**建立**连接失败)
|
||||
// 里自增,而 pump() 中途断流后的重连**只读它算延迟**。不清零的话,
|
||||
// 只要历史上累计过 5 次,之后每次断连都固定等 32s —— 哪怕这次刚成功
|
||||
// 连上、说明服务端和网络都好好的。
|
||||
//
|
||||
// 这正是「消息流不稳 / 看着卡」的一个共因:滞后、卡顿、闪断不是四个
|
||||
// 独立问题,而是同一条退避链在空等。
|
||||
state._sseRetryAttempts = 0;
|
||||
var reader = resp.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = "";
|
||||
@ -5577,9 +5595,12 @@ async function connectFetchSSE(url) {
|
||||
}
|
||||
// 断连后先增量同步历史(补偿断连窗口期丢失的事件),再重连
|
||||
syncChatFromHistory().catch(function () {});
|
||||
// ★ 此处也清零:本次连接曾成功建立(上面已清),断流是运行期事件,
|
||||
// 不该把「建连失败」的累计次数带进下一次退避。
|
||||
state._sseRetryAttempts = 0;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connectSSE();
|
||||
}, Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000));
|
||||
}, Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 32000));
|
||||
}
|
||||
pump();
|
||||
} catch (e) {
|
||||
|
||||
148
cmd/gui/sse-backoff.test.mjs
Normal file
148
cmd/gui/sse-backoff.test.mjs
Normal file
@ -0,0 +1,148 @@
|
||||
// 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);
|
||||
Reference in New Issue
Block a user