Files
MailUI4Agents/server/internal/sse/manager.go
JianFeeeee 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

423 lines
13 KiB
Go
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.

package sse
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/google/uuid"
)
// eventRing 是单用户事件的有界环形缓冲区。
//
// EventSource 断线重连时自带 Last-Event-ID 头:服务端据此回放断线期间的事件。
// 没有它,重连后永远看不到断线期间收到的邮件 —— 而这正是实时协作的体验核心。
//
// 缓冲区大小 500 条:一条事件约 200B(typical),500 条 ≈ 100KB/用户。
// 20 个在线用户 ≈ 2MB,远低于 OOM 风险。
type eventRing struct {
mu sync.Mutex
events []StoredEvent
cap int
head int // 下一次写入的位置
full bool
}
// StoredEvent 是缓冲区中的单条事件。
type StoredEvent struct {
ID string // 自增序列号,EventSource 的 Last-Event-ID 值
EventType string
Data []byte
Timestamp time.Time
}
func newEventRing(cap int) *eventRing {
return &eventRing{events: make([]StoredEvent, cap), cap: cap}
}
// push 追加一条事件到缓冲区。满了就覆盖最旧的。
func (r *eventRing) push(evt StoredEvent) {
r.mu.Lock()
defer r.mu.Unlock()
r.events[r.head] = evt
r.head = (r.head + 1) % r.cap
if r.head == 0 && !r.full {
r.full = true
}
}
// replay 从 afterID 之后的所有事件回放给 ResponseWriter。
// afterID 为空时:缓冲区未满不回放(首次连接无历史);满了也不回放
// (首次连接的 EventSource 不传 Last-Event-ID)。
// afterID 非空时:找到该 ID 的位置,从下一条开始回放。
func (r *eventRing) replay(afterID string, flush http.Flusher, res http.ResponseWriter) {
r.mu.Lock()
defer r.mu.Unlock()
if afterID == "" {
return // 首次连接,不回放
}
start := -1
total := r.cap
for i := 0; i < r.cap; i++ {
idx := (r.head + i) % r.cap
if r.events[idx].ID == afterID {
start = (idx + 1) % r.cap
break
}
}
if start == -1 {
// afterID 不在缓冲区里(已被覆盖或从未存在),
// 回放缓冲区里所有事件 —— 宁可重复也不丢失
start = 0
if !r.full {
total = r.head
}
} else {
// 从 start 开始到 head 结束
total = r.head - start
if total < 0 {
total += r.cap
}
}
for i := 0; i < total; i++ {
idx := (start + i) % r.cap
evt := &r.events[idx]
if evt.ID == "" {
continue
}
fmt.Fprintf(res, "id: %s\nevent: %s\ndata: %s\n\n", evt.ID, evt.EventType, evt.Data)
}
flush.Flush()
}
// Client 是一个 SSE 连接客户端
type Client struct {
ID string
AgentName string // 非空 = Agent 侧连接
UserName string // 非空 = 已登录人类用户的前端连接
Res http.ResponseWriter
Flusher http.Flusher
done chan struct{}
// ★ writeMu 串行化对 Res 的**每一次**写入。
//
// 为什么必需(2026-09-28 实测,-race 证实):
// http.ResponseWriter **不是并发安全**的,而本结构原先**一把写锁都没有**。
// Manager.mu 只护 `clients` map 的**遍历**,遍历期间的 `c.SendWithID` 是并发的 ——
// 任何两条并发请求都会同时向同一个 client 写。
//
// 生产上会打中的三条路径:
// ① handler/permission.go:412-414 —— `SendToAgent(perm.AgentName, …)` 紧接
// `SendToUser(user.Username, …)`,两个不同 HTTP 请求(两个 goroutine)命中同一账号;
// ② 任意两条并发邮件:一封投给 B,B 的插件回信进 C 的 handler,而 A 的
// `notify.Recipients` 还没跑完;
// ③ `heartbeat` 那条 goroutine 每 10s 写一次(见 heartbeatInterval 的注释),
// 与推送撞车的概率随在线时长线性上升。
//
// 症状:SSE 是 `id: N\nevent: X\ndata: {…}\n\n` 的**文本协议**,两个 Fprintf
// 交错 ⇒ data 的 JSON 被劈成半截 ⇒ 客户端 EventSource 收到坏帧、丢事件。
// 实测 32 goroutine × 25 帧 = 800 帧,只切出 **459** 帧完整。
//
// 为什么不能靠上层串行化:推送方有 5 个入口(SendToUser / SendToAgent /
// SendToRecipient / Broadcast / replay),要保证"同一个 client 的所有写互斥",
// 责任只能落在 client 自己身上 —— 那是唯一能覆盖**全部**写者的位置。
writeMu sync.Mutex
}
// Manager 管理所有 SSE 客户端连接
type Manager struct {
mu sync.RWMutex
clients map[string]*Client
// eventBuffer:per-user/agent 的事件环形缓冲区,供 Last-Event-ID 回放。
// Key 是 userName(人类)或 agentName(Agent),二者共享一个 map。
// 不是连接级别的 —— 同一用户断线重连后仍能从同一个缓冲区拿到断线期间的事件。
eventBuffer map[string]*eventRing
bufMu sync.RWMutex
seqCounter uint64 // 全局递增序列号,用作事件 ID
seqMu sync.Mutex
}
// Default 是全局 SSE 管理器
var Default = &Manager{
clients: make(map[string]*Client),
eventBuffer: make(map[string]*eventRing),
}
const eventBufferCap = 500 // 每用户最多保留 500 条事件
// nextEventID 生成下一个全局递增的事件 ID
func (m *Manager) nextEventID() string {
m.seqMu.Lock()
defer m.seqMu.Unlock()
m.seqCounter++
return fmt.Sprintf("%d", m.seqCounter)
}
// getOrCreateRing 获取或创建用户的环形缓冲区
func (m *Manager) getOrCreateRing(key string) *eventRing {
if key == "" {
return nil
}
m.bufMu.RLock()
ring, ok := m.eventBuffer[key]
m.bufMu.RUnlock()
if ok {
return ring
}
m.bufMu.Lock()
defer m.bufMu.Unlock()
// double-check
if ring, ok = m.eventBuffer[key]; ok {
return ring
}
ring = newEventRing(eventBufferCap)
m.eventBuffer[key] = ring
return ring
}
// AddClient 注册一个新 SSE 客户端(agentName 与 userName 二者恰其一)
func (m *Manager) AddClient(res http.ResponseWriter, r *http.Request, agentName, userName string) *Client {
flusher, ok := res.(http.Flusher)
if !ok {
return nil
}
id := uuid.New().String()[:8]
client := &Client{
ID: id,
AgentName: agentName,
UserName: userName,
Res: res,
Flusher: flusher,
done: make(chan struct{}),
}
// 设置 SSE 响应头
res.Header().Set("Content-Type", "text/event-stream")
res.Header().Set("Cache-Control", "no-cache")
res.Header().Set("Connection", "keep-alive")
res.Header().Set("X-Accel-Buffering", "no")
// Last-Event-ID 回放:EventSource 断线重连时自带这个头,
// 服务端据此把断线期间的事件补上 —— 否则重连后永远看不到那段时间的邮件。
//
// ★ 持 writeMu 写入(尽管此时**按构造就是单写者**):
// 回放发生在下面 `m.clients[id] = client` **之前** —— 此刻还没有任何 goroutine
// 拿得到这个 client 的指针,所以本身上就是安全的。持锁是为了让「对 Res 的写入
// 一律经由 writeMu」成为**结构上**的纪律:将来有人把注册提前、或把回放挪到
// 注册之后(很自然的一个改动),没上锁的版本会**静默**退化成并发写。
// 锁在这里零成本,而它买的正是「后人改顺序也不会破」这件事。
lastID := r.Header.Get("Last-Event-ID")
key := m.bufferKey(userName, agentName)
if ring := m.getOrCreateRing(key); ring != nil && lastID != "" {
client.writeMu.Lock()
ring.replay(lastID, flusher, res)
client.writeMu.Unlock()
}
m.mu.Lock()
m.clients[id] = client
m.mu.Unlock()
// 发送连接确认(带 id 让客户端知道自己的 ID)
evtID := m.nextEventID()
client.SendWithID(evtID, "connected", map[string]string{"id": id})
// 启动心跳
go m.heartbeat(client)
fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q) lastID=%q\n", id, agentName, userName, lastID)
return client
}
// bufferKey 返回缓冲区 key:优先 userName(人类),其次 agentName(Agent)
func (m *Manager) bufferKey(userName, agentName string) string {
if userName != "" {
return "u:" + userName
}
if agentName != "" {
return "a:" + agentName
}
return ""
}
// RemoveClient 移除一个客户端
func (m *Manager) RemoveClient(id string) {
m.mu.Lock()
if c, ok := m.clients[id]; ok {
close(c.done)
delete(m.clients, id)
fmt.Printf("[SSE] Client disconnected: %s\n", id)
}
m.mu.Unlock()
}
// SendToAgent 向指定 Agent 名的所有客户端推送事件
func (m *Manager) SendToAgent(agentName, eventType string, data interface{}) {
if agentName == "" {
return
}
// 写入缓冲区
evtID := m.nextEventID()
raw, _ := json.Marshal(data)
if ring := m.getOrCreateRing(m.bufferKey("", agentName)); ring != nil {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.AgentName == agentName {
c.SendWithID(evtID, eventType, data)
}
}
}
// SendToUser 向指定人类用户的所有前端连接推送事件
func (m *Manager) SendToUser(userName, eventType string, data interface{}) {
if userName == "" {
return
}
evtID := m.nextEventID()
raw, _ := json.Marshal(data)
if ring := m.getOrCreateRing(m.bufferKey(userName, "")); ring != nil {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.UserName == userName {
c.SendWithID(evtID, eventType, data)
}
}
}
// SendToRecipient 根据收件人名同时尝试 Agent 通道与人类用户通道
func (m *Manager) SendToRecipient(name, eventType string, data interface{}) {
if name == "" {
return
}
evtID := m.nextEventID()
raw, _ := json.Marshal(data)
// 同时写两个缓冲区(人类或 Agent,或两者都有)
if ring := m.getOrCreateRing(m.bufferKey(name, "")); ring != nil {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
}
if ring := m.getOrCreateRing(m.bufferKey("", name)); ring != nil {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.AgentName == name || c.UserName == name {
c.SendWithID(evtID, eventType, data)
}
}
}
// Broadcast 向所有客户端广播事件(心跳、系统通知等)
func (m *Manager) Broadcast(eventType string, data interface{}) {
evtID := m.nextEventID()
raw, _ := json.Marshal(data)
// 广播写入所有用户的缓冲区(确保任何用户重连都能回放)
m.bufMu.RLock()
for key, ring := range m.eventBuffer {
ring.push(StoredEvent{ID: evtID, EventType: eventType, Data: raw, Timestamp: time.Now()})
_ = key // key 仅用于日志,此处不需
}
m.bufMu.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
c.SendWithID(evtID, eventType, data)
}
}
// ClientCount 返回当前连接数
func (m *Manager) ClientCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.clients)
}
// Send 向单个客户端发送事件(无 ID)
func (c *Client) Send(eventType string, data interface{}) {
defer func() { recover() }()
jsonData, err := json.Marshal(data)
if err != nil {
return
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
c.Flusher.Flush()
}
// SendWithID 向单个客户端发送带 ID 的事件
func (c *Client) SendWithID(id, eventType string, data interface{}) {
defer func() { recover() }()
jsonData, err := json.Marshal(data)
if err != nil {
return
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
fmt.Fprintf(c.Res, "id: %s\nevent: %s\ndata: %s\n\n", id, eventType, jsonData)
c.Flusher.Flush()
}
// heartbeatInterval —— 心跳间隔。
//
// ★ 2026-09-15 实测(用户报「每次点击按钮 1-2s 延迟」):他的 SSE 连接每次只活
// 34.6s / 39.4s / 56.9s 就被关闭(网关日志里 /events/stream 的耗时即连接寿命),
// 而普通 API 只要 30-58ms —— 说明不是服务端慢,是**连接被中间反代按空闲超时掐掉**,
// 而我们的心跳是 30s,正好与那个超时擦边:晚一点就被判空闲。
//
// 心跳必须**明显小于**常见的 30s/60s 代理读超时,而不是与它相当。10s 留了三倍余量,
// 代价只是每 10s 一个 16 字节的注释帧。
const heartbeatInterval = 10 * time.Second
// heartbeat 定期发送心跳保活
func (m *Manager) heartbeat(client *Client) {
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-client.done:
return
case <-ticker.C:
defer func() { recover() }()
// ★ 同一把 writeMu:心跳是本结构里**第三条**写 Res 的路径。
// 漏了它就等于"推送之间互斥、心跳不参与"—— 而心跳每 10s 一次、
// 覆盖连接的全部存活期,撞上推送是必然事件(见 writeMu 的注释 ③)。
client.writeMu.Lock()
fmt.Fprintf(client.Res, ": heartbeat\n\n")
client.Flusher.Flush()
client.writeMu.Unlock()
}
}
}