Commit Graph

3 Commits

Author SHA1 Message Date
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