编号:T-01 严重级:medium 工作线:测试与文档(test/harness、test/accept、docs) 来源:总审查人实证(复审 #3) 依赖:无 被依赖:所有依赖 WebSocket 集成测试的问题(建议最先合入)
本 issue 更正 #3 原先的"上行 worker 同步 PublishDown 与 mochi InlineClient 死锁"结论。该结论经实证不成立,下方"原始描述"保留供追溯。
TestUplinkDMOfflineGroupRecall 等"resp 回不来"不是服务端死锁,是测试用 WebSocket 客户端 test/harness/mqtt.go 的两个缺陷:
TestUplinkDMOfflineGroupRecall
test/harness/mqtt.go
wsMQTT.Recv
test/harness/mqtt.go:173-192
outbuf
Write
clients.go:602-631
writeWSClientFrame
:249-277
wsMQTT.Send
:169-171
WS frame carries 3 MQTT packets: [group_event group_event resp/rid=g1]
timeout waiting resp rid=g1
g1
s4
cmd/nixmsg
test/accept
internal/app/group
internal/broker
通过的轮次里也一直在丢包(receipt+receipt、group_event+group_event 各丢第二个),只是现有断言没覆盖。
receipt+receipt
group_event+group_event
所有经 harness.DialMQTTWebSocket 的测试客户端:cmd/nixmsg/*_test.go(mqttSess)、test/accept(MQTTSession)、test/chaos、test/load/q3_live_test.go、web/e2e/seedpending。此前 Q accept-rest 里"F15 带密拉人用独立短生命周期进程""group.add 带密码卡住"等绕行,以及 internal/app/group/app.go:659-686 的 goroutine + time.Sleep(20ms),都是这个缺陷的误诊产物。
harness.DialMQTTWebSocket
cmd/nixmsg/*_test.go
test/chaos
test/load/q3_live_test.go
web/e2e/seedpending
internal/app/group/app.go:659-686
time.Sleep(20ms)
wsMQTT
buf []byte
wmu sync.Mutex
Recv
0x2
0x0
0x8
0x9
0xA
Send
internal/app/group/app.go
emit
PublishDown
group_event
test/accept/rest_accept_test.go
runF15JoinWithPasswordFresh
group.add
docs/DEVIATIONS.md
E:\code\NixMsg-wt\fix3
feat/fix-3-downlink-deadlock
OnPublish
type wsMQTT struct { conn net.Conn r *bufio.Reader buf []byte wmu sync.Mutex } func splitMQTTPacket(b []byte) ([]byte, int, bool) { if len(b) < 2 { return nil, 0, false } rem, mult := 0, 1 for i := 1; i < len(b) && i <= 4; i++ { rem += int(b[i]&127) * mult if b[i]&128 == 0 { total := 1 + i + rem if len(b) < total { return nil, 0, false } pkt := make([]byte, total) copy(pkt, b[:total]) return pkt, total, true } mult *= 128 } return nil, 0, false } func (c *wsMQTT) Send(packet []byte) error { c.wmu.Lock() defer c.wmu.Unlock() return writeWSClientBinary(c.conn, packet) } func (c *wsMQTT) Recv() ([]byte, error) { for { if pkt, n, ok := splitMQTTPacket(c.buf); ok { c.buf = c.buf[n:] return pkt, nil } payload, opcode, err := readWSFrame(c.r) if err != nil { return nil, err } switch opcode { case 0x0, 0x2: c.buf = append(c.buf, payload...) case 0x8: return nil, io.EOF case 0x9: c.wmu.Lock() _ = writeWSClientControl(c.conn, 0xA, payload) c.wmu.Unlock() } } } // writeWSClientFrame 末尾:帧头与掩码载荷拼成一个切片,一次写出 frame := make([]byte, len(header)+n) copy(frame, header) for i := 0; i < n; i++ { frame[len(header)+i] = payload[i] ^ mask[i%4] } _, err := w.Write(frame) return err
test/harness/mqtt.go、internal/app/group/app.go(仅 emit)、test/accept/rest_accept_test.go(仅 F15)、docs/DEVIATIONS.md。
go test ./cmd/nixmsg/ -run TestUplinkDMOfflineGroupRecall -count=20
task check
go test ./test/... -count=1
medium
设计缺陷 / 竞态
同一连接上 group.create / group.add 若在处理上行的 worker 里同步向本连接注入下行(server.Publish → InlineClient InjectPacket),会与 mochi InlineClient 互相等待,应用层 resp 发不回去。当前实现把 group_event 放到独立 goroutine 并 Sleep(20ms),让 worker 先 PublishDown resp。这是竞态窗口,不是根因修复:负载升高、调度延迟偏离 20ms、或其它仍同步的下行,仍可能卡住。
group.create
server.Publish
InjectPacket
resp
Sleep(20ms)
补测 TestUplinkDMOfflineGroupRecall 在清掉测试客户端 dial deadline 后可稳定复现(见 DEVIATIONS Q accept-rest 第 4 条)。
internal/app/group/void.go
publishRevokes
Leave
Remove
Dissolve
internal/broker/hooks.go
internal/broker/broker.go
internal/broker/queue.go
HandleUplink
cmd/nixmsg/uplink.go
replyOK
internal/app/message/push.go
WakePush
Submit
PushPending
InlineClient = true
20ms 不是 PRD/DEVELOPMENT 的协议语义,是补测为避开死锁加的睡眠。根因(上行 worker 调用栈上同步 Inline Publish)还在。group_event 绕开了,revoked 等同步路径没有。已知清单里的「群 emit 异步+20ms」是本波处理项,但死锁根因仍在,应按设计缺陷跟踪,而不是当作已修完。
Publish
revoked
不要用 time.Sleep 赌调度。正确修法例如:
time.Sleep
broker.PublishDown
复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。
4059a15
未关闭,也未合入 feat/fix-3-downlink-deadlock。当前 main 是 4059a1576bc97879b840ec5200defd80bc9eb3f0,其中仍保留 20ms 绕过。分析已写入仓库 docs/DEVIATIONS.md 末尾「### 死锁未修(issue #3)」。工作树 e:\code\NixMsg-wt\fix3 保留给工程师,里面的未提交改动未验证、不进 main。
4059a1576bc97879b840ec5200defd80bc9eb3f0
e:\code\NixMsg-wt\fix3
现象:每端上行串行 worker(internal/broker/queue.go 的 loop)同步调用 HandleUplink。群操作在同一调用栈里再对本连接 PublishDown group_event。PublishDown 走 mochi server.Publish → InlineClient InjectPacket,与读循环写 PUBACK 抢同一把 Client 锁,QoS 1 的 resp 回不去。清掉测试客户端 dial deadline 后,TestUplinkDMOfflineGroupRecall 在 group.create(rid=g1)稳定超时。
尝试 1:emit 立刻起 goroutine 再 PublishDown,仍与 resp 抢同一连接,仍然死锁。 尝试 2(已在 main,来自 479a08e):emit 的 goroutine 里 Sleep(20ms) 再下发,让 resp 先出去。当时 task check 通过。这是时间差绕过,不是根因修复;presence 等其他同步 PublishDown 仍可能卡。 尝试 3(叫停):分支停在 479a08e,没有可合的提交。未提交改动在 broker 层:OnPublish 先在读循环写完 PUBACK 再入队;该端 HandleUplink 期间对本连接的下行只入延后队列,handler 返回后再 server.Publish;group emit 改回同步并去掉 sleep。InlineClient 放行未改。本会话未对其跑 task check。
库约束:DEVELOPMENT 要求 InlineClient=true;OnPublish 对 InlineClient 必须放行,否则 PublishDown 送不到订阅者。 建议方向:broker 把对本连接的下行 InjectPacket 与上行 worker 解耦,不要靠固定 Sleep。
已按 2026-09-30 全量复审更新本 issue:原先的"服务端死锁"结论不成立,真实根因是测试用 WebSocket 客户端不按字节流拆包、并发写不加锁(对照实验见正文)。本 issue 现为工作包 T-01,请按正文方案修复;原始描述保留在正文末尾。
No dependencies set.
The note is not visible to the blocked user.
编号:T-01 严重级:medium 工作线:测试与文档(test/harness、test/accept、docs) 来源:总审查人实证(复审 #3)
依赖:无 被依赖:所有依赖 WebSocket 集成测试的问题(建议最先合入)
结论
TestUplinkDMOfflineGroupRecall等"resp 回不来"不是服务端死锁,是测试用 WebSocket 客户端test/harness/mqtt.go的两个缺陷:wsMQTT.Recv(test/harness/mqtt.go:173-192)把一个 WS 二进制帧当作恰好一个 MQTT 包返回。mochi 在客户端出站队列有积压时,会把多个包写进outbuf后一次Write(mochiclients.go:602-631),经 coder/websocket 成为一个 WS 消息。MQTT 5.0 §6.0 [MQTT-6.0.0-3] 明确规定接收方不得假设控制包与 WS 帧对齐。客户端只解出第一个包,其余全部丢弃。writeWSClientFrame(:249-277)把帧头和载荷分两次Write,wsMQTT.Send(:169-171)没有锁。读循环回 PUBACK 与测试线程发请求并发时,两帧交错,发往服务端的 WS 流被写乱,服务端卡在读一个错乱长度的包上,而且不打任何报错日志。实证(基线 main 4059a15,群事件改回同步下发、不带 20ms sleep)
WS frame carries 3 MQTT packets: [group_event group_event resp/rid=g1],随后timeout waiting resp rid=g1g1、s4),无服务端报错cmd/nixmsg、test/accept、internal/app/group、internal/broker全部通过通过的轮次里也一直在丢包(
receipt+receipt、group_event+group_event各丢第二个),只是现有断言没覆盖。影响范围
所有经
harness.DialMQTTWebSocket的测试客户端:cmd/nixmsg/*_test.go(mqttSess)、test/accept(MQTTSession)、test/chaos、test/load/q3_live_test.go、web/e2e/seedpending。此前 Q accept-rest 里"F15 带密拉人用独立短生命周期进程""group.add 带密码卡住"等绕行,以及internal/app/group/app.go:659-686的 goroutine +time.Sleep(20ms),都是这个缺陷的误诊产物。解决方案(已验证)
test/harness/mqtt.go:wsMQTT增加接收缓冲buf []byte与写锁wmu sync.Mutex。Recv先从缓冲里按 MQTT 剩余长度切出一个完整包;不够时继续读 WS 帧,opcode0x2与续帧0x0的载荷都追加到缓冲;0x8返回 EOF;0x9在写锁内回 PONG;0xA忽略。Send与回 PONG 都持写锁;writeWSClientFrame把帧头和掩码后的载荷拼成一个切片,一次Write。internal/app/group/app.go的emit:删除 goroutine 与time.Sleep(20ms),改回在调用方上下文里同步PublishDown(group_event是 QoS 0,mochi 的 QoS 0 发布走非阻塞通道,不会卡住调用方)。test/accept/rest_accept_test.go:F15 去掉runF15JoinWithPasswordFresh的"独立短进程"绕行,在同一长会话里用group.add+ 当次对话密码断言成功。docs/DEVIATIONS.md:把「Q accept-rest」第 2–4 条和「死锁未修(issue #3)」整节标注为已被本 issue 取代,写明真实根因;不要删除历史文字。E:\code\NixMsg-wt\fix3与分支feat/fix-3-downlink-deadlock(基于错误假设,其OnPublish先写 PUBACK 的改法还改变了语义),不要合入。已验证的测试客户端补丁要点(去掉了诊断日志)
改动文件
test/harness/mqtt.go、internal/app/group/app.go(仅emit)、test/accept/rest_accept_test.go(仅 F15)、docs/DEVIATIONS.md。交互 / 冲突说明
emit一个函数,与其他工作线没有文件冲突(身份线 U-02 改 group 其他函数时注意不要动emit)。验收与测试
Recv依次返回 3 个;一个包跨两个帧;两个 goroutine 各并发Send1000 次,服务端全部正确解码。go test ./cmd/nixmsg/ -run TestUplinkDMOfflineGroupRecall -count=20在同步emit下全部通过。task check与go test ./test/... -count=1通过。原始描述(已被推翻,保留追溯)
严重级
medium
分类
设计缺陷 / 竞态
现象
同一连接上
group.create/group.add若在处理上行的 worker 里同步向本连接注入下行(server.Publish→ InlineClientInjectPacket),会与 mochi InlineClient 互相等待,应用层resp发不回去。当前实现把group_event放到独立 goroutine 并Sleep(20ms),让 worker 先PublishDownresp。这是竞态窗口,不是根因修复:负载升高、调度延迟偏离 20ms、或其它仍同步的下行,仍可能卡住。补测
TestUplinkDMOfflineGroupRecall在清掉测试客户端 dial deadline 后可稳定复现(见 DEVIATIONS Q accept-rest 第 4 条)。路径
internal/app/group/app.go第 659–685 行:emit异步 + 20ms 再PublishDown(QoS 0)internal/app/group/void.go第 153–167 行:publishRevokes仍同步PublishDown;Leave/Remove/Dissolve(app.go 约 262、299、393 行)在返回给 uplink 发 resp 之前就会走到这里internal/broker/hooks.go第 114–118 行:InlineClient 必须放行才能分发给订阅者internal/broker/broker.go第 195–238 行:PublishDown→server.Publishinternal/broker/queue.go第 41–44 行:每端串行 worker 调用HandleUplinkcmd/nixmsg/uplink.go第 62–75、268–295 行:业务返回后replyOK再PublishDownrespinternal/app/message/push.goWakePush在Submit成功后异步PushPending;自发自收时可能与replyOK并发PublishDown到同一连接文档条款
InlineClient = true;OnPublish在客户端读循环同步执行;worker 不要同步等待「断开同一连接」为何不是故意设计
20ms 不是 PRD/DEVELOPMENT 的协议语义,是补测为避开死锁加的睡眠。根因(上行 worker 调用栈上同步 Inline
Publish)还在。group_event绕开了,revoked等同步路径没有。已知清单里的「群 emit 异步+20ms」是本波处理项,但死锁根因仍在,应按设计缺陷跟踪,而不是当作已修完。建议方向(不实现)
不要用
time.Sleep赌调度。正确修法例如:broker.PublishDown对 Inline 走无锁出站队列,由独立循环 Inject;上行 worker 只入队、立刻返回;或resp发布之后由 uplink 层入队,且禁止在HandleUplink栈上对本连接同步PublishDown。去掉 20ms 魔法数,并用原先会卡住的建群/退群+已推送投递用例做回归,而不是靠睡眠。
复审基线:main
4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。未关闭,也未合入 feat/fix-3-downlink-deadlock。当前 main 是
4059a1576bc97879b840ec5200defd80bc9eb3f0,其中仍保留 20ms 绕过。分析已写入仓库docs/DEVIATIONS.md末尾「### 死锁未修(issue #3)」。工作树e:\code\NixMsg-wt\fix3保留给工程师,里面的未提交改动未验证、不进 main。现象:每端上行串行 worker(
internal/broker/queue.go的 loop)同步调用 HandleUplink。群操作在同一调用栈里再对本连接 PublishDown group_event。PublishDown 走 mochiserver.Publish→ InlineClientInjectPacket,与读循环写 PUBACK 抢同一把 Client 锁,QoS 1 的 resp 回不去。清掉测试客户端 dial deadline 后,TestUplinkDMOfflineGroupRecall在 group.create(rid=g1)稳定超时。尝试 1:emit 立刻起 goroutine 再 PublishDown,仍与 resp 抢同一连接,仍然死锁。
尝试 2(已在 main,来自 479a08e):emit 的 goroutine 里 Sleep(20ms) 再下发,让 resp 先出去。当时 task check 通过。这是时间差绕过,不是根因修复;presence 等其他同步 PublishDown 仍可能卡。
尝试 3(叫停):分支停在 479a08e,没有可合的提交。未提交改动在 broker 层:OnPublish 先在读循环写完 PUBACK 再入队;该端 HandleUplink 期间对本连接的下行只入延后队列,handler 返回后再 server.Publish;group emit 改回同步并去掉 sleep。InlineClient 放行未改。本会话未对其跑 task check。
库约束:DEVELOPMENT 要求 InlineClient=true;OnPublish 对 InlineClient 必须放行,否则 PublishDown 送不到订阅者。
建议方向:broker 把对本连接的下行 InjectPacket 与上行 worker 解耦,不要靠固定 Sleep。
上行 worker 同步 PublishDown 与 mochi InlineClient 会死锁,20ms 只绕过 group_eventto [T-01][medium] 测试 WS 客户端不按字节流拆包、并发写不加锁,被误判为服务端死锁;移除群事件 20ms 绕过已按 2026-09-30 全量复审更新本 issue:原先的"服务端死锁"结论不成立,真实根因是测试用 WebSocket 客户端不按字节流拆包、并发写不加锁(对照实验见正文)。本 issue 现为工作包 T-01,请按正文方案修复;原始描述保留在正文末尾。