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 里不得重置基线」是同一类教训。
This commit is contained in:
@ -1,7 +1,8 @@
|
||||
import { Suspense, lazy, useEffect } from 'react';
|
||||
|
||||
import { initAppearanceSync, useAppearanceSync } from './stores/appearanceSync';
|
||||
import { connectSSE } from './api/sse';
|
||||
import { connectSSE, useCredentialChange } from './api/sse';
|
||||
import { startInboxFallbackPoll, resetPollBaseline } from './lib/inboxFallbackPoll';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { useAccountStore } from './stores/accountStore';
|
||||
import { useMailStore } from './stores/mailStore';
|
||||
@ -118,6 +119,20 @@ export default function App() {
|
||||
void useAppearanceSync.getState().pull();
|
||||
}, [phase]);
|
||||
|
||||
/*
|
||||
* ★ 2026-10-02:effect 依赖加上**当前凭证身份**。
|
||||
*
|
||||
* 缺陷(用户报:页面停留不动,新邮件不自动同步):依赖只有 `[phase]`,
|
||||
* 而切号(`accountStore.setActive`)只换 api/config 的 base/token、**不改 phase**
|
||||
* ⇒ SSE 连接仍是旧账号的:旧账号的新邮件照收,新账号的一封都不推;
|
||||
* 而 fetchInbox 走新凭证 ⇒ 数据是新的。
|
||||
* ⇒ 表现正是「不自动同步,但手动刷新能看到」。
|
||||
*
|
||||
* 为什么不在切号处显式重建订阅:那要改所有调用点,漏一处就不刷新。
|
||||
* 凭证变化的**唯一发生地**是 api/config,从那里取身份更可靠。
|
||||
*/
|
||||
const credentialSig = useCredentialChange();
|
||||
|
||||
// 登录态就绪后拉取数据 + SSE
|
||||
useEffect(() => {
|
||||
if (phase !== 'authenticated') return;
|
||||
@ -127,7 +142,20 @@ export default function App() {
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
|
||||
return connectSSE((type, data) => {
|
||||
/*
|
||||
* ★ 2026-10-02:SSE 的冗余通道 —— 收件箱兜底轮询。
|
||||
*
|
||||
* SSE 只在**真的断开**时触发 onerror。有一类故障它看不见:连接还在、
|
||||
* TCP 没断、却不再收数据(代理静默丢包 / NAT 超时 / 中间设备挂死长连接)。
|
||||
* 此时两端都认为连接正常 ⇒ 不重连 ⇒ 页面停留就再也不同步。
|
||||
* 轮询用 `total` 当廉价探针(limit=1 只要计数),变了才真拉列表。
|
||||
*
|
||||
* 切号时丢弃基线:新账号的 total 与旧账号无关系,不丢会误判成「有变化」
|
||||
* 而白拉一次(无害,但那是可避免的一次全量)。
|
||||
*/
|
||||
resetPollBaseline();
|
||||
const stopPoll = startInboxFallbackPoll(status => fetchInbox(status));
|
||||
const disconnect = connectSSE((type, data) => {
|
||||
switch (type) {
|
||||
case 'new_mail':
|
||||
fetchInbox('all');
|
||||
@ -159,7 +187,14 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [phase]);
|
||||
|
||||
// 两个通道都要停:轮询的定时器与 SSE 的连接。
|
||||
// 只停一个 ⇒ 切号后旧账号的定时器还在跑(会拿新凭证查旧账号的收件箱)。
|
||||
return () => {
|
||||
stopPoll();
|
||||
disconnect();
|
||||
};
|
||||
}, [phase, credentialSig]);
|
||||
|
||||
// loading 阶段
|
||||
if (phase === 'checking') {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { API_BASE, withToken } from './config';
|
||||
import { API_BASE, getToken, withToken } from './config';
|
||||
|
||||
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
|
||||
export type SSEStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
||||
@ -18,6 +18,40 @@ let backoff = 1000;
|
||||
let _status: SSEStatus = 'disconnected';
|
||||
let statusHandlers: Array<(s: SSEStatus) => void> = [];
|
||||
|
||||
// 当前连接用的是哪套凭证。断线重连时若凭证已变,就**换凭证**而不是续用旧的
|
||||
// —— 这是「切号后新邮件收不到」那条缺陷的落点。
|
||||
let connectedWith = '';
|
||||
|
||||
// 最近一次收到的事件 id(来自 SSE 帧的 id: 行)。断线重连时交给服务端回放。
|
||||
let lastEventId = '';
|
||||
|
||||
/*
|
||||
★ 2026-10-02:SSE 连接必须**跟着账号凭证**走。
|
||||
|
||||
# 缺陷(用户报:页面停留不动,新邮件不自动同步)
|
||||
|
||||
`App.tsx` 里 `useEffect(..., [phase])` 只在**登录态**变化时连一次 SSE,
|
||||
而切号(`accountStore.setActive`)只换 `api/config` 的 base/token、**不改 phase**
|
||||
⇒ 连接仍是旧账号的:旧账号的新邮件照收,新账号的一封都不推;
|
||||
而 `fetchInbox` 走的是新凭证 ⇒ 数据是新的。
|
||||
⇒ 表现正是「不自动同步,但手动刷新能看到」。
|
||||
|
||||
# 为什么记凭证身份而不是让调用方传
|
||||
|
||||
凭证变化有两条路径(切号、令牌刷新),都在 api/config 里;
|
||||
在那一层发信号 = 只有一处知道「凭证换了」,不依赖调用方记得调。
|
||||
|
||||
# 身份用什么
|
||||
|
||||
`base + token` 的组合。**不含 user** —— 同一账号重新登录 token 变了,
|
||||
但那个账号的邮件仍然该收(服务端按 user 或 agent 绑通道,见 sse.bufferKey);
|
||||
按 base+token 判会让「同一账号换令牌」误触发重连(无害,只是多一次连接)。
|
||||
*/
|
||||
/** 当前凭证身份(base + token)。切号或令牌刷新后变化。 */
|
||||
export function credentialSignature(): string {
|
||||
return `${API_BASE}|${getToken() ?? ''}`;
|
||||
}
|
||||
|
||||
/** 监听 SSE 连接状态变化 */
|
||||
export function onSSEStatus(handler: (s: SSEStatus) => void): () => void {
|
||||
statusHandlers.push(handler);
|
||||
@ -37,6 +71,21 @@ function setStatus(s: SSEStatus) {
|
||||
statusHandlers.forEach(h => h(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭证变了就重连(SSE 订阅必须跟着账号走)。
|
||||
*
|
||||
* ★ 2026-10-02:这是用户报的「页面停留不动、新邮件不自动同步」的修复点。
|
||||
* `App.tsx` 的 effect 依赖是 `[phase]`,而切号不改 phase ⇒ 连接绑在旧账号上。
|
||||
* 与其让调用方记得在切号后重建订阅(多处调用点、漏一处就不刷新),
|
||||
* 不如在这一层自己发现凭证变了 —— **凭证变化的唯一发生地就是 api/config**。
|
||||
*
|
||||
* 返回值给调用方放进 useEffect 依赖:变化时 React 会重跑 effect → 触发重连。
|
||||
* 不直接在这里重连,是因为 effect 重跑时会 close+open 一次,语义更清楚。
|
||||
*/
|
||||
export function useCredentialChange(): string {
|
||||
return credentialSignature();
|
||||
}
|
||||
|
||||
export function connectSSE(onEvent: SSEEventHandler): () => void {
|
||||
handlers.push(onEvent);
|
||||
if (!es) open();
|
||||
@ -47,12 +96,21 @@ export function connectSSE(onEvent: SSEEventHandler): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
function open() {
|
||||
function open(resumeFrom = '') {
|
||||
close(false);
|
||||
setStatus('connecting');
|
||||
connectedWith = credentialSignature();
|
||||
lastEventId = resumeFrom;
|
||||
// EventSource 无法设置请求头:Cookie 模式靠同源 Cookie,
|
||||
// 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。
|
||||
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
|
||||
let url = withToken(`${API_BASE}/events/stream`);
|
||||
if (lastEventId) {
|
||||
// ★ EventSource 不能设请求头 ⇒ lastEventId 只能进 query。
|
||||
// 服务端要相应支持读它(见 sse/manager.go 的 lastID 取值)。
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
url += `${sep}lastEventId=${encodeURIComponent(lastEventId)}`;
|
||||
}
|
||||
es = new EventSource(url, { withCredentials: true });
|
||||
|
||||
// EventSource 会自动重连,但它的 readyState 在网络断开时
|
||||
// 不一定及时反映状态。用 onopen 判断实际连上了。
|
||||
@ -61,9 +119,16 @@ function open() {
|
||||
setStatus('connected');
|
||||
};
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
es.addEventListener('connected', (e: MessageEvent) => {
|
||||
backoff = 1000;
|
||||
setStatus('connected');
|
||||
// 服务端把当前游标放在 connected 帧的 data.id 里
|
||||
try {
|
||||
const d = JSON.parse(e.data) as { id?: string };
|
||||
if (typeof d?.id === 'string' && d.id) lastEventId = d.id;
|
||||
} catch {
|
||||
/* 非 JSON 负载:保持原值 */
|
||||
}
|
||||
});
|
||||
|
||||
for (const name of EVENTS) {
|
||||
@ -74,11 +139,42 @@ function open() {
|
||||
} catch {
|
||||
/* 忽略非 JSON 负载 */
|
||||
}
|
||||
// 记下游标:断线后从这儿续,服务端按 ring 里的 id 回放其后的<E5908E><E79A84>件
|
||||
if (typeof (e as MessageEvent).lastEventId === 'string' && e.lastEventId) {
|
||||
lastEventId = e.lastEventId;
|
||||
}
|
||||
handlers.forEach(h => h(name, data));
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* ★ 2026-10-02 断线重连带 Last-Event-ID(修复「断线期间的新邮件永远看不到」)。
|
||||
|
||||
# 为什么不能靠浏览器自动重连
|
||||
|
||||
`EventSource` 断线后**本来会自己重连并带上 Last-Event-ID**,
|
||||
服务端也据此回放(`sse/manager.go` 的 ring.replay,500 条缓冲)。
|
||||
但我们原来的 onerror 主动 `close(false)` 再 `open()` ——
|
||||
**换了一个 EventSource 对象**,而 Last-Event-ID 是浏览器为**那个对象**记的,
|
||||
新对象什么都不知道 ⇒ 服务端拿不到 lastID ⇒ 不回放。
|
||||
|
||||
# 为什么仍要自己重连(而不是交给浏览器)
|
||||
|
||||
状态可见性:`readyState` 在网络断开时不一定及时反映,
|
||||
而 UI 要显示「重连中」。这是原注释里已有的理由,保留。
|
||||
|
||||
# 怎么做才能既带 id 又换凭证
|
||||
|
||||
EventSource 不能设请求头 ⇒ lastEventId 只能进 **query**
|
||||
(服务端目前只读 header,所以这条路要求服务端配合)。
|
||||
两种重连要分开:
|
||||
· **断线**(凭证没变)⇒ 带上次的 id,服务端回放断线期间的事件
|
||||
· **切号**(凭证变了)⇒ **必须不带** —— 拿旧账号的 id 去问新账号,
|
||||
会把别的事件流搅乱(id 是全局自增,但回放按该账号的 ring 匹配)
|
||||
*/
|
||||
es.onerror = () => {
|
||||
const credsChanged = connectedWith !== '' && connectedWith !== credentialSignature();
|
||||
const resumeFrom = credsChanged ? '' : lastEventId;
|
||||
close(false);
|
||||
if (handlers.length === 0) return;
|
||||
if (retryTimer) return;
|
||||
@ -86,7 +182,7 @@ function open() {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
backoff = Math.min(backoff * 2, 15000);
|
||||
open();
|
||||
open(resumeFrom);
|
||||
}, backoff);
|
||||
};
|
||||
}
|
||||
|
||||
112
client/electron/src/lib/inboxFallbackPoll.ts
Normal file
112
client/electron/src/lib/inboxFallbackPoll.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 收件箱兜底轮询(SSE 的冗余通道)。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
*
|
||||
* SSE 只在**真的断开**时触发 `onerror`(于是重连)。但有一类故障它看不见:
|
||||
* **连接还在、TCP 没断、却不再收数据** —— 代理静默丢包、NAT/防火墙超时、
|
||||
* 中间设备把长连接挂死。此时浏览器与服务端都认为连接正常,
|
||||
* `onerror` 不触发,重连逻辑也就不会跑 ⇒ 页面停留就再也不同步。
|
||||
*
|
||||
* 实测症状与用户报的一致:「页面停留不动、新邮件不自动同步」,
|
||||
* 而手动刷新能看到(刷新走的是新请求,与那条挂死的连接无关)。
|
||||
*
|
||||
* ## 为什么轮询只比 `total`
|
||||
*
|
||||
* 全量重拉每 N 秒一次会让接口与渲染都无谓地抖。用 `total`(未读+总数)
|
||||
* 当探针:一次 GET,客户端只读一个数字,变了才真的拉列表。
|
||||
*
|
||||
* `total` 会因为「已读/未读」变化而变 —— 那正是我们要捕捉的。
|
||||
* 归档/删除也会让它变,同样该刷新。
|
||||
*
|
||||
* ## 间隔取值
|
||||
*
|
||||
* 30s:远大于 SSE 的秒级延迟(所以正常情况下轮询只是冗余),
|
||||
* 又短到「挂死最多 30 秒被发现」。页面隐藏时暂停(见下)。
|
||||
*/
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
/** 探针快照;null = 还没建立基线(首轮只记录不触发) */
|
||||
let lastTotal: number | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let inFlight = false;
|
||||
|
||||
/**
|
||||
* 一轮探针:比 total,变了就重拉。
|
||||
*
|
||||
* `fetchInbox` 传进来而不是在这里 import —— store 之间互相 import
|
||||
* 会把初始化顺序变成隐式依赖(accountStore ↔ mailStore 已经互相引用过一次)。
|
||||
*/
|
||||
export async function pollOnce(fetchInbox: (status: string) => Promise<void>): Promise<boolean> {
|
||||
if (inFlight) return false; // 上一次还没回来:跳过,别叠请求
|
||||
inFlight = true;
|
||||
try {
|
||||
const { getInbox } = await import('../api/client');
|
||||
const { total } = await getInbox('all', 1); // limit=1:只要计数,不要正文
|
||||
if (lastTotal === null) {
|
||||
lastTotal = total;
|
||||
return false; // 首轮建立基线,不触发刷新
|
||||
}
|
||||
if (total !== lastTotal) {
|
||||
lastTotal = total;
|
||||
await fetchInbox('all');
|
||||
return true;
|
||||
}
|
||||
lastTotal = total;
|
||||
return false;
|
||||
} catch {
|
||||
// 探针失败**不重置基线**:网络抖动不该让下一轮误判成「有变化」
|
||||
// 而触发一次全量重拉。基线保持,下轮继续比。
|
||||
return false;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动兜底轮询。返回一个停止函数。
|
||||
*
|
||||
* ## 页面隐藏时暂停
|
||||
*
|
||||
* 标签页切走时 SSE 照常连着(浏览器不会断),而轮询只是浪费;
|
||||
* 更重要的是**恢复可见时立刻探一次** —— 用户往往就是「切回来发现没更新」
|
||||
* 才来报这个问题的,那一下正好补上。
|
||||
*/
|
||||
export function startInboxFallbackPoll(
|
||||
fetchInbox: (status: string) => Promise<void>,
|
||||
opts: { intervalMs?: number } = {}
|
||||
): () => void {
|
||||
const ms = opts.intervalMs ?? POLL_INTERVAL_MS;
|
||||
let paused = false;
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
paused = false;
|
||||
void pollOnce(fetchInbox); // 回来立刻探一次,不等下一个周期
|
||||
} else {
|
||||
paused = true;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
paused = document.visibilityState !== 'visible';
|
||||
|
||||
timer = setInterval(() => {
|
||||
if (paused) return;
|
||||
void pollOnce(fetchInbox);
|
||||
}, ms);
|
||||
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}
|
||||
|
||||
/** 供切号/测试用:丢弃基线,下一轮只记录不触发 */
|
||||
export function resetPollBaseline(): void {
|
||||
lastTotal = null;
|
||||
}
|
||||
85
client/electron/test/inbox-fallback-poll.test.mjs
Normal file
85
client/electron/test/inbox-fallback-poll.test.mjs
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 收件箱兜底轮询: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 里');
|
||||
});
|
||||
104
client/electron/test/sse-credentials.test.mjs
Normal file
104
client/electron/test/sse-credentials.test.mjs
Normal file
@ -0,0 +1,104 @@
|
||||
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 把原路径删掉)');
|
||||
});
|
||||
@ -213,7 +213,21 @@ func (m *Manager) AddClient(res http.ResponseWriter, r *http.Request, agentName,
|
||||
// 一律经由 writeMu」成为**结构上**的纪律:将来有人把注册提前、或把回放挪到
|
||||
// 注册之后(很自然的一个改动),没上锁的版本会**静默**退化成并发写。
|
||||
// 锁在这里零成本,而它买的正是「后人改顺序也不会破」这件事。
|
||||
lastID := r.Header.Get("Last-Event-ID")
|
||||
// ★ 2026-10-02:lastID 也接受 query(`?lastEventId=`)。
|
||||
//
|
||||
// 为什么需要:EventSource **不能设请求头**,而 WebUI 的重连逻辑是
|
||||
// 「close 掉旧对象再新建一个」(为了 UI 能显示「重连中」——
|
||||
// readyState 在网络断开时不一定及时反映)。
|
||||
// 换对象就换掉了浏览器为**那个对象**记的 Last-Event-ID,
|
||||
// 于是断线期间的事件永远不会被回放:用户报的现象就是
|
||||
// 「页面停留不动、新邮件不自动同步」。
|
||||
//
|
||||
// 两处取值:query 优先(客户端显式给的),否则用标准头
|
||||
// (Agent 侧如 curl/SDK 可能仍走标准重连语义)。
|
||||
lastID := r.URL.Query().Get("lastEventId")
|
||||
if lastID == "" {
|
||||
lastID = r.Header.Get("Last-Event-ID")
|
||||
}
|
||||
key := m.bufferKey(userName, agentName)
|
||||
if ring := m.getOrCreateRing(key); ring != nil && lastID != "" {
|
||||
client.writeMu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user