用户报「每次点击按钮 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/重连 (服务端看到的寿命 = 对方关掉的时刻)。这条改动是**正确的加固**(心跳必须明显小于 任何合理超时),但不构成对那个症状的修复;真正定位还需要用户浏览器侧的日志。
114 lines
3.2 KiB
Go
114 lines
3.2 KiB
Go
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)
|
||
}
|
||
}
|