Files
MailUI4Agents/client/electron/src/api/sse.ts
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

206 lines
7.4 KiB
TypeScript
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 { API_BASE, getToken, withToken } from './config';
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
export type SSEStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
const EVENTS = [
'new_mail',
'permission_decision',
'session_update',
'session_archived',
'agent_online'
] as const;
let es: EventSource | null = null;
let handlers: SSEEventHandler[] = [];
let retryTimer: ReturnType<typeof setTimeout> | null = null;
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);
return () => {
statusHandlers = statusHandlers.filter(h => h !== handler);
};
}
/** 当前 SSE 连接状态 */
export function getSSEStatus(): SSEStatus {
return _status;
}
function setStatus(s: SSEStatus) {
if (_status === s) return;
_status = s;
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();
return () => {
handlers = handlers.filter(h => h !== onEvent);
if (handlers.length === 0) close();
};
}
function open(resumeFrom = '') {
close(false);
setStatus('connecting');
connectedWith = credentialSignature();
lastEventId = resumeFrom;
// EventSource 无法设置请求头:Cookie 模式靠同源 Cookie,
// 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。
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 判断实际连上了。
es.onopen = () => {
backoff = 1000;
setStatus('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) {
es.addEventListener(name, (e: MessageEvent) => {
let data: Record<string, unknown> = {};
try {
data = JSON.parse(e.data);
} 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;
setStatus('reconnecting');
retryTimer = setTimeout(() => {
retryTimer = null;
backoff = Math.min(backoff * 2, 15000);
open(resumeFrom);
}, backoff);
};
}
function close(clearHandlers = true) {
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
if (es) {
es.close();
es = null;
}
if (clearHandlers) handlers = [];
setStatus('disconnected');
}
export function disconnectSSE() {
close();
}