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