Files
MailUI4Agents/client/electron/test/inbox-fallback-poll.test.mjs
JianFeeeee aeb1f4116b fix(WebUI): SSE 订阅跟着账号凭证走 + 断线重放 + 兜底轮询
用户报:**页面停留不动,新邮件不自动同步**(手动刷新能看到)。

## 根因一(主因):SSE 连接不跟着账号走

`App.tsx` 的 effect 依赖是 `[phase]`,而切号(`accountStore.setActive`)
只换 `api/config` 的 base/token、**不改 phase** ⇒ SSE 连接仍绑旧账号的凭证:
旧账号的新邮件照收,新账号的一封都不推。而 `fetchInbox` 走**新**凭证 ⇒ 数据是新的。
⇒ 表现正是「不自动同步,但手动刷新能看到」。

修法:effect 依赖加上「当前凭证身份」(base + token)。
不在切号处显式重建订阅 —— 那要改所有调用点、漏一处就不刷新;
凭证变化的**唯一发生地**是 api/config,从那里取身份更可靠。

★ 身份**不含 user**:同一账号重新登录 token 变了,那个账号的邮件仍该收
  (服务端按 user/agent 绑通道,见 sse.bufferKey);
  按 base+token 判只会让「同账号换令牌」多触发一次重连(无害)。

## 根因二:断线重连不重放

服务端一直支持按 Last-Event-ID 回放(ring.replay,500 条缓冲),
EventSource 断线后**本来会自己重连并带该头**。但这里的 onerror 主动
`close(false)` 再 `open()` —— **换了 EventSource 对象**,
而 Last-Event-ID 是浏览器为**那个对象**记的 ⇒ 服务端拿不到 ⇒ 不回放。

EventSource 不能设请求头 ⇒ 游标只能进 query,服务端相应要读
`?lastEventId=`(**两侧都要改,缺一半都不生效且没有任何东西会红**)。
服务端写成 query 优先、header 兜底 —— header 保留给 Agent 侧(curl/SDK)。
⚠ query 会进访问日志;游标是自增数字(不是令牌),与「令牌不进日志」的约定不同级。

`onerror` 区分两种重连:
- 断线(凭证没变)⇒ 带游标,服务端回放断线期间的事件
- 切号(凭证变了)⇒ **必须不带** —— 拿旧账号的 id 去问新账号会搅乱事件流

## 根因三:连接静默但不再收数据

SSE 只在**真的断开**时触发 onerror。有一类故障它看不见:
连接还在、TCP 没断、却不再收数据(代理静默丢包 / NAT 超时 /
中间设备挂死长连接)。两端都认为正常 ⇒ 不重连 ⇒ 页面停留就再也不同步。

补 `lib/inboxFallbackPoll.ts` 作为冗余通道:
- 探针 `getInbox('all', 1)` **只要 total**(全量重拉会让接口与渲染无谓抖动)
- 首轮只建基线不触发;探针失败**不重置基线**(否则一次抖动会变成「下一轮假装有变化」)
- inFlight 去重,慢网络下不叠请求
- 页面隐藏时暂停,恢复可见**立刻探一次**(用户往往正是「切回来发现没更新」才报的)
- 切号时 resetPollBaseline:新账号 total 与旧账号无关,不丢会白拉一次
- 间隔 30s:远大于 SSE 的秒级延迟(正常时纯冗余),又短到挂死最多 30s 被发现

## 判据

12 格(sse-credentials 5 + inbox-fallback-poll 7)。九个变异全部经得起:
依赖退回 [phase] / 重连不带游标 / 切号也带旧游标 / 服务端不读 query /
catch 重置基线 / cleanup 漏停轮询 / 凭证依赖丢失 / 探针拉全量 / 恢复可见不立即探。

★ 一处判据自身缺陷被变异抓出来并修掉:第 3 格原先只查
  `url += \`${sep}lastEventId=…\`` 这行**文本存在**,把 `if (lastEventId)`
  改成 `if (false)` 后照样绿 —— 正则匹配文本,缺陷在控制流。
  补了条件本身的断言才红。与「catch 里不得重置基线」是同一类教训。
2026-10-02 10:46:45 +08:00

85 lines
3.7 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 的**冗余**通道。
*
* ## 为什么需要
*
* SSE 只在**真的断开**时触发 `onerror`。有一类故障它看不见:
* **连接还在、TCP 没断、却不再收数据**(代理静默丢包、NAT 超时、
* 中间设备挂死长连接)。两端都认为连接正常 ⇒ 不重连 ⇒ 页面停留就再也不同步。
*
* 症状与用户报的一致:「页面停留不动、新邮件不自动同步」,
* 而手动刷新能看到(刷新走新请求,与那条挂死的连接无关)。
*
* ## 为什么只比 `total`
*
* 每 N 秒全量重拉会让接口与渲染无谓地抖。`getInbox('all', 1)` 只要计数,
* 变了才真的拉列表。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const pollSrc = readFileSync(join(HERE, '..', 'src', 'lib', 'inboxFallbackPoll.ts'), 'utf8');
const appSrc = readFileSync(join(HERE, '..', 'src', 'App.tsx'), 'utf8');
test('★ 探针只取计数(limit=1),变了才重拉', () => {
// 全量重拉会让接口与渲染无谓抖动;探针要廉价
assert.ok(
/getInbox\('all',\s*1\)/.test(pollSrc),
'探针必须 limit=1 只要 total,不要正文');
assert.ok(
/total !== lastTotal/.test(pollSrc),
'必须比 total —— 变了才刷新');
});
test('★ 首轮只建基线不触发刷新(否则刚打开页面就白拉一次)', () => {
assert.ok(
/lastTotal === null/.test(pollSrc) && /return false; \/\/ 首轮建立基线/.test(pollSrc),
'首轮必须只记录基线,不触发刷新');
});
test('★ 探针失败不得重置基线', () => {
// 网络抖动不该让下一轮误判成「有变化」而触发全量重拉
const m = pollSrc.match(/} catch \{[\s\S]*?\n \}/);
assert.ok(m, '未找到 catch 分支');
assert.ok(
!/lastTotal = null/.test(m[0]),
'★ catch 里不得重置基线 —— 那会把一次抖动变成「下一轮假装有变化」');
});
test('★ inFlight 去重(上一轮未回就不叠请求)', () => {
assert.ok(
/if \(inFlight\) return false;/.test(pollSrc),
'必须有 inFlight 去重,否则慢网络下请求会叠加');
});
test('★ 页面隐藏时暂停 + 恢复可见立刻探一次', () => {
// 用户往往就是「切回来发现没更新」才报的 —— 那一下正好补上
assert.ok(
/visibilitychange/.test(pollSrc) && /pollOnce\(fetchInbox\)/.test(pollSrc),
'恢复可见时应立刻探一次,不等下一个周期');
assert.ok(/paused = document\.visibilityState !== 'visible'/.test(pollSrc),
'初始就应按可见性决定是否暂停');
});
test('★ App 里两条通道的 cleanup 都要调(漏一个 = 旧账号定时器残留)', () => {
assert.ok(/stopPoll\(\)/.test(appSrc), 'cleanup 必须停轮询定时器');
assert.ok(/disconnect\(\)/.test(appSrc), 'cleanup 必须断 SSE');
assert.ok(
/resetPollBaseline\(\)/.test(appSrc),
'切号必须丢弃基线:新账号的 total 与旧账号无关,'
+ '不丢会误判成「有变化」而白拉一次');
});
test('★ 轮询与 SSE 共用同一个 effect 依赖(凭证变化时一起重建)', () => {
// 轮询若挂在另一个 effect 上,切号时它不会重建 ⇒ 拿新凭证查旧账号的收件箱
assert.ok(
/const credentialSig = useCredentialChange\(\);/.test(appSrc),
'凭证身份必须存在');
assert.ok(
/}, \[phase, credentialSig\]\);/.test(appSrc),
'effect 依赖必须含 credentialSig —— 轮询与 SSE 都在这个 effect 里');
});