编号:B-01 严重级:critical 工作线:broker(internal/broker) 来源:总审查人实证 依赖:无 被依赖:B-03 (#10)(同在 hooks.go,按顺序合入)
hooks.go
只要有一个客户端在 CONNECT 里带了 MQTT 5 的 Receive Maximum 属性(这是标准属性,很多设备库会带;F20 允许裸 MQTT 设备接入),mochi v2.7.9(当前最新版)就会走发送配额路径。该路径有两个缺陷:
Receive Maximum
Inflight.NextImmediate
RLock
GetAll
inflight.go:95-105
74-90
sync.RWMutex
publishToClient
Inflight.Set
server.go:1083
processPacket
server.go:722-726
processPuback
server.go:1162
server.go:1089-1093
触发条件:processPacket 在 Inflight.Len() > 0 && sendQuota > 0 时对每个收到的包都会调用 NextImmediate(server.go:719-728);sendQuota 来自客户端 CONNECT 的 Receive Maximum(clients.go:233)。测试客户端和目前的验收都不带这个属性,所以一直没暴露。
Inflight.Len() > 0 && sendQuota > 0
NextImmediate
server.go:719-728
sendQuota
clients.go:233
放到 NixMsg 里,死锁后以下都会永久阻塞:
messageLoops
cmd/nixmsg/serve.go:322-348
eventLoop
clearExpiredInflights
inheritClientSession
ClearInflights
srv.Close()
用原版 mochi v2.7.9 复现:客户端 CONNECT 带 ReceiveMaximum=20,订阅主题,边收边回 PUBACK 并每 200µs 发一次 PINGREQ;服务端 8 个 goroutine 持续 server.Publish(QoS1)。只收到 31 条就永久卡死,用时不到 4 秒。卡死时的栈:
ReceiveMaximum=20
server.Publish(QoS1)
server.go:720 processPacket
inflight.go:99 NextImmediate
inflight.go:75 GetAll
RWMutex.RLock
server.go:1083 publishToClient
inflight.go:34 Inflight.Set
RWMutex.Lock
clients.go:344 ClearExpiredInflights
只修掉递归读锁、再修掉"补发后删除"之后,6 轮里仍有 1 轮在 935 条时停滞,说明这条路径不可依赖。
DEVELOPMENT §5 只提到 inflight 满时 mochi 会静默丢弃,没有提到发送配额;DEVIATIONS 无相关条目。这是依赖库缺陷,NixMsg 需要规避。
internal/broker/hooks.go
nixHook.OnConnect
ParseConnect
cl.State.Inflight.ResetSendQuota(0)
maximumSendQuota
不采用的备选:fork 并修补 mochi(replace 指令)。只修递归读锁仍观察到停滞,且长期维护 fork 成本高;置 0 直接绕开整条路径更稳。
replace
internal/broker/hooks.go(OnConnect)、internal/broker/broker_test.go 或新测试文件、docs/DEVELOPMENT.md §5/§10、docs/DEVIATIONS.md。
OnConnect
internal/broker/broker_test.go
docs/DEVELOPMENT.md
docs/DEVIATIONS.md
port.Downlink
PublishDown
task check
// 客户端 CONNECT 带 Receive Maximum cp := packets.Packet{ FixedHeader: packets.FixedHeader{Type: packets.Connect}, ProtocolVersion: 5, Connect: packets.ConnectParams{ProtocolName: []byte("MQTT"), Clean: true, ClientIdentifier: "c1", Keepalive: 30}, Properties: packets.Properties{ReceiveMaximum: 20}, } // 读 goroutine:每收到 QoS1 PUBLISH 就回 PUBACK(写操作加锁);另一个 goroutine 每 200µs 发 PINGREQ // 8 个 goroutine:for { srv.Publish("t", []byte("x"), false, 1) } // 看门狗:received 计数 3 秒不变 → runtime.Stack(buf, true) 打印含 NextImmediate / (*Inflight).Set 的栈并 t.Fatal // 修复验证:在 OnConnect 钩子里 cl.State.Inflight.ResetSendQuota(0) 后,30 秒内持续增长、不停滞
复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。
4059a15
已合入 origin/main 0c9b459。落地提交 d94fb25 fix: 在 OnConnect 把 mochi 发送配额置 0 规避死锁 (#8)。
0c9b459
d94fb25
No dependencies set.
The note is not visible to the blocked user.
编号:B-01 严重级:critical 工作线:broker(internal/broker) 来源:总审查人实证
依赖:无 被依赖:B-03 (#10)(同在
hooks.go,按顺序合入)现象与影响
只要有一个客户端在 CONNECT 里带了 MQTT 5 的
Receive Maximum属性(这是标准属性,很多设备库会带;F20 允许裸 MQTT 设备接入),mochi v2.7.9(当前最新版)就会走发送配额路径。该路径有两个缺陷:Inflight.NextImmediate先RLock,再调GetAll又RLock(mochiinflight.go:95-105、74-90)。Go 的sync.RWMutex在有写锁排队时会阻塞新的读锁,明确禁止递归读锁。只要此时另一个 goroutine 向该客户端发布 QoS 1(publishToClient→Inflight.Set写锁,server.go:1083),两边就永久互等。processPacket补发被配额扣住的包后,立刻把它从 inflight 删除(server.go:722-726)。客户端回的 PUBACK 在processPuback里找不到条目(server.go:1162),发送配额永远少一格。配额耗尽后,publishToClient把所有 QoS 1 扣在 inflight 里却返回成功(server.go:1089-1093),这个连接从此收不到任何 QoS 1,而上层以为已推送。触发条件:
processPacket在Inflight.Len() > 0 && sendQuota > 0时对每个收到的包都会调用NextImmediate(server.go:719-728);sendQuota来自客户端 CONNECT 的 Receive Maximum(clients.go:233)。测试客户端和目前的验收都不带这个属性,所以一直没暴露。放到 NixMsg 里,死锁后以下都会永久阻塞:
messageLoops(cmd/nixmsg/serve.go:322-348,串行对所有在线端推送,导致全站推送、到点分发、清理停止)、其他端的上行 worker(回执、撤回推送)、管理接口(作废后的 revoked、fatal)。eventLoop(clearExpiredInflights→GetAll读锁),以及该编号重连时的inheritClientSession(ClearInflights),新连接也挂住;srv.Close()无法返回。实证
用原版 mochi v2.7.9 复现:客户端 CONNECT 带
ReceiveMaximum=20,订阅主题,边收边回 PUBACK 并每 200µs 发一次 PINGREQ;服务端 8 个 goroutine 持续server.Publish(QoS1)。只收到 31 条就永久卡死,用时不到 4 秒。卡死时的栈:server.go:720 processPacket→inflight.go:99 NextImmediate→inflight.go:75 GetAll阻塞在RWMutex.RLock(手里握着第一次读锁);server.go:1083 publishToClient→inflight.go:34 Inflight.Set阻塞在RWMutex.Lock;eventLoop:clients.go:344 ClearExpiredInflights→GetAll阻塞;srv.Close()挂住直到测试超时。只修掉递归读锁、再修掉"补发后删除"之后,6 轮里仍有 1 轮在 935 条时停滞,说明这条路径不可依赖。
为何判定为真实缺陷
DEVELOPMENT §5 只提到 inflight 满时 mochi 会静默丢弃,没有提到发送配额;DEVIATIONS 无相关条目。这是依赖库缺陷,NixMsg 需要规避。
解决方案(已验证)
internal/broker/hooks.go的nixHook.OnConnect(第 33 行起,mochi 在ParseConnect之后调用它)里执行cl.State.Inflight.ResetSendQuota(0)。这样sendQuota与maximumSendQuota都为 0:processPacket不再进入NextImmediate分支,publishToClient不再扣包,整条有缺陷的路径不会被执行。NextImmediate递归读锁与processPacket补发后删除 inflight 的问题。不采用的备选:fork 并修补 mochi(
replace指令)。只修递归读锁仍观察到停滞,且长期维护 fork 成本高;置 0 直接绕开整条路径更稳。改动文件
internal/broker/hooks.go(OnConnect)、internal/broker/broker_test.go或新测试文件、docs/DEVELOPMENT.md§5/§10、docs/DEVIATIONS.md。交互 / 冲突说明
hooks.go冲突。port.Downlink接口,对消息线透明。验收与测试
ReceiveMaximum=20订阅自己的 down 主题;8 个 goroutine 持续PublishDownQoS 1 10 秒,客户端回 PUBACK 并定时 PINGREQ;断言进度停滞从不超过 2 秒。修复前应在数秒内失败。task check通过。复现测试要点(可作为回归测试起点)
复审基线:main
4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。已合入 origin/main
0c9b459。落地提交d94fb25fix: 在 OnConnect 把 mochi 发送配额置 0 规避死锁 (#8)。