diff --git a/server/internal/sse/manager.go b/server/internal/sse/manager.go index 2f8ccaf..4bc436b 100644 --- a/server/internal/sse/manager.go +++ b/server/internal/sse/manager.go @@ -351,9 +351,20 @@ func (c *Client) SendWithID(id, eventType string, data interface{}) { 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(30 * time.Second) + ticker := time.NewTicker(heartbeatInterval) defer ticker.Stop() for { diff --git a/server/internal/sse/manager_test.go b/server/internal/sse/manager_test.go index a9dab41..973d0ac 100644 --- a/server/internal/sse/manager_test.go +++ b/server/internal/sse/manager_test.go @@ -98,3 +98,16 @@ func TestEventRingConcurrent(t *testing.T) { } // 只验证不 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) + } +}