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:
JianFeeeee
2026-09-28 07:59:15 +08:00
parent d084137def
commit 1ef1998e72
2 changed files with 172 additions and 3 deletions

View File

@ -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) {