编号:B-04 严重级:medium 工作线:broker(internal/broker/session.go 等),附带 identity 与 serve 装配的少量改动 来源:总审查人核实 依赖:B-03 (#10)(使用每连接发送队列) 被依赖:无
internal/broker/session.go:284-289
time.Sleep(50ms)
Session.fatalKick
session.go:316-335
time.Sleep(20ms)
internal/app/identity/lifecycle.go:131-138
time.Sleep(kickFlushDelay=20ms)
connCtrl.Disconnect(DisconnectFatal)
ConnControl: brk
cmd/nixmsg/serve.go:156
identity.Disable
internal/admin/endpoints_db.go:316
afterDisableKick
Session.Disable
ClearSession
synchronous=FULL
ConnInfoOf
DisconnectClient
server.go:1414-1438
WritePacket
outbuf
clients.go:616-627
Stop()
影响:SDK 收不到 fatal(disabled|deleted|password_reset) 与断开原因码,用已清空的令牌重连得到"认证失败",展示给用户的原因错误;退出登录的 resp 可能丢失,SDK 的 logout 调用超时。功能安全仍由令牌作废保证(DEVELOPMENT §5 第 272 行),所以定为 medium。
fatal(disabled|deleted|password_reset)
logout
DEVELOPMENT §5「管理员停用、删除、重置密码:先发 fatal 帧再断开」;6.8;PRD F02;D13。
ConnControl
lifecycle.go
PublishThenDisconnect(ctx, endpointID, connID, payload, reason)
server.Publish
OnPacketSent
fatalKick
internal/broker/session.go、broker.go、hooks.go(OnPacketSent);internal/app/identity/lifecycle.go(删除 20ms goroutine);cmd/nixmsg/serve.go(identity 装配一行)。
internal/broker/session.go
broker.go
hooks.go
internal/app/identity/lifecycle.go
cmd/nixmsg/serve.go
serve.go
identity.New
fatal(disabled)
以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。解决方案以本 issue 上方的"结论与统一方案"为准;原文里的方案与之不一致时,按上方执行。
严重级:medium
分类:并发 / 协议一致性(#4 回归)
现象与影响
kickFlushDelay 想保证的是:identity 的兜底断开不要抢在 Session.fatalKick 发出 fatal 之前。现在停用或删除时,有两路互不知道对方的延时断开:
Disconnect(ep, "")
DisableKick
只要第二步写库超过约 20ms(云盘落盘慢、写队列积压都很常见),第一路就已经把连接断了。fatal 要么因为 ConnInfoOf 找不到连接而不发,要么发到已关闭的客户端被丢掉。
即使只剩一路延时,在 mochi 下也不可靠:
PublishDown
最终效果:SDK 只看到断开或 0x98,自动重连后被 0x86 拒绝,以 session_invalid 停止。重连是停了,但原因不是 disabled、deleted 或 password_reset,应用没法提示「已被停用」或「密码已被重置」。self.logout 的 50ms 延时有同样问题,影响较小。
session_invalid
self.logout
证据
identity/lifecycle.go:27-28
128-138
broker/session.go:281-290
316-336
cmd/nixmsg/serve.go:156-157
198-216
admin/endpoints.go:366-381
admin/endpoints_db.go:310-325
server.go:1014-1020
1064-1111
1414-1438
clients.go:194-207
393-410
600-631
// fatal+断开由 admin DisableKick/DeleteKick(Session.Disable/Deleted)完成。 // 未接 Kick 钩子的单元测试仍可用 ConnControl 兜底断开。 if a.connCtrl != nil { go func() { time.Sleep(kickFlushDelay) _ = a.connCtrl.Disconnect(context.Background(), endpointID, "", port.DisconnectFatal) }() }
文档依据:DEVELOPMENT 6.8「发出后断开」、第 5 节(272)「先发 fatal 帧再断开」;PRD F02(152);DEVIATIONS fix-issue-4。
为何不是故意设计:兜底断开的注释写明是给「未接 Kick 钩子的单元测试」用的,但 serve 同时注入了 ConnControl 和 Kick 钩子,生产环境里每次都会触发。
解决方案:不靠固定延时,在 broker 层提供原语 KickAfterFrame(ep, connID, frame, reason, maxWait):
KickAfterFrame(ep, connID, frame, reason, maxWait)
st.mu
closing=true
OnQosComplete
maxWait
DisconnectClient(cl, 0x98)
Receive Maximum 用满时,fatal 仍可能进不了 outbound,退化为超时后断开,和现在一样。要彻底避免,可以把 fatal 改为 QoS 0,这需要记一条偏差。
改动文件:broker/broker.go、broker/hooks.go、broker/session.go、identity/lifecycle.go、cmd/nixmsg/serve.go。
broker/broker.go
broker/hooks.go
broker/session.go
identity/lifecycle.go
与其他模块的交互/冲突风险
需补测试
置信度:代码阅读确定。无负载时 fatal 集成测试连跑 10 次都通过,未复现。
复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。
4059a15
已合入 origin/main 0c9b459。落地提交 91e887b fix: 完成 broker 复审 B-03 至 B-12 (#11)。
0c9b459
91e887b
No dependencies set.
The note is not visible to the blocked user.
编号:B-04 严重级:medium 工作线:broker(internal/broker/session.go 等),附带 identity 与 serve 装配的少量改动 来源:总审查人核实
依赖:B-03 (#10)(使用每连接发送队列) 被依赖:无
现象与影响
internal/broker/session.go:284-289发完 resp 后起 goroutinetime.Sleep(50ms)再断开。Session.fatalKick(session.go:316-335)发 fatal 后time.Sleep(20ms)再断开。internal/app/identity/lifecycle.go:131-138事务后time.Sleep(kickFlushDelay=20ms)再connCtrl.Disconnect(DisconnectFatal);生产装配注入了ConnControl: brk(cmd/nixmsg/serve.go:156)。identity.Disable(internal/admin/endpoints_db.go:316,事务后启动 20ms 断开)→afterDisableKick→Session.Disable(先ClearSession写库,synchronous=FULL下落盘常超过 20ms)→ 发 fatal。identity 的断开可能先发生,此时ConnInfoOf已找不到连接,fatal 根本不会发出。DisconnectClient(server.go:1414-1438)用WritePacket写 DISCONNECT;客户端出站队列非空时,WritePacket只把数据放进outbuf不刷出(clients.go:616-627),紧接着Stop()关连接,队列里的 fatal 与 DISCONNECT 一起丢失。固定 sleep 只是在低负载时碰巧够用。影响:SDK 收不到
fatal(disabled|deleted|password_reset)与断开原因码,用已清空的令牌重连得到"认证失败",展示给用户的原因错误;退出登录的 resp 可能丢失,SDK 的logout调用超时。功能安全仍由令牌作废保证(DEVELOPMENT §5 第 272 行),所以定为 medium。文档依据
DEVELOPMENT §5「管理员停用、删除、重置密码:先发 fatal 帧再断开」;6.8;PRD F02;D13。
解决方案
ConnControl(或 identity 仅在未配置踢线钩子时才自己断开),删除lifecycle.go里的 20ms goroutine;单元测试通过显式选项保留兜底。PublishThenDisconnect(ctx, endpointID, connID, payload, reason):把帧放入该连接的发送队列(B-03),发送 goroutine 在server.Publish返回、且该帧已被写出(mochiOnPacketSent钩子确认,或等待出站队列排空)之后再调用DisconnectClient;设 2 秒超时兜底,超时照样断开。Session.fatalKick与 logout 改用该原语,删除所有固定 sleep。fatalKick在ClearSession失败时仍然断开连接并返回错误,管理侧据此处理(见管理后端 A 线"重置密码踢线失败被吞"一条)。改动文件
internal/broker/session.go、broker.go、hooks.go(OnPacketSent);internal/app/identity/lifecycle.go(删除 20ms goroutine);cmd/nixmsg/serve.go(identity 装配一行)。交互 / 冲突说明
lifecycle.go的作废与收尾函数由消息线在 C-04(统一终态函数)修改,本 issue 只删除第 131-138 行的断开 goroutine,改动范围不重叠。serve.go只改identity.New的ConnControl一行,与其他工作线在该文件的改动不在同一处。验收与测试
fatal(disabled)再收到原因码 0x98 的 DISCONNECT。问题明细(各区审查原文,证据含文件与行号)
[I-07] 回归(#4):停用、删除、重置的「先发 fatal 再断开」靠两路 20ms 延时,不可靠还互相抢跑
严重级:medium
分类:并发 / 协议一致性(#4 回归)
现象与影响
kickFlushDelay 想保证的是:identity 的兜底断开不要抢在
Session.fatalKick发出 fatal 之前。现在停用或删除时,有两路互不知道对方的延时断开:Disconnect(ep, "")。DisableKick,走fatalKick:先再做一次ClearSession(又一次写队列提交,FULL 同步下要落盘,还要排在其他写操作后面),然后才发布 fatal,再起一个 20ms 的断开。只要第二步写库超过约 20ms(云盘落盘慢、写队列积压都很常见),第一路就已经把连接断了。fatal 要么因为
ConnInfoOf找不到连接而不发,要么发到已关闭的客户端被丢掉。即使只剩一路延时,在 mochi 下也不可靠:
PublishDown只是把包放进 outbound 通道,由 WriteLoop 异步写出。DisconnectClient直接同步写 DISCONNECT 然后 Stop:Stop 关闭连接并取消上下文,WriteLoop 退出,通道里还没写出的 fatal 被丢弃。两者之间没有先后保证。server.Publish不返回单个客户端的失败,调用方无从得知 fatal 是否被丢。最终效果:SDK 只看到断开或 0x98,自动重连后被 0x86 拒绝,以
session_invalid停止。重连是停了,但原因不是 disabled、deleted 或 password_reset,应用没法提示「已被停用」或「密码已被重置」。self.logout的 50ms 延时有同样问题,影响较小。证据
identity/lifecycle.go:27-28、128-138(见下方代码)。broker/session.go:281-290(logout)、316-336(fatalKick)。cmd/nixmsg/serve.go:156-157、198-216。admin/endpoints.go:366-381、admin/endpoints_db.go:310-325。server.go:1014-1020、1064-1111、1414-1438;clients.go:194-207、393-410、600-631。文档依据:DEVELOPMENT 6.8「发出后断开」、第 5 节(272)「先发 fatal 帧再断开」;PRD F02(152);DEVIATIONS fix-issue-4。
为何不是故意设计:兜底断开的注释写明是给「未接 Kick 钩子的单元测试」用的,但 serve 同时注入了 ConnControl 和 Kick 钩子,生产环境里每次都会触发。
解决方案:不靠固定延时,在 broker 层提供原语
KickAfterFrame(ep, connID, frame, reason, maxWait):st.mu下置closing=true。此后对该连接的 PublishDown 一律返回 ErrNoConnection,保证 fatal 是 outbound 里的最后一帧。OnPacketSent:mochi 在字节写入连接后回调,包里带 Payload 和 PacketID。看到 fatal 就记下 PacketID,通知「已写出」。OnQosComplete收到同一 PacketID 的 PUBACK 时通知「已确认」。maxWait(建议 2–3 秒);只等到「已写出」时再留 100–200ms 余量。然后调用DisconnectClient(cl, 0x98)。这时 outbound 已空,WritePacket 会把 outbuf 连同 DISCONNECT 一起 flush,然后再 Stop。fatalKick和 logout 改用这个原语;去掉 fatalKick 里重复的ClearSession。Receive Maximum 用满时,fatal 仍可能进不了 outbound,退化为超时后断开,和现在一样。要彻底避免,可以把 fatal 改为 QoS 0,这需要记一条偏差。
改动文件:
broker/broker.go、broker/hooks.go、broker/session.go、identity/lifecycle.go、cmd/nixmsg/serve.go。与其他模块的交互/冲突风险
需补测试
maxWait内断开。DisconnectClient。置信度:代码阅读确定。无负载时 fatal 集成测试连跑 10 次都通过,未复现。
复审基线:main
4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。已合入 origin/main
0c9b459。落地提交91e887bfix: 完成 broker 复审 B-03 至 B-12 (#11)。