用户报:**页面停留不动,新邮件不自动同步**(手动刷新能看到)。
## 根因一(主因):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 里不得重置基线」是同一类教训。
85 lines
3.7 KiB
JavaScript
85 lines
3.7 KiB
JavaScript
/**
|
||
* 收件箱兜底轮询: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 里');
|
||
}); |