Skip to content

Commit febfb9b

Browse files
authored
Merge pull request #152 from oekit/fix/sweep-20261111-network
fix: 审计修复轮 v0.10.0——#144 SSESink Emit/Close 原子化 / #143 MQClient.Close(ctx) BREAKING / #142 writeMu 条目回收
2 parents 7819d35 + 3624e2e commit febfb9b

12 files changed

Lines changed: 511 additions & 33 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# AGENTS.md — oekit/network
22

3-
> 轻量网络客户端——WebSocket/NATS/MQ 抽象、连接与流工具
4-
> 当前版本:`v0.9.2` · module:`github.com/oekit/network` · org:[oekit = Open Easy Kit](https://oekit.github.io)
3+
> 轻量网络客户端——WebSocket/NATS/MQ 抽象、连接与流工具;v0.10.0 审计修复轮——SSESink Emit/Close 原子化(#144)、MQClient.Close 统一 Close(ctx) error(BREAKING,#143)、Broadcaster 写锁条目随 Conn 回收(#142)
4+
> 当前版本:`v0.10.0` · module:`github.com/oekit/network` · org:[oekit = Open Easy Kit](https://oekit.github.io)
55
66
## 本仓包含
77
`conn/`、`mq/`、`nats/`、`peercred/`、`sshx/`、`streamx/`、`wsutil/`

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog — oekit/network
22

3+
## [v0.10.0] — 2026-11-11
4+
5+
- fix(streamx): SSESink.Emit 的关闭检查与 stream 写入原子化(#144)——#117 在 wsutil.Outbox 修掉的同构竞态换包复活:closed 检查与写入两步无 happens-before,Close 可插进中间,通过检查的 Emit 把事件写进已关 stream、返回底层 stream 错误而非 ErrSinkClosed(契约「Close 后 Emit 返回 ErrSinkClosed」被击穿,Hub 把正常关闭当 sink 故障留痕)。现照 #117 修法把两步纳入同一临界区(Emit 持 RLock、Close 持 Lock,Close 等在途 Emit 写完才关 stream——慢消费者在途写会推迟 Close 返回,契约明示),Sink 接口契约同步写明原子要求
6+
7+
关联问题单:oekit/network#144。
8+
9+
- fix(mq): MQClient.Close 改 Close(ctx) error(BREAKING)(#143)——门面此前把无 ctx 的 Close() error 写进契约,与 AGENTS.md「组件 Close 统一 Close(ctx) error」硬规则及同仓 nats.Client 实现三方分裂:实现者只能无界关停(#83/#86 事故形态,ctx 上界入口都没有)或放弃门面。现接口与全部实现统一 Close(ctx) error(Go 不支持同名重载,无参形态移除,调用点改 c.Close(context.Background())),Requester.Request/RequestJSON 的「ctx 截止与 timeout 谁压谁」同步写死:先到先生效(min 语义,context.WithTimeout 同款),timeout ≤ 0 不是「不限时」而是立即到期(同 sshx #106 的两截止源教训)
10+
11+
关联问题单:oekit/network#143。
12+
13+
- fix(wsutil): Broadcaster.writeMu 锁条目随 Conn 生命周期回收(#142)——条目此前无任何 Delete 路径,每见过一个 Conn 永久多一条 Conn→锁 强引用,断开的 websocket Conn 连同读写缓冲永远无法 GC,轮换型推送服务 RSS 随历史连接总数线性增长、永不回落。现按安全回收协议回收(无在途写协程窗口 且 conn 不属任何房间才删,users 计数盖住「取到条目、尚未加锁」的缝隙,恒等校验防换锁):写协程归还与 Unregister 收尾两处收尾,#87 的同锁串行保证不受影响(在途窗口内条目必留)
14+
15+
关联问题单:oekit/network#142。
16+
17+
- 现代语言特性(贴合处落地):clear(Broadcaster.Close 丢弃条目表)、for range int(churn 轮换回归)、errors.Join(并发 emit/close 错误聚合)、sync.Once(SSESink.Close 幂等——「仅首次返回结果」口径贴 nats.Close 先例,故用 Do + 局部结果而非 OnceValue 的结果缓存形态)
18+
319
## [v0.9.2] — 2026-11-11
420

521
- fix(nats): 订阅 handler 的 panic 就地收编——handler 跑在 nats.go 无 recover 的派发 goroutine(waitForMsgs 直接 mcb(m))里,一条语义残缺消息的 panic 会穿透炸掉整个进程、连同其上全部订阅与在途请求一并消灭;现在 Subscribe/QueueSubscribe(覆盖 SubscribeJSON/QueueSubscribeJSON/Reply 全部漏斗)的 handler 调用点统一 panic 收编(Error 日志 + 该条消息丢弃留痕,与 wsutil「只死这条连接」同口径),订阅与进程存活(#145)

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
## 安装
99

1010
```bash
11-
go get github.com/oekit/network@v0.9.2
11+
go get github.com/oekit/network@v0.10.0
1212
```
1313

1414
## 这个仓库做什么

‎mq/doc.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
// 支持多种后端实现(NATS、Kafka、RabbitMQ 等)。
55
//
66
// 当前实现:
7-
// - nats: 基于 NATS 的客户端(注意:尚未提供 mq.MQClient 适配器,需自行桥接)
7+
// - nats: 基于 NATS 的客户端(Close(ctx) error 已符合门面签名(#143);
8+
// Request 返回 *nats.Msg 与门面 []byte 形态不同,尚未提供 mq.MQClient
9+
// 适配器,需自行桥接)
810
package mq

‎mq/mq.go‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,19 @@ type Subscriber interface {
3535
// Requester 请求-应答接口。
3636
type Requester interface {
3737
// Request 发送请求并等待应答。
38+
//
39+
// 截止契约(#143,两个截止源并存必须定义交互,同 sshx #106 的教训):
40+
// ctx 截止与 timeout 谁也不压谁——**谁先到谁生效**,有效截止取两者
41+
// 较早者(context.WithTimeout 的合成语义,参考实现 nats.Client.Request
42+
// 即如此合成)。timeout ≤ 0 **不是**「不限时」:按同一语义视为立即
43+
// 到期——本接口的每个请求必然有界,长等待请把 timeout 设为上界值
44+
// (ctx 先到仍先生效)。
3845
Request(ctx context.Context, subject string, data []byte, timeout time.Duration) ([]byte, error)
3946

4047
// RequestJSON 发送 JSON 请求并等待 JSON 应答。
48+
//
49+
// 截止契约与 [Requester.Request] 相同:ctx 截止与 timeout 先到先生效,
50+
// timeout ≤ 0 视为立即到期。
4151
RequestJSON(ctx context.Context, subject string, req any, resp any, timeout time.Duration) error
4252
}
4353

@@ -48,7 +58,17 @@ type MQClient interface {
4858
Requester
4959

5060
// Close 关闭客户端连接。
51-
Close() error
61+
//
62+
// 签名遵循矩阵硬规则「组件 Close 统一 Close(ctx) error + sync.Once 幂等」
63+
// (AGENTS.md):ctx 是关停的**上界入口**——Drain/flush 遇慢消费者不得
64+
// 无界阻塞(#83/#86 的事故形态),ctx 到期须回退强制关闭(修法参照
65+
// nats.Client.Close);实现须幂等,重复 Close 返回 nil。
66+
//
67+
// ⚠️ BREAKING(#143,0.x 允许,参照 nats PR #91 先例):原 `Close() error`
68+
// 移除——无 ctx 的门面签名把「无界关停」定成了契约,实现者连给关停设
69+
// 上界的入口都没有;Go 不支持同名方法重载,无法保留兼容别名。调用点
70+
// 改为 `c.Close(context.Background())`,要上界就传带截止的 ctx。
71+
Close(ctx context.Context) error
5272
}
5373

5474
// MessageHandler 消息处理函数。

‎mq/mq_test.go‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,13 @@ type mockMQClient struct {
7878
mockPublisher
7979
mockSubscriber
8080
mockRequester
81-
closed bool
81+
closed bool
82+
closeCtx context.Context
8283
}
8384

84-
func (m *mockMQClient) Close() error {
85+
func (m *mockMQClient) Close(ctx context.Context) error {
8586
m.closed = true
87+
m.closeCtx = ctx
8688
return nil
8789
}
8890

@@ -203,7 +205,7 @@ func TestRequester_RequestJSON(t *testing.T) {
203205

204206
func TestMQClient_Close(t *testing.T) {
205207
c := &mockMQClient{}
206-
err := c.Close()
208+
err := c.Close(context.Background())
207209
if err != nil {
208210
t.Fatalf("unexpected error: %v", err)
209211
}
@@ -242,7 +244,56 @@ func TestMQClient_FullWorkflow(t *testing.T) {
242244
}
243245

244246
// Close
245-
if err := c.Close(); err != nil {
247+
if err := c.Close(context.Background()); err != nil {
246248
t.Fatalf("close error: %v", err)
247249
}
248250
}
251+
252+
// --- #143 回归:门面 Close 必须带 ctx 上界入口 ---
253+
254+
// ctxCloseClient 是「合规实现者」的形态(#143):Close 带 ctx,可为关停
255+
// 设上界(Drain/flush 遇慢消费者时按 ctx 截止回退强制关闭)。
256+
//
257+
// 修复前 MQClient 把无 ctx 的 Close() error 写死进契约,此形态**编译不过**
258+
// (var _ MQClient 断言失败)——正是 issue 的失败场景:合规实现者被迫
259+
// fork 或放弃门面,门面反而约束出无界关停(#83/#86 事故形态)。
260+
type ctxCloseClient struct {
261+
mockPublisher
262+
mockSubscriber
263+
mockRequester
264+
gotCtx context.Context
265+
}
266+
267+
func (c *ctxCloseClient) Close(ctx context.Context) error {
268+
c.gotCtx = ctx
269+
return nil
270+
}
271+
272+
// TestMQClient_AcceptsContextBoundClose 钉住 #143:带 ctx 的 Close 能满足
273+
// MQClient(修复前编译失败),且 ctx 透传至关停动作(上界入口不能丢)。
274+
func TestMQClient_AcceptsContextBoundClose(t *testing.T) {
275+
impl := &ctxCloseClient{}
276+
var c MQClient = impl
277+
want, cancel := context.WithCancel(context.Background())
278+
defer cancel()
279+
if err := c.Close(want); err != nil {
280+
t.Fatalf("Close: %v", err)
281+
}
282+
if got := impl.gotCtx; got != want {
283+
t.Errorf("Close ctx = %v, want %v(ctx 须原样透传,关停上界入口不能丢)", got, want)
284+
}
285+
}
286+
287+
// TestMockMQClient_CloseReceivesContext 钉住 mock 自身的形态与接口一致:
288+
// Close 带 ctx(修复前 mock 钉的是违规签名 Close() error,测试绿反而把
289+
// 错误契约固化进了套件)。
290+
func TestMockMQClient_CloseReceivesContext(t *testing.T) {
291+
c := &mockMQClient{}
292+
want := context.Background()
293+
if err := c.Close(want); err != nil {
294+
t.Fatalf("Close: %v", err)
295+
}
296+
if c.closeCtx != want {
297+
t.Errorf("closeCtx = %v, want %v", c.closeCtx, want)
298+
}
299+
}

‎streamx/event.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ type Sink interface {
6565
// 客户端断开)。
6666
Emit(ctx context.Context, ev Event) error
6767
// Close 关闭 sink,后续 Emit 返回 ErrSinkClosed。
68+
//
69+
// 实现须保证「关闭检查 + 写入」原子(#144):Close 与 Emit 并发交错
70+
// 时不得把事件写进已关底层流,交错后的 Emit 必须返回 ErrSinkClosed
71+
// 而非底层流错误——同构修法见 SSESink 与 wsutil.Outbox(#117)。
6872
Close() error
6973
}
7074

‎streamx/sse_sink.go‎

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
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 返回。
3336
type 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 生效")。
82116
func (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+
// 同型)。
100144
func (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

Comments
 (0)