55 "encoding/json"
66 "errors"
77 "fmt"
8+ "sync"
89 "sync/atomic"
910)
1011
@@ -29,12 +30,40 @@ type SSEStreamOp interface {
2930// ⚠️ 背压限制:SSE 写底层无写超时(hertz app 层未暴露 write deadline),
3031// 读取停滞的客户端(TCP 窗口耗尽)会阻塞 Emit 调用——Hub 串行扇出场景
3132// 会连带阻塞其他 sink。生产部署建议在网关/负载均衡层配置发送空闲超时,
32- // 或避免把多个 sink 挂在同一个串行 Hub 上。
33+ // 或避免把多个 sink 挂在同一个串行 Hub 上。同型限制也作用在 Close 上:
34+ // Close 等在途 Emit 写完才关 stream(见 mu),慢消费者的在途写会推迟
35+ // Close 返回。
3336type SSESink struct {
3437 stream SSEStreamOp
38+ // mu 让「Emit 的关闭检查 + stream 写入」成为同一个临界区(#144,与
39+ // wsutil.Outbox 的 #117 同构修法):Emit 持 RLock,Close 持 Lock。
40+ //
41+ // 为什么必须是同一临界区:旧实现先 closed.Load() 再写 stream,两步之间
42+ // 没有 happens-before——Close 可落在中间:通过检查的 Emit 把事件写进
43+ // **已关闭**的 stream,返回底层 stream 错误而非 ErrSinkClosed(契约
44+ // 「Close 关闭 sink,后续 Emit 返回 ErrSinkClosed」被击穿),Hub 把正常
45+ // 关闭当 sink 故障留痕(hub.go 的 "streamx sink emit failed"),断线
46+ // 续传窗口的最后一帧落进「写了但报错」的灰色态。RWMutex 的排他性堵住
47+ // 这个交错:Lock 等所有 RLock 离场 ⇒ Close 的临界区开始时所有在途
48+ // Emit 已写完,之后的 Emit 一律收 ErrSinkClosed、stream 不再被写。
49+ //
50+ // 代价(契约明示,issue #144 两选一中取「Close 等待在途 Emit」):
51+ // Emit 是阻塞写,Close 等在途 Emit 完成后才关 stream——慢消费者的
52+ // 在途写会推迟 Close 返回(背压限制同型,见类型文档);换来「stream
53+ // 关闭不与在途写并发」的确定性(hertz stream 的写-关并发行为未定义,
54+ // 正是要堵的窗口)。
55+ mu sync.RWMutex
3556 // closed 用 atomic:文档化用法是 handler defer Close 与 Hub goroutine
3657 // Emit 并发(见 NewSSESinkFromFuncs 示例),裸 bool 是数据竞争。
58+ // 置位发生在 Close 的临界区内(与 stream 关闭原子),读取发生在
59+ // Emit 的 RLock 内(与写入原子)。
3760 closed atomic.Bool
61+ // closeOnce 保证 stream.Close 只下发一次、重复 Close 返回 nil——
62+ // 矩阵硬规则「组件 Close 统一 Close(ctx) error + sync.Once 幂等」的
63+ // 幂等半句;用 sync.Once + 局部结果而非 sync.OnceFunc/OnceValue:
64+ // 「仅首个调用返回关闭结果、后续返回 nil」的口径(nats.Client.Close
65+ // 同款)需要 Do 内赋值局部变量的形态,OnceValue 的结果缓存口径不同。
66+ closeOnce sync.Once
3867}
3968
4069// NewSSESinkFromFuncs 显式传入 4 个方法构造 SSESink(避免 ekit package
@@ -72,6 +101,11 @@ func (a sseFuncAdapter) Close() error { return a.close()
72101
73102// Emit 实现 Sink。
74103//
104+ // 「closed 检查 + stream 写入」持 RLock 是同一个临界区(#144,与
105+ // wsutil.Outbox.Send 的 #117 同构口径):不存在「检查通过了、事件却落在
106+ // Close 之后」的交错——写入只会发生在 stream 关闭之前,或干脆不发生而
107+ // 返回 ErrSinkClosed。
108+ //
75109// ID 透传(2026-09-06 复审修复):Last-Event-ID 断线续传依赖 event id,
76110// 原实现对 JSON 载荷走 Send 把 ev.ID 整个丢掉 — 现在带 ID 的事件一律
77111// 编码后走 SendRaw。[]byte 载荷仍直走 SendRaw(避免 JSON 双编码)。
@@ -80,6 +114,8 @@ func (a sseFuncAdapter) Close() error { return a.close()
80114// 位,上游 hertz sse.Writer.WriteEvent 虽支持,ekit 适配层未暴露 — 置该
81115// 字段无效(字段文档已同步修正,不再宣称"仅 SSE 生效")。
82116func (s * SSESink ) Emit (_ context.Context , ev Event ) error {
117+ s .mu .RLock ()
118+ defer s .mu .RUnlock ()
83119 if s .closed .Load () {
84120 return ErrSinkClosed
85121 }
@@ -96,12 +132,24 @@ func (s *SSESink) Emit(_ context.Context, ev Event) error {
96132 return s .stream .Send (ev .Type , ev .Data )
97133}
98134
99- // Close 实现 Sink,幂等。Swap 保证并发 Close 只有首个真正下发。
135+ // Close 实现 Sink,幂等:仅首个调用执行 stream.Close 并返回其结果,
136+ // 后续调用直接返回 nil(nats.Client.Close 同口径)。
137+ //
138+ // 「置 closed + 关 stream」持写锁,与 Emit 的临界区互斥(#144):Close
139+ // 等在途 Emit 写完才关 stream,之后的 Emit 一律返回 ErrSinkClosed、不再
140+ // 触碰 stream——契约「Close 关闭 sink,后续 Emit 返回 ErrSinkClosed」在
141+ // 并发形态下(handler defer Close 与 Hub goroutine Emit 并发,见类型
142+ // 文档)同样成立。代价是慢消费者的在途写会推迟 Close 返回(背压限制
143+ // 同型)。
100144func (s * SSESink ) Close () error {
101- if s .closed .Swap (true ) {
102- return nil
103- }
104- return s .stream .Close ()
145+ var closeErr error
146+ s .closeOnce .Do (func () {
147+ s .mu .Lock ()
148+ defer s .mu .Unlock ()
149+ s .closed .Store (true )
150+ closeErr = s .stream .Close ()
151+ })
152+ return closeErr
105153}
106154
107155// ErrSinkEmpty 保留 sentinel(避免 unused 误删,实际用 ErrSinkClosed)。
0 commit comments