用户报:**页面停留不动,新邮件不自动同步**(手动刷新能看到)。
## 根因一(主因):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 里不得重置基线」是同一类教训。
104 lines
5.5 KiB
JavaScript
104 lines
5.5 KiB
JavaScript
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 把原路径删掉)');
|
||
}); |