[B-04][medium] 停用/删除/重置密码/退出登录靠固定 sleep 等下行写出再断开,且有两条断开路径赛跑,fatal 与 resp 可能丢失 #11

Closed
opened 2026-09-30 13:56:51 +08:00 by nixevol · 1 comment
Owner

编号:B-04 严重级:medium 工作线:broker(internal/broker/session.go 等),附带 identity 与 serve 装配的少量改动 来源:总审查人核实
依赖:B-03 (#10)(使用每连接发送队列) 被依赖:无

现象与影响

  • 退出登录:internal/broker/session.go:284-289 发完 resp 后起 goroutine time.Sleep(50ms) 再断开。
  • 停用/删除/重置密码:Session.fatalKick(session.go:316-335)发 fatal 后 time.Sleep(20ms) 再断开。
  • identity 另有一条断开路径: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 根本不会发出。
  • 更深一层:mochi 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。

解决方案

  1. 只保留一条断开路径:生产装配不再给 identity 注入 ConnControl(或 identity 仅在未配置踢线钩子时才自己断开),删除 lifecycle.go 里的 20ms goroutine;单元测试通过显式选项保留兜底。
  2. broker 提供"写出后断开"原语,例如 PublishThenDisconnect(ctx, endpointID, connID, payload, reason):把帧放入该连接的发送队列(B-03),发送 goroutine 在 server.Publish 返回、且该帧已被写出(mochi OnPacketSent 钩子确认,或等待出站队列排空)之后再调用 DisconnectClient;设 2 秒超时兜底,超时照样断开。
  3. Session.fatalKick 与 logout 改用该原语,删除所有固定 sleep。
  4. 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,改动范围不重叠。
  • 管理侧"重置密码踢线失败被吞"在 H-07 处理。审查 I-07 的完整分析附在下方,本 issue 的方案已吸收其"closing 标记保证 fatal 是最后一帧""OnPacketSent 确认写出""去掉 fatalKick 里重复的 ClearSession"等要点。
  • serve.go 只改 identity.New 的 ConnControl 一行,与其他工作线在该文件的改动不在同一处。

验收与测试

  • 集成:在线端出站队列里积压 30 个大帧时停用它,50 次中客户端都先收到 fatal(disabled) 再收到原因码 0x98 的 DISCONNECT。
  • 退出登录的 resp 总能收到;同一次停用只发生一次断开。

问题明细(各区审查原文,证据含文件与行号)

以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。解决方案以本 issue 上方的"结论与统一方案"为准;原文里的方案与之不一致时,按上方执行。

[I-07] 回归(#4):停用、删除、重置的「先发 fatal 再断开」靠两路 20ms 延时,不可靠还互相抢跑

  • 严重级:medium

  • 分类:并发 / 协议一致性(#4 回归)

  • 现象与影响

    kickFlushDelay 想保证的是:identity 的兜底断开不要抢在 Session.fatalKick 发出 fatal 之前。现在停用或删除时,有两路互不知道对方的延时断开:

    1. identity 的事务提交、发完 revoked 后立即起 goroutine,20ms 后执行 Disconnect(ep, "")。
    2. 后台随后调用 DisableKick,走 fatalKick:先再做一次 ClearSession(又一次写队列提交,FULL 同步下要落盘,还要排在其他写操作后面),然后才发布 fatal,再起一个 20ms 的断开。

    只要第二步写库超过约 20ms(云盘落盘慢、写队列积压都很常见),第一路就已经把连接断了。fatal 要么因为 ConnInfoOf 找不到连接而不发,要么发到已关闭的客户端被丢掉。

    即使只剩一路延时,在 mochi 下也不可靠:

    • PublishDown 只是把包放进 outbound 通道,由 WriteLoop 异步写出。DisconnectClient 直接同步写 DISCONNECT 然后 Stop:Stop 关闭连接并取消上下文,WriteLoop 退出,通道里还没写出的 fatal 被丢弃。两者之间没有先后保证。
    • 通道里有积压时 20ms 不够。停用时会先给被停用端自己发 revoked,正好排在 fatal 前面;慢客户端或 TCP 发送窗口满也会造成积压。
    • fatal 是 QoS 1。客户端的 Receive Maximum 用满时,mochi 把它放进 inflight 等额度,根本不进 outbound。
    • outbound 非空时,WritePacket 只写进 outbuf、不 flush,DISCONNECT 本身也可能丢失。
    • server.Publish 不返回单个客户端的失败,调用方无从得知 fatal 是否被丢。

    最终效果:SDK 只看到断开或 0x98,自动重连后被 0x86 拒绝,以 session_invalid 停止。重连是停了,但原因不是 disabled、deleted 或 password_reset,应用没法提示「已被停用」或「密码已被重置」。self.logout 的 50ms 延时有同样问题,影响较小。

  • 证据

    • identity 的兜底:identity/lifecycle.go:27-28、128-138(见下方代码)。
    • Session 的两处延时:broker/session.go:281-290(logout)、316-336(fatalKick)。
    • 两者同时接上:cmd/nixmsg/serve.go:156-157、198-216。
    • 后台调用顺序(先 identity.Disable,后 DisableKick):admin/endpoints.go:366-381、admin/endpoints_db.go:310-325。
    • mochi 写出路径: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):

    1. 在 st.mu 下置 closing=true。此后对该连接的 PublishDown 一律返回 ErrNoConnection,保证 fatal 是 outbound 里的最后一帧。
    2. 发布 fatal。
    3. hook 增加 OnPacketSent:mochi 在字节写入连接后回调,包里带 Payload 和 PacketID。看到 fatal 就记下 PacketID,通知「已写出」。OnQosComplete 收到同一 PacketID 的 PUBACK 时通知「已确认」。
    4. 在独立 goroutine 里等「已确认」,最多 maxWait(建议 2–3 秒);只等到「已写出」时再留 100–200ms 余量。然后调用 DisconnectClient(cl, 0x98)。这时 outbound 已空,WritePacket 会把 outbuf 连同 DISCONNECT 一起 flush,然后再 Stop。
    5. fatalKick 和 logout 改用这个原语;去掉 fatalKick 里重复的 ClearSession。
    6. serve 不再给 identity 注入 ConnControl,删掉 kickFlushDelay,只保留一个断开方。
    7. 可选:停用时不给被停用端自己发 revoked。

    Receive Maximum 用满时,fatal 仍可能进不了 outbound,退化为超时后断开,和现在一样。要彻底避免,可以把 fatal 改为 QoS 0,这需要记一条偏差。

  • 改动文件:broker/broker.go、broker/hooks.go、broker/session.go、identity/lifecycle.go、cmd/nixmsg/serve.go。

  • 与其他模块的交互/冲突风险

    • 等待不能放在上行 worker 里同步进行(DEVELOPMENT 第 5 节)。
    • 等待期间令牌已作废,不影响安全。
    • 和 #3 对 broker 的改动在同一批文件里。
  • 需补测试

    • 客户端不读、先塞满 outbound 再停用:客户端先收到 fatal,再收到 DISCONNECT。
    • Receive Maximum=1 且有未确认消息时停用:在 maxWait 内断开。
    • 每次停用只调用一次 DisconnectClient。
  • 置信度:代码阅读确定。无负载时 fatal 集成测试连跑 10 次都通过,未复现。


复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。

**编号**:B-04 **严重级**:medium **工作线**:broker(internal/broker/session.go 等),附带 identity 与 serve 装配的少量改动 **来源**:总审查人核实 **依赖**:B-03 (#10)(使用每连接发送队列) **被依赖**:无 ### 现象与影响 - 退出登录:`internal/broker/session.go:284-289` 发完 resp 后起 goroutine `time.Sleep(50ms)` 再断开。 - 停用/删除/重置密码:`Session.fatalKick`(`session.go:316-335`)发 fatal 后 `time.Sleep(20ms)` 再断开。 - identity 另有一条断开路径:`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 根本不会发出**。 - 更深一层:mochi `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。 ### 解决方案 1. **只保留一条断开路径**:生产装配不再给 identity 注入 `ConnControl`(或 identity 仅在未配置踢线钩子时才自己断开),删除 `lifecycle.go` 里的 20ms goroutine;单元测试通过显式选项保留兜底。 2. **broker 提供"写出后断开"原语**,例如 `PublishThenDisconnect(ctx, endpointID, connID, payload, reason)`:把帧放入该连接的发送队列(B-03),发送 goroutine 在 `server.Publish` 返回、且该帧已被写出(mochi `OnPacketSent` 钩子确认,或等待出站队列排空)之后再调用 `DisconnectClient`;设 2 秒超时兜底,超时照样断开。 3. `Session.fatalKick` 与 logout 改用该原语,删除所有固定 sleep。 4. `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,改动范围不重叠。 - 管理侧"重置密码踢线失败被吞"在 H-07 处理。审查 I-07 的完整分析附在下方,本 issue 的方案已吸收其"closing 标记保证 fatal 是最后一帧""OnPacketSent 确认写出""去掉 fatalKick 里重复的 ClearSession"等要点。 - `serve.go` 只改 `identity.New` 的 `ConnControl` 一行,与其他工作线在该文件的改动不在同一处。 ### 验收与测试 - 集成:在线端出站队列里积压 30 个大帧时停用它,50 次中客户端都先收到 `fatal(disabled)` 再收到原因码 0x98 的 DISCONNECT。 - 退出登录的 resp 总能收到;同一次停用只发生一次断开。 --- ### 问题明细(各区审查原文,证据含文件与行号) > 以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。**解决方案以本 issue 上方的"结论与统一方案"为准**;原文里的方案与之不一致时,按上方执行。 #### [I-07] 回归(#4):停用、删除、重置的「先发 fatal 再断开」靠两路 20ms 延时,不可靠还互相抢跑 - **严重级**:medium - **分类**:并发 / 协议一致性(#4 回归) - **现象与影响** kickFlushDelay 想保证的是:identity 的兜底断开不要抢在 `Session.fatalKick` 发出 fatal 之前。现在停用或删除时,有两路互不知道对方的延时断开: 1. identity 的事务提交、发完 revoked 后立即起 goroutine,20ms 后执行 `Disconnect(ep, "")`。 2. 后台随后调用 `DisableKick`,走 `fatalKick`:先再做一次 `ClearSession`(又一次写队列提交,FULL 同步下要落盘,还要排在其他写操作后面),然后才发布 fatal,再起一个 20ms 的断开。 只要第二步写库超过约 20ms(云盘落盘慢、写队列积压都很常见),第一路就已经把连接断了。fatal 要么因为 `ConnInfoOf` 找不到连接而不发,要么发到已关闭的客户端被丢掉。 即使只剩一路延时,在 mochi 下也不可靠: - `PublishDown` 只是把包放进 outbound 通道,由 WriteLoop 异步写出。`DisconnectClient` 直接同步写 DISCONNECT 然后 Stop:Stop 关闭连接并取消上下文,WriteLoop 退出,通道里还没写出的 fatal 被丢弃。两者之间没有先后保证。 - 通道里有积压时 20ms 不够。停用时会先给被停用端自己发 revoked,正好排在 fatal 前面;慢客户端或 TCP 发送窗口满也会造成积压。 - fatal 是 QoS 1。客户端的 Receive Maximum 用满时,mochi 把它放进 inflight 等额度,根本不进 outbound。 - outbound 非空时,WritePacket 只写进 outbuf、不 flush,DISCONNECT 本身也可能丢失。 - `server.Publish` 不返回单个客户端的失败,调用方无从得知 fatal 是否被丢。 最终效果:SDK 只看到断开或 0x98,自动重连后被 0x86 拒绝,以 `session_invalid` 停止。重连是停了,但原因不是 disabled、deleted 或 password_reset,应用没法提示「已被停用」或「密码已被重置」。`self.logout` 的 50ms 延时有同样问题,影响较小。 - **证据** - identity 的兜底:`identity/lifecycle.go:27-28`、`128-138`(见下方代码)。 - Session 的两处延时:`broker/session.go:281-290`(logout)、`316-336`(fatalKick)。 - 两者同时接上:`cmd/nixmsg/serve.go:156-157`、`198-216`。 - 后台调用顺序(先 identity.Disable,后 DisableKick):`admin/endpoints.go:366-381`、`admin/endpoints_db.go:310-325`。 - mochi 写出路径:`server.go:1014-1020`、`1064-1111`、`1414-1438`;`clients.go:194-207`、`393-410`、`600-631`。 ```131:138:e:\code\NixMsg\internal\app\identity\lifecycle.go // 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)`: 1. 在 `st.mu` 下置 `closing=true`。此后对该连接的 PublishDown 一律返回 ErrNoConnection,保证 fatal 是 outbound 里的最后一帧。 2. 发布 fatal。 3. hook 增加 `OnPacketSent`:mochi 在字节写入连接后回调,包里带 Payload 和 PacketID。看到 fatal 就记下 PacketID,通知「已写出」。`OnQosComplete` 收到同一 PacketID 的 PUBACK 时通知「已确认」。 4. 在独立 goroutine 里等「已确认」,最多 `maxWait`(建议 2–3 秒);只等到「已写出」时再留 100–200ms 余量。然后调用 `DisconnectClient(cl, 0x98)`。这时 outbound 已空,WritePacket 会把 outbuf 连同 DISCONNECT 一起 flush,然后再 Stop。 5. `fatalKick` 和 logout 改用这个原语;去掉 fatalKick 里重复的 `ClearSession`。 6. serve 不再给 identity 注入 ConnControl,删掉 kickFlushDelay,只保留一个断开方。 7. 可选:停用时不给被停用端自己发 revoked。 Receive Maximum 用满时,fatal 仍可能进不了 outbound,退化为超时后断开,和现在一样。要彻底避免,可以把 fatal 改为 QoS 0,这需要记一条偏差。 - **改动文件**:`broker/broker.go`、`broker/hooks.go`、`broker/session.go`、`identity/lifecycle.go`、`cmd/nixmsg/serve.go`。 - **与其他模块的交互/冲突风险** - 等待不能放在上行 worker 里同步进行(DEVELOPMENT 第 5 节)。 - 等待期间令牌已作废,不影响安全。 - 和 #3 对 broker 的改动在同一批文件里。 - **需补测试** - 客户端不读、先塞满 outbound 再停用:客户端先收到 fatal,再收到 DISCONNECT。 - Receive Maximum=1 且有未确认消息时停用:在 `maxWait` 内断开。 - 每次停用只调用一次 `DisconnectClient`。 - **置信度**:代码阅读确定。无负载时 fatal 集成测试连跑 10 次都通过,未复现。 --- <sub>复审基线:main `4059a15`(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。</sub>
nixevol added the P2-mediumlane/brokerreview-2026-09-30 labels 2026-09-30 13:56:51 +08:00
Author
Owner

已合入 origin/main 0c9b459。落地提交 91e887b fix: 完成 broker 复审 B-03 至 B-12 (#11)。

已合入 origin/main `0c9b459`。落地提交 `91e887b` fix: 完成 broker 复审 B-03 至 B-12 (#11)。
Sign in to join this conversation.