Files
MailUI4Agents/client/electron/test/sse-credentials.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

104 lines
5.5 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.

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';
import { readFileSync as read } from 'node:fs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, '..', 'src');
const sse = readFileSync(join(SRC, 'api', 'sse.ts'), 'utf8');
const app = readFileSync(join(SRC, 'App.tsx'), 'utf8');
// 服务端在另一个仓(../../../../server),读它是为了钉「两侧同步」这条 ——
// 只改客户端不改服务端时,重放永远不生效,而**没有任何东西会红**。
const sseMgr = read(
join(SRC, '..', '..', '..', 'server', 'internal', 'sse', 'manager.go'),
'utf8');
/**
* WebUI 实时性:SSE 订阅必须**跟着账号凭证**走 + 断线重连要能补回漏掉的事件。
*
* ## 缺陷 1(用户报:页面停留不动,新邮件不自动同步)
*
* `App.tsx` 里 `useEffect(..., [phase])` 只在**登录态**变化时连一次 SSE,
* 而切号(`accountStore.setActive`)只换 `api/config` 的 base/token、**不改 phase**
* ⇒ 连接仍绑旧账号:旧账号的新邮件照收,新账号的一封都不推。
* 而 `fetchInbox` 走新凭证 ⇒ 数据是新的。
* ⇒ 表现正是「不自动同步,但手动刷新能看到」——因为刷新走的是新凭证。
*
* 修法:effect 依赖加上「当前凭证身份」。不在切号处显式重建订阅,
* 因为那要改所有调用点、漏一处就不刷新;凭证变化的唯一发生地是 api/config。
*
* ## 缺陷 2:断线重连不重放
*
* 服务端一直支持按 `Last-Event-ID` 回放(`sse/manager.go` 的 ring.replay,500 条缓冲),
* 而 EventSource 断线后**本来会自己重连并带上该头**。
* 但本文件的 onerror 主动 `close(false)` 再 `open()` —— **换了 EventSource 对象**,
* 而 Last-Event-ID 是浏览器为**那个对象**记的,新对象什么都不知道 ⇒ 服务端拿不到 ⇒ 不回放。
*
* 又因为 EventSource **不能设请求头**,只能走 query,
* 所以服务端也要相应支持读 `?lastEventId=`(两侧都要改,缺一半都不生效)。
*/
test('★ SSE 订阅跟着凭证走:App 的 effect 依赖含凭证身份', () => {
assert.ok(
/useCredentialChange/.test(app),
'App.tsx 必须取当前凭证身份(否则切号后 SSE 仍绑旧账号)');
// 关键:依赖数组里必须有它
assert.ok(
/useEffect\(\(\) => \{[\s\S]*?connectSSE\([\s\S]*?\}, \[phase, credentialSig\]\)/.test(app),
'SSE effect 的依赖数组必须含 credentialSig —— 只有 [phase] 时切号不重连');
});
test('凭证身份 = base + token(不含 user:同账号换令牌不该误触发)', () => {
const m = sse.match(/export function credentialSignature\(\)[\s\S]*?\n}/);
assert.ok(m, '缺少 credentialSignature()');
const body = m[0];
assert.ok(/API_BASE/.test(body), '身份必须含 base(不同网关是不同的连接)');
assert.ok(/getToken\(\)/.test(body), '身份必须含 token(不同账号是不同的通道)');
});
test('★ 断线重连带上 lastEventId(否则服务端不回放 = 断线期间的新邮件永远看不到)', () => {
assert.ok(/lastEventId/.test(sse), '未记录/传递 lastEventId');
// 从 connected 帧与事件帧里取游标
assert.ok(
/addEventListener\('connected',[\s\S]{0,400}lastEventId\s*=/.test(sse),
'必须从 connected 帧的 data.id 记下游标(那是服务端给的游标)');
// URL 上要真的带上 —— ★ 必须连**条件**一起钉。
//
// 我第一版只查那行字符串存在,变异把 `if (lastEventId)` 改成 `if (false)`
// 后判据**照样绿**:正则匹配的是文本,而缺陷在控制流(那行永远不执行)。
// ⇒ 断言必须同时包含「取 lastEventId」+ 「据此追加 query」两段。
assert.ok(
/url \+= `\$\{sep\}lastEventId=\$\{encodeURIComponent\(lastEventId\)\}`/.test(sse),
'EventSource 不能设请求头 ⇒ lastEventId 必须进 query');
assert.ok(
/if \(lastEventId\) \{/.test(sse),
'★ 追加 query 的条件必须是 `if (lastEventId)` —— ' +
'只查那行文本存在的话,`if (false)` 让它永不执行也照样绿(实测过)');
// 且条件读的是同一个变量(不是别的)
assert.ok(
/open\(resumeFrom = ''\)/.test(sse),
'open() 必须接受 resumeFrom 并写入 lastEventId(否则重连参数无处落地)');
});
test('★ 切号时不得带上旧账号的 lastEventId', () => {
// 拿旧 id 去问新账号会把别的事件流搅乱 —— 两种重连必须分开
assert.ok(
/credsChanged = connectedWith !== '' && connectedWith !== credentialSignature\(\)/.test(sse),
'必须检测凭证是否变化');
assert.ok(
/const resumeFrom = credsChanged \? '' : lastEventId;/.test(sse),
'凭证变了就必须**不带** lastEventId(它属于旧账号的 ring)');
});
test('★ 服务端也认 lastEventId query(EventSource 不能设头,两侧要同步)', () => {
// 只改客户端不改服务端 = 重放永远不生效,而且没有任何东西会红
assert.ok(
/r\.URL\.Query\(\)\.Get\("lastEventId"\)/.test(sseMgr),
'服务端必须读 ?lastEventId= —— 否则客户端带了也没人收');
// 仍要保留标准头(Agent 侧 curl/SDK 可能走 EventSource 之外的路径)
assert.ok(
/r\.Header\.Get\("Last-Event-ID"\)/.test(sseMgr),
'必须保留标准头兜底(不能为了 query 把原路径删掉)');
});