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

114 lines
3.2 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 (
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestEventRingPushReplay(t *testing.T) {
ring := newEventRing(5)
// 推 3 条
for i := 1; i <= 3; i++ {
ring.push(StoredEvent{
ID: string(rune('0' + i)),
EventType: "test",
Data: []byte(`{"n":` + string(rune('0'+i)) + `}`),
Timestamp: time.Now(),
})
}
// 空 afterID → 首次连接,不回放(缓冲区未满)
rec := httptest.NewRecorder()
ring.replay("", rec, rec)
if rec.Body.Len() > 0 {
t.Error("首次连接不应回放事件,实际:", rec.Body.String())
}
// 有 afterID → 从下一条开始回放
rec2 := httptest.NewRecorder()
ring.replay("1", rec2, rec2)
body := rec2.Body.String()
if !strings.Contains(body, "id: 2") {
t.Error("afterID=1 应该回放 id:2,实际:", body)
}
if !strings.Contains(body, "id: 3") {
t.Error("afterID=1 应该回放 id:3,实际:", body)
}
if strings.Contains(body, "id: 1") {
t.Error("afterID=1 不应回放 id:1,实际:", body)
}
// 不存在的 afterID → 从头回放全部
rec3 := httptest.NewRecorder()
ring.replay("999", rec3, rec3)
body3 := rec3.Body.String()
if !strings.Contains(body3, "id: 1") {
t.Error("不存在的 afterID 应从头回放,实际:", body3)
}
}
func TestEventRingOverflow(t *testing.T) {
ring := newEventRing(3)
// 推 5 条(超过容量 3,最旧的 2 条被覆盖)
for i := 1; i <= 5; i++ {
ring.push(StoredEvent{
ID: string(rune('0' + i)),
EventType: "test",
Data: []byte(`{}`),
Timestamp: time.Now(),
})
}
if !ring.full {
t.Fatal("推了 5 条进容量 3 的缓冲区,应该已满")
}
// afterID=2 已被覆盖 → 找不到位置,从头回放全部
rec := httptest.NewRecorder()
ring.replay("2", rec, rec)
body := rec.Body.String()
if !strings.Contains(body, "id: 3") || !strings.Contains(body, "id: 5") {
t.Error("缓冲区溢出后应能回放可用范围,实际:", body)
}
}
func TestEventRingConcurrent(t *testing.T) {
ring := newEventRing(100)
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func() {
for j := 0; j < 200; j++ {
ring.push(StoredEvent{
ID: "evt",
EventType: "test",
Data: []byte(`{}`),
Timestamp: time.Now(),
})
}
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
// 只验证不 panic,不验证内容(并发下顺序无意义)
}
// TestHeartbeatIntervalIsWellUnderProxyIdleTimeout —— 心跳必须**明显小于**常见代理读超时。
//
// 2026-09-15 实测(用户报「每次点击按钮 1-2s 延迟」):他的 SSE 连接每次只活
// 34.6s / 39.4s / 56.9s 就被关闭,而普通 API 只要 30-58ms —— 掐连接的不是我们,
// 是中间那层反代的空闲超时,而当时的心跳是 30s,正好与它擦边。
// 这条判据钉的不是"某个数字",而是**量级关系**:心跳要留出余量,不能与超时相当。
func TestHeartbeatIntervalIsWellUnderProxyIdleTimeout(t *testing.T) {
if heartbeatInterval >= 20*time.Second {
t.Fatalf("心跳间隔 %s 与常见的 30s 代理读超时擦边:实测连接只活 34-57s 就断,"+
"心跳必须明显小于该超时(当前上限取 20s)", heartbeatInterval)
}
}