|
|
560c462768
|
feat(mcp): GET /api/v1/mcp —— 投递侧事件流(让接入方被动收信,不用轮询)
## 这半边解决什么
工具面(POST)只解决「接入方**问**」。这一条解决「服务端**说**」:
邮件投递时把 new_mail / session_update 推给接入方,让它**拉起对话** ——
与各桥靠 /api/v1/events/stream 收信是同一件事,只是方言不同:
桥: id: 7\nevent: new_mail\ndata: {…}\n\n
MCP: {"jsonrpc":"2.0","method":"notifications/message","params":{…}}
## 为什么复用 sse.Manager 而不是另起一套
Manager 里那些东西**都是踩过坑才对的**:writeMu 串行化(2026-09-28 -race
实测 http.ResponseWriter 并发写会把 JSON 劈成半截,800 帧只切出 459 个完整)、
Last-Event-ID 回放(宁可重复也不丢失)、心跳(反代按空闲 30-58s 掐连接)、
环形缓冲上限、断线清理。复制一份等于把那些坑再踩一遍,
而两边的修复从此各走各的。
代价是 `sse.Client` 多了一个可选 `Frame` 钩子:
**nil = AgentMail 原格式,各桥与 WebUI 行为一字未变**(默认值即历史行为)。
## ★ 回放是第三条写路径,漏了就只在断线时现形
`Send` / `SendWithID` / `replay` 是三条写 Res 的路径。原先**三条都把格式写死**,
只改前两条的话:MCP 客户端**平时**一切正常,只有带 `Last-Event-ID` 重连时
才会收到一批自己解不开的帧 —— 同一个连接上两种方言。
判据 `TestCustomFrameAppliesToReplayToo` 专门钉这条,并带反向对照
(nil 帧必须回落 AgentMail 格式)。
`Frame` 必须在**注册时**传入(`AddClientWithFrame`),不能事后设 ——
回放发生在「先写响应、再注册」的前半段,事后设只影响之后推来的事件。
原先 `AddClient` 保留为薄封装,各桥与 WebUI 调用点一字未改。
## 判据(6 格)
Frame 是 JSON-RPC 2.0 通知 + 帧完整性(单事件、\n\n 结尾)
payload 原样嵌入(不是 JSON 字符串)—— 再 marshal 会让客户端解析两次
event_id / event_type 必带(前者是 Last-Event-ID 续传的依据)
Accept 判定(含 q 值、大小写)
匿名 GET → 401(不能变成静默的匿名订阅)
缺 Accept → 406(接错的客户端会静默收不到东西)
## 顺带修:TestAdvanceRecurrenceLunar 的时区缺陷(★ 今天第三次假红)
全量测试红了,查下来是**我今天早些时候改判据时引入的**,与本次改动无关。
农历换算必须按**本地公历日**算(`AdvanceRecurrence` 里那句
`eventTime.In(time.Local)` 就是这条规则)。库里读回的 EventTime 是 **UTC**
(DSN 用 `_timezone=UTC`),UTC 比本地晚 8 小时,跨零点时农历日差一天:
start (Local) = 2026-10-04 农历日 24
after (UTC) = 2026-11-01 16:00 农历日 23 ← 断言没换算时区(错)
after.In(Local) = 2026-11-02 00:00 农历日 24 ← 正确
服务端代码一直是对的,是判据没照做。失败信息里现在打印时区,
免得下次要重新推导一遍。变异验证:去掉 `.In(time.Local)` → 红 1 ✓
(这条判据是农历的第三次假红了:3459605「断言要求不存在的农历日」、
今天早些「起点写死日期 + advanceToFuture 跳过过期月份」、现在「没换算时区」——
三次都是判据自己写错,代码三次都对。它依赖 Local 时区与「今天」,
天生脆弱,值得记着。)
## 验证
go test ./... 14 包全绿
go test ./internal/sse/ 含新判据绿
go test ./internal/mcp/ 6 格新判据 + 原 19 格全绿
|
2026-10-02 15:37:34 +08:00 |
|
|
|
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 |
|
|
|
e2472287f0
|
fix(sse): Client 加写锁 —— 同一 ResponseWriter 被并发写(-race 证实)
## 缺陷
`internal/sse/manager.go` 的 Client 结构体**一把写锁都没有**,
而 `Manager.mu` 只护 `clients` map 的**遍历** —— 遍历期间对每个
client 的 `c.SendWithID` 是**并发**的。
`http.ResponseWriter` 不是并发安全的,而 SSE 又是文本协议
(`id: N\nevent: X\ndata: {…}\n\n`),两个 Fprintf 交错就把
data 的 JSON 劈成半截 ⇒ 客户端 EventSource 收到坏帧、丢邮件。
## 实测(真实 httptest.ResponseRecorder + -race)
WARNING: DATA RACE
Read at ... by goroutine 13:
net/http/httptest.(*ResponseRecorder).writeHeader()
sse.(*Client).SendWithID() manager.go:350
★ 32 goroutine × 25 帧 = 800 帧,只切出 459 帧完整
## 生产上会打中的三条路径
① handler/permission.go:412-414 —— `SendToAgent(perm.AgentName,…)`
紧接 `SendToUser(user.Username,…)`,两个不同 HTTP 请求命中同一账号。
② 任意两条并发邮件:一封投给 B,B 的插件回信进 C 的 handler,
而 A 的 `notify.Recipients` 还没跑完。
③ heartbeat 那条 goroutine 每 10s 写一次(见 heartbeatInterval
注释:实测本机 SSE 连接只活 34~57s,被中间反代按空闲超时掐掉),
撞车概率随在线时长线性上升。
## 修法
Client 加 `writeMu`,串行化**全部四条**写路径:
Send / SendWithID / heartbeat / replay
`replay` 虽在注册之前、按构造就是单写者,仍持锁 ——
让「对 Res 的写入一律经由 writeMu」成为**结构上**的纪律:
将来有人把注册提前或把回放挪到注册之后,没上锁的版本会静默退化成并发写。
为什么不能靠上层串行化:推送方有 5 个入口
(SendToUser/SendToAgent/SendToRecipient/Broadcast/replay),
要保证"同一 client 的所有写互斥",责任只能落在 client 自己身上。
## 判据(新增 frame_integrity_test.go,2 格)
**用帧完整性而不是"不许有 race"当判据** —— 本仓 `go test ./...`
默认不带 -race,判据必须在默认路径能判,否则就变成"要记得加 flag"。
TestFrameIntegrityUnderConcurrentPush 800 帧必须 800 帧完整
TestHeartbeatDoesNotInterleaveWithPush 钉住"只锁推送漏掉心跳"那个漏法
变异验证(去掉三处锁):
★ 17 次交错;切出 793/800 帧;心跳格也报 3 次交错 ⇒ 两格都有分辨力。
|
2026-09-28 08:26:29 +08:00 |
|
|
|
bb8201f0c3
|
fix(sse): 心跳 30s → 10s(实测连接寿命 34-57s 就断,与代理空闲超时擦边)
用户报「每次点击按钮 1-2s 延迟」时抓到的实测:
· 普通 API 30-58ms(服务端不慢);
· 他的 /events/stream 连接每次只活 34.6s / 39.4s / 56.9s 就被关闭;
· 当时心跳是 30s —— 与常见的 30s 代理读超时**擦边**,晚一点就被判空闲。
所以心跳改 10s(留三倍余量,代价是每 10s 一个 16 字节注释帧),并加判据钉"量级关系":
心跳间隔必须 < 20s,不写成具体数字(心跳与超时"相当"就是错,不是"30 不对 10 对")。
变异:改回 30s → 该判据红。
★ 诚实记录:部署后**连接still 在被掐**(观察到 12.6s / 17.2s 的寿命,反而更短),
说明掐连接的不是"30s 空闲超时"这一条 —— 更可能是客户端自己 close/重连
(服务端看到的寿命 = 对方关掉的时刻)。这条改动是**正确的加固**(心跳必须明显小于
任何合理超时),但不构成对那个症状的修复;真正定位还需要用户浏览器侧的日志。
|
2026-09-15 09:02:02 +08:00 |
|
|
|
f9d757b5e5
|
chore: directory migration - gateway→server, web→client/electron
|
2026-09-08 19:16:35 +08:00 |
|