[K-01][critical] Go SDK:断线后已发出的发送永不重交、回调持锁导致重入死锁、收包路径被阻塞、重连不恢复上下线订阅、fatal 原因可能丢失 #58

Closed
opened 2026-09-30 13:57:06 +08:00 by nixevol · 2 comments
Owner

编号:K-01 严重级:critical 工作线:SDK(sdk/*) 来源:审查 S-01、S-05、S-08、S-09、S-12、S-13、S-14、S-15、S-19、S-20
依赖:K-00 (#57)(对外语义条目) 被依赖:无

结论与统一方案

本 issue 汇总审查中涉及 Go SDK 的条目,由 Go SDK 负责人一次改完(它们集中在 send.go、receive.go、connect.go、client.go、api.go,拆开会互相冲突)。下方"问题明细"是跨四套的原文,请只看其中 Go 的部分。

条目 严重级 Go SDK 的改法
S-01 断线后已发出未收到 resp 的发送永不重交 critical 断线且将重连时,遍历发送队列把在途条目置回未在途、删除 pending rid、重算在途数,帧内容保持原样(原 id、原 send_at_ms);dispatchSend 改为 select 等待 ch、每次连接一个的 connGone、c.ctx.Done() 三路
S-05 回调持 cbMu 执行,回调里调 ChangeLoginPassword、手动 Ack(撤回先到)会重入死锁 high 锁内只记录状态变化,释放锁后由单一 goroutine 按顺序派发回调
S-08 下行分发阻塞 paho 收包协程,与 downLoop 等 resp 形成队头阻塞 high handleDown 永不阻塞(业务帧进无界 FIFO,presence/group_event 超阈值丢最旧);fatal 在收包路径同步处理;ack/receipt_ack 发出后异步处理结果
S-09 重连后不恢复上下线订阅 medium 保存 {ids, all},每次握手成功后异步重发 presence.watch
S-12 fatal 原因可能丢失、状态事件乱序、顶号原因写成 "0x8E" medium 收包路径识别 fatal 后立即置停止标志(含 transport 的 stopped)、失败发送队列与挂起请求、只上报一次原因;状态事件有序派发;顶号原因改 taken_over
S-13 断线时非发送请求不失败、Logout 不收尾 medium 断线钩子以 not_connected 失败非发送请求;默认请求超时 60 秒;Logout 与 Close 同样收尾
S-14 rate_limited 固定 1 秒重试 medium 按 K-00 的退避参数
S-15 断线后立即重连、PacketTimeout 默认 10 秒使连接超时失效 medium 按 K-00 (#57) 修订后的"重连退避"算法:断线后第一次重连也等约 1 秒;设 PacketTimeout = ConnectTimeout;MarkOffline 里的 !wasOnline 分支在 Go 中不会被调用(只有 OnConnectionDown 调它),改写时一并清理
S-16 / S-24 对外语义 medium / low 按 K-00 统一(首次连接超时、错误码、队列满、sendAt+delay、停止后 send、max_receive_bytes、取消语义)
S-19 离线入队的帧不检查整帧大小 low 入队和 drain 前都按握手给出的 max_frame_bytes 检查
S-20 回执去重集合没有上限 low 改为 10000 条的 LRU
S-23 工程问题(Go 部分) low 假传输移入测试(export_test.go 暴露注入点);执行 gofmt;示例只打印令牌前缀。门禁与 Go 版本对齐见 K-05
补充:SendResult 没有 json 标签,send_at_ms 解析不到,SendAtMs 恒为 0(types.go:93-97、send.go:199-200) low 加 json:"id"、json:"send_at_ms"、json:"state" 标签
补充:消息去重顺序表会重复记键。回调出错(receive.go:130-134)和撤回(:242)只删 map、不删 dedupOrd,同一键再次登记后顺序表里有两份,淘汰旧的那份时会误删新条目 low 消息去重和回执去重(S-20)共用一个 10000 条的 LRU(container/list 加 map),删除时同步移除节点
补充:failAuth、failKicked(connect.go:97-133)在 autopaho 回调里同步调用 tr.Stop,cm.Disconnect 要等主循环退出,而回调就在主循环里,于是固定白等 3 秒(代码推导,未实测) low 先置停止标志并上报原因,再在单独的 goroutine 里调 tr.Stop;Close 用自己的超时
补充:限速后重交复用原 rid(send.go:156、:186-194),同一连接内 rid 重复 low 按 K-00 (#57) 修订后的"rate_limited 重交退避":每次重交重新生成 rid 并重新序列化,消息 id、正文、send_at_ms 不变;和 S-01 的重交共用同一段代码

标"补充"的条目来自第二轮 SDK 审查,总审查人已对照代码核实,证据写在条目里(没有对应的原文段落)。

改动文件

sdk/go/send.go、receive.go、connect.go、client.go、api.go、types.go、backoff.go、transport_mqtt.go、transport_fake.go、example/minimal/main.go、README.md。

与其他问题的交互 / 冲突说明

  • 只改 sdk/go;不需要服务端配合(重交依赖服务端已有的消息号防重)。
  • 对外语义条目以 K-00 为准。

验收与测试

  • 假传输中 publish 后不回 resp,模拟断线重连:以同 id、同 send_at_ms 重发并完成;循环 150 次后仍能发送。
  • 在回调里调改密、手动 ack(撤回结果):2 秒内返回。
  • 注入 1 条待 ack 的 msg 加 1000 条 presence,然后回复 ack:200 ms 内完成。
  • watch 后模拟重连:新握手后再次发出同样参数的 presence.watch。
  • 处理 goroutine 阻塞时注入 fatal 加断线:只出现一次正确原因、没有重连尝试。
  • 退避单测(按 K-00 修订后的算法,比较去掉抖动的标称值):连续失败 6 次,间隔为 1、2、4、8、16、30 秒;每次在线不足 60 秒就断开时,间隔继续增长;稳定在线 61 秒后断开,下一次约 1 秒。CONNACK 延迟 15 秒仍能连上。
  • gofmt -l 为空;go test ./... 通过(sdk/go 目录)。
  • 假传输回 {"id":"m1","send_at_ms":123,"state":"scheduled"}:SendResult 三个字段都正确。
  • 同一键"登记→删除→再登记"后再插入 9999 个新键:该键仍在去重表里,没有被提前淘汰。
  • 在回调里触发认证失败:failAuth 在 100 毫秒内返回,原因只上报一次。
  • 连续回 2 次 rate_limited:三次发出的 rid 互不相同,消息 id 与 send_at_ms 相同。

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

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

[S-01] 断线时已发出、未收到 resp 的发送永不重交,调用方挂起,在途额度泄漏(Go / JS / Python / Java)

  • 严重级:critical
  • 分类:逻辑 / 与PRD不符
  • 现象与影响:发送帧 publish 后、resp 回来前断线,这一条会一直标记为“在途”。重连后的 drain/pump 只挑未在途的条目,所以永不按原消息号重交。
    • 调用方挂起情况:Go 的 Send 在 ctx 没有截止时间时永久阻塞;JS 的 Promise 永远不会完成;Python 调用时若已在线,会 wait(timeout=None) 永久阻塞;Java 等 1 小时后抛 busy,但条目仍留在队列里。
    • 在途计数永不回退:累计 100 次后该进程再也发不出消息,最终队列报满。
    • JS 更严重:MQTT.js 在 reconnectPeriod:0 下,非主动断开时只清掉 volatile 回调,QoS1 publish 的回调永不触发,所以在 PUBACK 之前断线也会卡住。
    • Java:PUBACK 之前断线时,pumpSends 直接把这条发送判为失败并出队,不会重交。
  • 证据:
    • Go:send.go:170-180(publish 成功后 rf := <-ch 既无超时,也没有断线出口)、send.go:141-153(只挑 !it.inflight)、connect.go:57-64(OnOffline 没有处理在途条目)。
    • JS:client.ts:494-543、client.ts:174-177、mqtt.ts:93-99(断线后新建 client,不 end 旧的);MQTT.js client.js:380-384、:882-904、:985-987、:490-496。
    • Python:client.py:861-873(只挑 rid=="")、client.py:605-640、client.py:323。
    • Java:Client.java:1000-1006、Client.java:1019-1029(publish 抛异常就判失败并出队)、Transport.java:383-391。
  • 文档依据:
    • PRD F19:“重连后按原消息号再交;已经发出但没等到结果的也一样”。
    • DEVELOPMENT 9 发送:“包括已发出但没收到 resp 的……重连后按原消息号、原请求内容再交”。
    • DEVELOPMENT 5:“提交成功但还没发出 resp 就断线时,客户端用同一消息号重试”。
  • 为何不是故意设计:DEVIATIONS 没有相关条目;各 SDK 的注释都写着“网络错误:保留队列”,意图就是重交。
  • 解决方案:
    1. 在“断线且将重连”的钩子里遍历发送队列:删除 pending rid,置回未在途(Python/Java 设 pending.rid=""),按实际状态重算在途数;帧内容保持原样(原 id、原 send_at_ms)。服务器按消息号防重,会返回原结果。
    2. Go 的 dispatchSend 改为 select 三路:ch、每次连接一个的 connGone、c.ctx.Done()。
    3. JS 在 close 事件里对旧 client 调 end(true),并加 generation 编号,防止同一条被重复完成。
    4. Java 的 publish 失败在未停止重连时回退为未在途,不判失败。
    5. 可选:等 resp 超时(如 60 秒)也按原 id 重交,兜底 mochi 在 inflight 满时静默丢弃的 resp。
  • 改动文件:sdk/go/send.go、connect.go;sdk/js/src/client.ts、mqtt.ts;sdk/python/src/nixmsg/client.py;sdk/java/.../Client.java
  • 与其他模块的交互/冲突风险:不需要服务端配合(依赖已有的消息号防重)。和 S-13 改的是同一个断线钩子。
  • 需补测试:假传输中 publish 后不回 resp,模拟断线重连,断言以同 id、同 send_at_ms 重发并完成;循环 150 次后仍能发送;Java 模拟 publish 抛异常,断言重交而非失败。
  • 置信度:代码阅读确定;MQTT.js 行为已按库源码核对。

[S-05] 回调在 SDK 内部锁下执行,回调里调用 SDK 方法会死锁;Java 还持全局锁等 PUBACK(Go / Python / Java)

  • 严重级:high
  • 分类:并发
  • 现象与影响:
    • Python:
      • _cb_lock 是不可重入的 threading.Lock,所有回调都在它下面执行。在回调里调用 close()、logout()、change_login_password(),或手动 ack() 时撤回先到,都会自锁。
      • on_connection 回调还持有 _lock:回调里调 send() 会永久卡住;调 _request 类方法会卡 60 秒,期间 paho 网络线程拿不到锁,心跳也停。
      • 另外存在 _cb_lock 与 _lock 交叉加锁(ABBA)的死锁。
    • Java:
      • setState 在 lock 内调用回调,而 ONLINE 回调就运行在 runLoop 线程上。回调里调 sendSync 会卡 1 小时,调 .get() 会永久卡住。
      • request() 在 synchronized(lock) 里调用 transport.publish(),最多阻塞 10 秒等 PUBACK。结果所有 SDK 操作都被串行到网络往返上。
      • HiveMQ 的回调和 PUBACK 完成都跑在 RxJava computation 调度器上(线程轮转分配)。如果 PUBACK 的完成恰好排到正阻塞在 dispatchResp 的那条线程,就会死锁 10 秒,然后以 busy 失败,而服务器其实已经处理了请求。
    • Go:cbMu 不可重入,每个回调期间都持有。ChangeLoginPassword 和 Ack(结果为撤回时会 emitRevoked)都会再次加这把锁。手动确认模式下,在 OnMessage 末尾调 Ack 若遇上撤回先到(PRD F13 本身就有这个竞态),downLoop 会永久卡死。
  • 证据:
    • Python:client.py:109、:192,220,573-578,605-640(持 _lock 调 _set_state)、:951-959、:787-788。
    • Java:Client.java:659-665,913-915,1034-1054,1108-1120、Transport.java:377-391;javap 可见 MqttRxClient.publishes/publishUnsafe 用 observeOn(applicationScheduler),默认为 Schedulers.computation()。
    • Go:receive.go:121-125,247-253、api.go:134-139。
    • 复现:Python 在 on_connection(online) 里调 close(),永不返回、锁一直被占;调 send() 同样卡死。Java 在 onConnection(ONLINE) 里调 sendSync,5 秒后 nixmsg-client 线程仍处于 TIMED_WAITING。
  • 文档依据:DEVELOPMENT 9“回调串行”;DEVIATIONS S1.7-6 和 S2 4–5 第 3 条专门为“回调里调用 SDK”做了 resp 分流。
  • 为何不是故意设计:那两条偏差只处理了“resp 与 msg 同队列”的问题,没有覆盖锁重入和持锁执行回调。
  • 解决方案:
    1. 锁内只记录状态变化,释放锁后由单一回调线程或 goroutine 按顺序派发,保证回调不在任何 SDK 锁下运行。
    2. 去掉串行用的互斥锁(或改为可重入锁)。
    3. Java 的 request() 在锁内只登记 pending,锁外 publish,且不在调用线程里等待 PUBACK。
  • 改动文件:sdk/python/.../client.py;sdk/java/.../Client.java、Transport.java;sdk/go/receive.go、connect.go、api.go
  • 与其他模块的交互/冲突风险:和 S-12(Go 事件乱序)是同一改动点。
  • 需补测试:在回调里调用 close / logout / send / 改密 / 手动 ack(撤回结果),断言 2 秒内返回;Java 用 -Drx2.computation-threads=1 做并发 ack 压测。
  • 置信度:Python 和 Java onConnection 已用测试复现;Go 的锁重入、Java 持锁等 PUBACK 为代码阅读确定,10 秒死锁的触发概率为“较高”。

[S-08] Go 下行分发会阻塞 paho 收包协程,与 downLoop 同步等 resp 形成队头阻塞(Go)

  • 严重级:high
  • 分类:并发
  • 现象与影响:
    • paho 用单个协程按顺序调用 OnPublishReceived,SDK 在里面对非 resp 帧阻塞写 downCh(容量 256)。
    • downLoop 处理 msg/receipt 时同步等 ack 的 resp(最长 30 秒),而 resp 也要经同一个收包协程投递。
    • 当 downCh 被上下线风暴、回执重推(每次唤醒 64 条)或群事件塞满,排在后面的 resp 送不进来,downLoop 等满 30 秒,吞吐退化为约每 30 秒一帧。
    • 同时 paho 要等处理函数返回才回 PUBACK,服务端 inflight 逐渐涨到 1024 后,mochi 会静默丢弃后续 QoS1 下行(包括 resp)。
  • 证据:receive.go:38-42,168-177,218-223、client.go:100;paho client.go:441-474;服务端 push.go:456-495。
  • 文档依据:DEVIATIONS S1.7-6 的目的就是避免 resp 与业务帧互等;DEVELOPMENT 5 说明 inflight 满时会静默丢弃。
  • 为何不是故意设计:S1.7-6 没有考虑 downCh 满时收包路径本身被阻塞。
  • 解决方案:
    1. handleDown 永不阻塞:业务帧进无界 FIFO;presence/group_event 超过阈值时丢最旧的(本来就是尽力送达)。
    2. fatal 在收包路径同步处理(见 S-12)。
    3. ack/receipt_ack 改为发出后异步处理结果,downLoop 不等待。
  • 改动文件:sdk/go/receive.go、client.go
  • 与其他模块的交互/冲突风险:和 S-5、S-12 在同一文件。
  • 需补测试:注入 1 条待 ack 的 msg 加 1000 条 presence,然后回复 ack,断言 200ms 内完成。
  • 置信度:较高(代码阅读,未真机复现)。

[S-09] Go / JS 重连后不恢复上下线订阅(Go / JS)

  • 严重级:medium
  • 分类:与PRD不符
  • 现象与影响:watch 只发一次,SDK 不记住应用的选择。任何一次重连(网络抖动、管理员踢下线、服务器重启)之后,上下线通知都会静默停止。Python/Java 已经实现了记忆和重新订阅。
  • 证据:Go api.go:74-82、connect.go:148-216;JS client.ts:601-606;对照 Python client.py:585-593、Java Client.java:672-683。
  • 文档依据:PRD F04“SDK 在重连后按应用上次的选择重新订阅”。
  • 为何不是故意设计:没有偏差记录,另外两套已经实现。
  • 解决方案:保存 {ids, all},每次握手成功后异步重发 presence.watch。
  • 改动文件:Go api.go、connect.go;JS client.ts
  • 与其他模块的交互/冲突风险:无。
  • 需补测试:watch 后模拟重连,断言新握手后再次发出同样参数的 presence.watch。
  • 置信度:代码阅读确定。

[S-12] fatal 与顶号的处理时序、原因上报不一致(Go / Python / Java;Go / JS 原因名)

  • 严重级:medium
  • 分类:协议一致性 / 并发
  • 现象与影响:服务端的顺序是:先清令牌,再发 fatal,20ms 后以 0x98 断开。
    1. Go/Python/Java 把 fatal 放进串行业务队列异步处理。如果处理线程正忙(执行回调或等 ack),断线会先被处理:Python/Java 立刻重连,Go 的退避 Func(0)=0 也立刻重连,拿作废令牌连上得到 0x86,于是先上报 auth_failed(session_invalid);Python/Java 随后再报一次真实原因;Go 的 failAuth 调 cancel 后,downLoop 在 select 里可能直接退出,fatal 原因根本没上报。JS 在收包回调里同步处理 fatal,是正确的。
    2. Go 每个状态事件起一个 goroutine,顺序不保证。
    3. Go/JS 被顶号时原因写 "0x8E",应为 taken_over。
    4. Go/JS fatal 之后不失败挂起中的其他请求。
  • 证据:服务端 session.go:316-336、broker.go:274-292;Go receive.go:38-42,75-87、connect.go:120,135-146、backoff.go:27-29、transport_mqtt.go:81-92(只看 t.stopped,fatal 没有设置它);Python client.py:652-655,735-745;Java Client.java:752,863-875;JS client.ts:207,304-311。
  • 文档依据:DEVELOPMENT 6.8;9 第 6 条“收到 fatal……事件 auth_failed 带原因”;5“原因 taken_over”。
  • 为何不是故意设计:没有偏差记录;JS 已经按同步方式处理。
  • 解决方案:
    1. 在收包路径识别 fatal 后立即置停止标志、失败发送队列和所有挂起请求、只上报一次原因、异步停止传输(Go 还要设 transport 的 stopped);之后到来的 CONNACK 拒绝不再覆盖原因。
    2. Go 的状态事件改为有序派发。
    3. Go/JS 顶号原因改为 taken_over。
  • 改动文件:Go receive.go、connect.go、transport_mqtt.go;Python client.py;Java Client.java;JS client.ts
  • 与其他模块的交互/冲突风险:和 S-5、S-8 同文件。
  • 需补测试:处理线程阻塞时注入 fatal 加断线,断言只出现一次正确原因、没有重连尝试;真机上停用、删除、重置密码分别断言原因为 disabled、deleted、password_reset。
  • 置信度:代码阅读确定;时序竞态部分为“较高”。

[S-13] 断线时非发送请求不失败、JS 请求无超时;Go / JS 的 logout 不收尾(Go / JS;Python / Java 部分)

  • 严重级:medium
  • 分类:协议一致性
  • 现象与影响:
    • 断线时挂起中的 recall、群操作等请求:JS 永不结束,Go 只能等调用方 ctx,Python/Java 要等满 60 秒;Python/Java 的 down 线程还会卡在 ack 上。
    • Go 的 Logout 不失败发送队列、不停止传输、不发 offline 事件;JS 的 logout 不失败队列、不发 offline 事件。
  • 证据:JS client.ts:413-437,718-725;Go receive.go:322-337、connect.go:232-245;Python client.py:913;Java Client.java:1055。
  • 文档依据:PRD F19“除发送以外的请求在离线时直接失败”;DEVELOPMENT 9“停止重连时队列里的发送全部以对应错误结束”。
  • 为何不是故意设计:没有偏差记录,Python/Java 的 logout 已经收尾。
  • 解决方案:断线钩子里以 not_connected 失败非发送请求;Go/JS 默认请求超时 60 秒;logout 做和 close 相同的收尾。
  • 改动文件:Go receive.go、connect.go;JS client.ts;Python client.py、Java Client.java(断线失败挂起请求)
  • 与其他模块的交互/冲突风险:和 S-1 同一断线钩子。
  • 需补测试:recall 未得到响应时断线,断言立即报错;logout 后断言队列清空并发出 offline。
  • 置信度:代码阅读确定。

[S-14] 发送收到 rate_limited 后重交没有退避(Python / Java 热循环;Go / JS 固定 1 秒)

  • 严重级:medium
  • 分类:协议一致性
  • 现象与影响:Python/Java 清空 rid 后立即唤醒发送泵,形成按往返时间计的重试风暴,持续打满服务器令牌桶。Go/JS 固定 1 秒重试,不会随连续限流拉长。
  • 证据:Python client.py:682-689;Java Client.java:797-805;Go send.go:186-195;JS client.ts:528-533。
  • 文档依据:DEVELOPMENT 9“按退避自动重交”。
  • 为何不是故意设计:没有偏差记录,文档明确要求退避。
  • 解决方案:每条发送记录连续限流次数,按 1 秒起、翻倍、上限 30 秒、±30% 抖动退避,成功后清零。
  • 改动文件:四套 send/pump 相关文件
  • 与其他模块的交互/冲突风险:无。
  • 需补测试:连续回 3 次 rate_limited,断言重发间隔约为 1、2、4 秒。
  • 置信度:代码阅读确定。

[S-15] 重连退避与连接超时四套不一致(Go / Python / Java)

  • 严重级:medium
  • 分类:设计 / 协议一致性
  • 现象与影响:
    • Go 断线后立即重连(Func(0)=0);paho 的 PacketTimeout 默认 10 秒,限制了等 CONNACK 的时间,SDK 设置的 30 秒连接超时实际不生效——这正是文档点名要改的情况。
    • Python/Java 断线后立即重连;连上又很快被断开(闪断)时退避不增长,和 Go/JS“未稳定在线则跨周期继续抬升”(S1.3)的做法不同;首次连接也会上报 reconnecting。
    • JS 基本符合。
  • 证据:Go backoff.go:24-39、autopaho net.go:43-52、transport_mqtt.go:115-133(没设 PacketTimeout)、paho client.go:186-188,272;Python client.py:458-499;Java Client.java:522-581。
  • 文档依据:DEVELOPMENT 9 第 3、4 条;第 15 节“不要在四种 SDK 里各写一套不一样的重连”。
  • 为何不是故意设计:S1.3 只说明了 Go/JS 自管退避,没有声明断线后立即重连或 Python/Java 闪断不抬升。
  • 解决方案:Go 记录“是否曾连上”,连上过之后首次重连也等待;设 PacketTimeout = ConnectTimeout。Python/Java 移植与 Go/JS 相同的退避对象。
  • 改动文件:Go backoff.go、transport_mqtt.go;Python client.py;Java Client.java
  • 与其他模块的交互/冲突风险:无。
  • 需补测试:退避对象单测(在线 5 秒断开约 2 秒、在线 61 秒断开约 1 秒);CONNACK 延迟 15 秒时仍能连上。
  • 置信度:代码阅读确定。

[S-19] 本地整帧检查有缺口(四套)

  • 严重级:low
  • 分类:协议一致性
  • 现象与影响:JS 用字符串长度(UTF-16 码元)比较字节上限;四套在离线时入队的帧都不检查,握手后也不复查。超限帧会被服务器直接断开,重连后重交同一帧,形成无限循环。
  • 证据:JS client.ts:478-482;Go send.go:96-102。
  • 文档依据:DEVELOPMENT 9 本地检查一段。
  • 为何不是故意设计:文档明确要求本地拦截,正是为了避免这个循环。
  • 解决方案:JS 按 UTF-8 字节计长;四套在入队和 drain 前都按握手给出的 max_frame_bytes 检查。
  • 改动文件:四套 send 相关文件
  • 与其他模块的交互/冲突风险:无。
  • 需补测试:离线时发送超大 meta 应报 frame_too_large;用中文 meta 构造“UTF-16 未超、UTF-8 超限”的用例。
  • 置信度:代码阅读确定。

[S-20] Go / JS 的回执去重集合没有上限(Go / JS)

  • 严重级:low
  • 分类:数据(资源)
  • 现象与影响:去重集合只增不删,长期运行的发送端内存持续增长。
  • 证据:Go client.go:72、receive.go:201-208;JS client.ts:93。
  • 文档依据:DEVELOPMENT 9 去重容量 10000 的约定(Python/Java 已按此为回执设上限)。
  • 为何不是故意设计:Python/Java 已按 10000 条设上限。
  • 解决方案:改为 10000 条的 LRU。
  • 改动文件:Go client.go、receive.go;JS client.ts
  • 与其他模块的交互/冲突风险:无。
  • 需补测试:注入 10001 个不同回执,断言集合大小不超过 10000。
  • 置信度:代码阅读确定。

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

**编号**:K-01 **严重级**:critical **工作线**:SDK(sdk/*) **来源**:审查 S-01、S-05、S-08、S-09、S-12、S-13、S-14、S-15、S-19、S-20 **依赖**:K-00 (#57)(对外语义条目) **被依赖**:无 ### 结论与统一方案 本 issue 汇总审查中涉及 Go SDK 的条目,由 Go SDK 负责人一次改完(它们集中在 `send.go`、`receive.go`、`connect.go`、`client.go`、`api.go`,拆开会互相冲突)。下方"问题明细"是跨四套的原文,请只看其中 Go 的部分。 | 条目 | 严重级 | Go SDK 的改法 | |---|---|---| | S-01 断线后已发出未收到 resp 的发送永不重交 | critical | 断线且将重连时,遍历发送队列把在途条目置回未在途、删除 pending rid、重算在途数,帧内容保持原样(原 id、原 `send_at_ms`);`dispatchSend` 改为 `select` 等待 `ch`、每次连接一个的 `connGone`、`c.ctx.Done()` 三路 | | S-05 回调持 `cbMu` 执行,回调里调 `ChangeLoginPassword`、手动 `Ack`(撤回先到)会重入死锁 | high | 锁内只记录状态变化,释放锁后由单一 goroutine 按顺序派发回调 | | S-08 下行分发阻塞 paho 收包协程,与 downLoop 等 resp 形成队头阻塞 | high | `handleDown` 永不阻塞(业务帧进无界 FIFO,presence/group_event 超阈值丢最旧);fatal 在收包路径同步处理;ack/receipt_ack 发出后异步处理结果 | | S-09 重连后不恢复上下线订阅 | medium | 保存 `{ids, all}`,每次握手成功后异步重发 presence.watch | | S-12 fatal 原因可能丢失、状态事件乱序、顶号原因写成 "0x8E" | medium | 收包路径识别 fatal 后立即置停止标志(含 transport 的 stopped)、失败发送队列与挂起请求、只上报一次原因;状态事件有序派发;顶号原因改 `taken_over` | | S-13 断线时非发送请求不失败、`Logout` 不收尾 | medium | 断线钩子以 `not_connected` 失败非发送请求;默认请求超时 60 秒;`Logout` 与 `Close` 同样收尾 | | S-14 rate_limited 固定 1 秒重试 | medium | 按 K-00 的退避参数 | | S-15 断线后立即重连、`PacketTimeout` 默认 10 秒使连接超时失效 | medium | 按 K-00 (#57) 修订后的"重连退避"算法:断线后第一次重连也等约 1 秒;设 `PacketTimeout = ConnectTimeout`;`MarkOffline` 里的 `!wasOnline` 分支在 Go 中不会被调用(只有 `OnConnectionDown` 调它),改写时一并清理 | | S-16 / S-24 对外语义 | medium / low | 按 K-00 统一(首次连接超时、错误码、队列满、sendAt+delay、停止后 send、max_receive_bytes、取消语义) | | S-19 离线入队的帧不检查整帧大小 | low | 入队和 drain 前都按握手给出的 `max_frame_bytes` 检查 | | S-20 回执去重集合没有上限 | low | 改为 10000 条的 LRU | | S-23 工程问题(Go 部分) | low | 假传输移入测试(`export_test.go` 暴露注入点);执行 gofmt;示例只打印令牌前缀。门禁与 Go 版本对齐见 K-05 | | 补充:`SendResult` 没有 json 标签,`send_at_ms` 解析不到,`SendAtMs` 恒为 0(`types.go:93-97`、`send.go:199-200`) | low | 加 `json:"id"`、`json:"send_at_ms"`、`json:"state"` 标签 | | 补充:消息去重顺序表会重复记键。回调出错(`receive.go:130-134`)和撤回(`:242`)只删 map、不删 `dedupOrd`,同一键再次登记后顺序表里有两份,淘汰旧的那份时会误删新条目 | low | 消息去重和回执去重(S-20)共用一个 10000 条的 LRU(`container/list` 加 map),删除时同步移除节点 | | 补充:`failAuth`、`failKicked`(`connect.go:97-133`)在 autopaho 回调里同步调用 `tr.Stop`,`cm.Disconnect` 要等主循环退出,而回调就在主循环里,于是固定白等 3 秒(代码推导,未实测) | low | 先置停止标志并上报原因,再在单独的 goroutine 里调 `tr.Stop`;`Close` 用自己的超时 | | 补充:限速后重交复用原 rid(`send.go:156`、`:186-194`),同一连接内 rid 重复 | low | 按 K-00 (#57) 修订后的"rate_limited 重交退避":每次重交重新生成 rid 并重新序列化,消息 id、正文、`send_at_ms` 不变;和 S-01 的重交共用同一段代码 | 标"补充"的条目来自第二轮 SDK 审查,总审查人已对照代码核实,证据写在条目里(没有对应的原文段落)。 ### 改动文件 `sdk/go/send.go`、`receive.go`、`connect.go`、`client.go`、`api.go`、`types.go`、`backoff.go`、`transport_mqtt.go`、`transport_fake.go`、`example/minimal/main.go`、`README.md`。 ### 与其他问题的交互 / 冲突说明 - 只改 sdk/go;不需要服务端配合(重交依赖服务端已有的消息号防重)。 - 对外语义条目以 K-00 为准。 ### 验收与测试 - 假传输中 publish 后不回 resp,模拟断线重连:以同 id、同 `send_at_ms` 重发并完成;循环 150 次后仍能发送。 - 在回调里调改密、手动 ack(撤回结果):2 秒内返回。 - 注入 1 条待 ack 的 msg 加 1000 条 presence,然后回复 ack:200 ms 内完成。 - watch 后模拟重连:新握手后再次发出同样参数的 presence.watch。 - 处理 goroutine 阻塞时注入 fatal 加断线:只出现一次正确原因、没有重连尝试。 - 退避单测(按 K-00 修订后的算法,比较去掉抖动的标称值):连续失败 6 次,间隔为 1、2、4、8、16、30 秒;每次在线不足 60 秒就断开时,间隔继续增长;稳定在线 61 秒后断开,下一次约 1 秒。CONNACK 延迟 15 秒仍能连上。 - `gofmt -l` 为空;`go test ./...` 通过(sdk/go 目录)。 - 假传输回 `{"id":"m1","send_at_ms":123,"state":"scheduled"}`:`SendResult` 三个字段都正确。 - 同一键"登记→删除→再登记"后再插入 9999 个新键:该键仍在去重表里,没有被提前淘汰。 - 在回调里触发认证失败:`failAuth` 在 100 毫秒内返回,原因只上报一次。 - 连续回 2 次 rate_limited:三次发出的 rid 互不相同,消息 id 与 `send_at_ms` 相同。 --- ### 问题明细(各区审查原文,证据含文件与行号) > 以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。**解决方案以本 issue 上方的"结论与统一方案"为准**;原文里的方案与之不一致时,按上方执行。 #### [S-01] 断线时已发出、未收到 resp 的发送永不重交,调用方挂起,在途额度泄漏(Go / JS / Python / Java) - **严重级**:critical - **分类**:逻辑 / 与PRD不符 - **现象与影响**:发送帧 publish 后、resp 回来前断线,这一条会一直标记为“在途”。重连后的 drain/pump 只挑未在途的条目,所以永不按原消息号重交。 - 调用方挂起情况:Go 的 `Send` 在 ctx 没有截止时间时永久阻塞;JS 的 Promise 永远不会完成;Python 调用时若已在线,会 `wait(timeout=None)` 永久阻塞;Java 等 1 小时后抛 busy,但条目仍留在队列里。 - 在途计数永不回退:累计 100 次后该进程再也发不出消息,最终队列报满。 - JS 更严重:MQTT.js 在 `reconnectPeriod:0` 下,非主动断开时只清掉 volatile 回调,QoS1 publish 的回调永不触发,所以在 PUBACK 之前断线也会卡住。 - Java:PUBACK 之前断线时,`pumpSends` 直接把这条发送判为失败并出队,不会重交。 - **证据**: - Go:`send.go:170-180`(publish 成功后 `rf := <-ch` 既无超时,也没有断线出口)、`send.go:141-153`(只挑 `!it.inflight`)、`connect.go:57-64`(OnOffline 没有处理在途条目)。 - JS:`client.ts:494-543`、`client.ts:174-177`、`mqtt.ts:93-99`(断线后新建 client,不 `end` 旧的);MQTT.js `client.js:380-384`、`:882-904`、`:985-987`、`:490-496`。 - Python:`client.py:861-873`(只挑 `rid==""`)、`client.py:605-640`、`client.py:323`。 - Java:`Client.java:1000-1006`、`Client.java:1019-1029`(publish 抛异常就判失败并出队)、`Transport.java:383-391`。 - **文档依据**: - PRD F19:“重连后按原消息号再交;已经发出但没等到结果的也一样”。 - DEVELOPMENT 9 发送:“包括已发出但没收到 resp 的……重连后按原消息号、原请求内容再交”。 - DEVELOPMENT 5:“提交成功但还没发出 resp 就断线时,客户端用同一消息号重试”。 - **为何不是故意设计**:DEVIATIONS 没有相关条目;各 SDK 的注释都写着“网络错误:保留队列”,意图就是重交。 - **解决方案**: 1. 在“断线且将重连”的钩子里遍历发送队列:删除 pending rid,置回未在途(Python/Java 设 `pending.rid=""`),按实际状态重算在途数;帧内容保持原样(原 id、原 `send_at_ms`)。服务器按消息号防重,会返回原结果。 2. Go 的 `dispatchSend` 改为 `select` 三路:`ch`、每次连接一个的 `connGone`、`c.ctx.Done()`。 3. JS 在 `close` 事件里对旧 client 调 `end(true)`,并加 generation 编号,防止同一条被重复完成。 4. Java 的 publish 失败在未停止重连时回退为未在途,不判失败。 5. 可选:等 resp 超时(如 60 秒)也按原 id 重交,兜底 mochi 在 inflight 满时静默丢弃的 resp。 - **改动文件**:`sdk/go/send.go`、`connect.go`;`sdk/js/src/client.ts`、`mqtt.ts`;`sdk/python/src/nixmsg/client.py`;`sdk/java/.../Client.java` - **与其他模块的交互/冲突风险**:不需要服务端配合(依赖已有的消息号防重)。和 S-13 改的是同一个断线钩子。 - **需补测试**:假传输中 publish 后不回 resp,模拟断线重连,断言以同 id、同 `send_at_ms` 重发并完成;循环 150 次后仍能发送;Java 模拟 publish 抛异常,断言重交而非失败。 - **置信度**:代码阅读确定;MQTT.js 行为已按库源码核对。 #### [S-05] 回调在 SDK 内部锁下执行,回调里调用 SDK 方法会死锁;Java 还持全局锁等 PUBACK(Go / Python / Java) - **严重级**:high - **分类**:并发 - **现象与影响**: - **Python**: - `_cb_lock` 是不可重入的 `threading.Lock`,所有回调都在它下面执行。在回调里调用 `close()`、`logout()`、`change_login_password()`,或手动 `ack()` 时撤回先到,都会自锁。 - on_connection 回调还持有 `_lock`:回调里调 `send()` 会永久卡住;调 `_request` 类方法会卡 60 秒,期间 paho 网络线程拿不到锁,心跳也停。 - 另外存在 `_cb_lock` 与 `_lock` 交叉加锁(ABBA)的死锁。 - **Java**: - `setState` 在 `lock` 内调用回调,而 ONLINE 回调就运行在 runLoop 线程上。回调里调 `sendSync` 会卡 1 小时,调 `.get()` 会永久卡住。 - `request()` 在 `synchronized(lock)` 里调用 `transport.publish()`,最多阻塞 10 秒等 PUBACK。结果所有 SDK 操作都被串行到网络往返上。 - HiveMQ 的回调和 PUBACK 完成都跑在 RxJava computation 调度器上(线程轮转分配)。如果 PUBACK 的完成恰好排到正阻塞在 `dispatchResp` 的那条线程,就会死锁 10 秒,然后以 busy 失败,而服务器其实已经处理了请求。 - **Go**:`cbMu` 不可重入,每个回调期间都持有。`ChangeLoginPassword` 和 `Ack`(结果为撤回时会 `emitRevoked`)都会再次加这把锁。手动确认模式下,在 OnMessage 末尾调 `Ack` 若遇上撤回先到(PRD F13 本身就有这个竞态),downLoop 会永久卡死。 - **证据**: - Python:`client.py:109`、`:192,220,573-578,605-640`(持 `_lock` 调 `_set_state`)、`:951-959`、`:787-788`。 - Java:`Client.java:659-665,913-915,1034-1054,1108-1120`、`Transport.java:377-391`;javap 可见 `MqttRxClient.publishes/publishUnsafe` 用 `observeOn(applicationScheduler)`,默认为 `Schedulers.computation()`。 - Go:`receive.go:121-125,247-253`、`api.go:134-139`。 - 复现:Python 在 on_connection(online) 里调 `close()`,永不返回、锁一直被占;调 `send()` 同样卡死。Java 在 onConnection(ONLINE) 里调 `sendSync`,5 秒后 nixmsg-client 线程仍处于 TIMED_WAITING。 - **文档依据**:DEVELOPMENT 9“回调串行”;DEVIATIONS S1.7-6 和 S2 4–5 第 3 条专门为“回调里调用 SDK”做了 resp 分流。 - **为何不是故意设计**:那两条偏差只处理了“resp 与 msg 同队列”的问题,没有覆盖锁重入和持锁执行回调。 - **解决方案**: 1. 锁内只记录状态变化,释放锁后由单一回调线程或 goroutine 按顺序派发,保证回调不在任何 SDK 锁下运行。 2. 去掉串行用的互斥锁(或改为可重入锁)。 3. Java 的 `request()` 在锁内只登记 pending,锁外 publish,且不在调用线程里等待 PUBACK。 - **改动文件**:`sdk/python/.../client.py`;`sdk/java/.../Client.java`、`Transport.java`;`sdk/go/receive.go`、`connect.go`、`api.go` - **与其他模块的交互/冲突风险**:和 S-12(Go 事件乱序)是同一改动点。 - **需补测试**:在回调里调用 close / logout / send / 改密 / 手动 ack(撤回结果),断言 2 秒内返回;Java 用 `-Drx2.computation-threads=1` 做并发 ack 压测。 - **置信度**:Python 和 Java onConnection 已用测试复现;Go 的锁重入、Java 持锁等 PUBACK 为代码阅读确定,10 秒死锁的触发概率为“较高”。 #### [S-08] Go 下行分发会阻塞 paho 收包协程,与 downLoop 同步等 resp 形成队头阻塞(Go) - **严重级**:high - **分类**:并发 - **现象与影响**: - paho 用单个协程按顺序调用 `OnPublishReceived`,SDK 在里面对非 resp 帧阻塞写 `downCh`(容量 256)。 - downLoop 处理 msg/receipt 时同步等 ack 的 resp(最长 30 秒),而 resp 也要经同一个收包协程投递。 - 当 downCh 被上下线风暴、回执重推(每次唤醒 64 条)或群事件塞满,排在后面的 resp 送不进来,downLoop 等满 30 秒,吞吐退化为约每 30 秒一帧。 - 同时 paho 要等处理函数返回才回 PUBACK,服务端 inflight 逐渐涨到 1024 后,mochi 会静默丢弃后续 QoS1 下行(包括 resp)。 - **证据**:`receive.go:38-42,168-177,218-223`、`client.go:100`;paho `client.go:441-474`;服务端 `push.go:456-495`。 - **文档依据**:DEVIATIONS S1.7-6 的目的就是避免 resp 与业务帧互等;DEVELOPMENT 5 说明 inflight 满时会静默丢弃。 - **为何不是故意设计**:S1.7-6 没有考虑 downCh 满时收包路径本身被阻塞。 - **解决方案**: 1. `handleDown` 永不阻塞:业务帧进无界 FIFO;presence/group_event 超过阈值时丢最旧的(本来就是尽力送达)。 2. fatal 在收包路径同步处理(见 S-12)。 3. ack/receipt_ack 改为发出后异步处理结果,downLoop 不等待。 - **改动文件**:`sdk/go/receive.go`、`client.go` - **与其他模块的交互/冲突风险**:和 S-5、S-12 在同一文件。 - **需补测试**:注入 1 条待 ack 的 msg 加 1000 条 presence,然后回复 ack,断言 200ms 内完成。 - **置信度**:较高(代码阅读,未真机复现)。 #### [S-09] Go / JS 重连后不恢复上下线订阅(Go / JS) - **严重级**:medium - **分类**:与PRD不符 - **现象与影响**:watch 只发一次,SDK 不记住应用的选择。任何一次重连(网络抖动、管理员踢下线、服务器重启)之后,上下线通知都会静默停止。Python/Java 已经实现了记忆和重新订阅。 - **证据**:Go `api.go:74-82`、`connect.go:148-216`;JS `client.ts:601-606`;对照 Python `client.py:585-593`、Java `Client.java:672-683`。 - **文档依据**:PRD F04“SDK 在重连后按应用上次的选择重新订阅”。 - **为何不是故意设计**:没有偏差记录,另外两套已经实现。 - **解决方案**:保存 `{ids, all}`,每次握手成功后异步重发 presence.watch。 - **改动文件**:Go `api.go`、`connect.go`;JS `client.ts` - **与其他模块的交互/冲突风险**:无。 - **需补测试**:watch 后模拟重连,断言新握手后再次发出同样参数的 presence.watch。 - **置信度**:代码阅读确定。 #### [S-12] fatal 与顶号的处理时序、原因上报不一致(Go / Python / Java;Go / JS 原因名) - **严重级**:medium - **分类**:协议一致性 / 并发 - **现象与影响**:服务端的顺序是:先清令牌,再发 fatal,20ms 后以 0x98 断开。 1. Go/Python/Java 把 fatal 放进串行业务队列异步处理。如果处理线程正忙(执行回调或等 ack),断线会先被处理:Python/Java 立刻重连,Go 的退避 `Func(0)=0` 也立刻重连,拿作废令牌连上得到 0x86,于是先上报 `auth_failed(session_invalid)`;Python/Java 随后再报一次真实原因;Go 的 `failAuth` 调 cancel 后,downLoop 在 select 里可能直接退出,fatal 原因根本没上报。JS 在收包回调里同步处理 fatal,是正确的。 2. Go 每个状态事件起一个 goroutine,顺序不保证。 3. Go/JS 被顶号时原因写 `"0x8E"`,应为 `taken_over`。 4. Go/JS fatal 之后不失败挂起中的其他请求。 - **证据**:服务端 `session.go:316-336`、`broker.go:274-292`;Go `receive.go:38-42,75-87`、`connect.go:120,135-146`、`backoff.go:27-29`、`transport_mqtt.go:81-92`(只看 `t.stopped`,fatal 没有设置它);Python `client.py:652-655,735-745`;Java `Client.java:752,863-875`;JS `client.ts:207,304-311`。 - **文档依据**:DEVELOPMENT 6.8;9 第 6 条“收到 fatal……事件 auth_failed 带原因”;5“原因 taken_over”。 - **为何不是故意设计**:没有偏差记录;JS 已经按同步方式处理。 - **解决方案**: 1. 在收包路径识别 fatal 后立即置停止标志、失败发送队列和所有挂起请求、只上报一次原因、异步停止传输(Go 还要设 transport 的 stopped);之后到来的 CONNACK 拒绝不再覆盖原因。 2. Go 的状态事件改为有序派发。 3. Go/JS 顶号原因改为 `taken_over`。 - **改动文件**:Go `receive.go`、`connect.go`、`transport_mqtt.go`;Python `client.py`;Java `Client.java`;JS `client.ts` - **与其他模块的交互/冲突风险**:和 S-5、S-8 同文件。 - **需补测试**:处理线程阻塞时注入 fatal 加断线,断言只出现一次正确原因、没有重连尝试;真机上停用、删除、重置密码分别断言原因为 disabled、deleted、password_reset。 - **置信度**:代码阅读确定;时序竞态部分为“较高”。 #### [S-13] 断线时非发送请求不失败、JS 请求无超时;Go / JS 的 logout 不收尾(Go / JS;Python / Java 部分) - **严重级**:medium - **分类**:协议一致性 - **现象与影响**: - 断线时挂起中的 recall、群操作等请求:JS 永不结束,Go 只能等调用方 ctx,Python/Java 要等满 60 秒;Python/Java 的 down 线程还会卡在 ack 上。 - Go 的 `Logout` 不失败发送队列、不停止传输、不发 offline 事件;JS 的 `logout` 不失败队列、不发 offline 事件。 - **证据**:JS `client.ts:413-437,718-725`;Go `receive.go:322-337`、`connect.go:232-245`;Python `client.py:913`;Java `Client.java:1055`。 - **文档依据**:PRD F19“除发送以外的请求在离线时直接失败”;DEVELOPMENT 9“停止重连时队列里的发送全部以对应错误结束”。 - **为何不是故意设计**:没有偏差记录,Python/Java 的 logout 已经收尾。 - **解决方案**:断线钩子里以 not_connected 失败非发送请求;Go/JS 默认请求超时 60 秒;logout 做和 close 相同的收尾。 - **改动文件**:Go `receive.go`、`connect.go`;JS `client.ts`;Python `client.py`、Java `Client.java`(断线失败挂起请求) - **与其他模块的交互/冲突风险**:和 S-1 同一断线钩子。 - **需补测试**:recall 未得到响应时断线,断言立即报错;logout 后断言队列清空并发出 offline。 - **置信度**:代码阅读确定。 #### [S-14] 发送收到 rate_limited 后重交没有退避(Python / Java 热循环;Go / JS 固定 1 秒) - **严重级**:medium - **分类**:协议一致性 - **现象与影响**:Python/Java 清空 rid 后立即唤醒发送泵,形成按往返时间计的重试风暴,持续打满服务器令牌桶。Go/JS 固定 1 秒重试,不会随连续限流拉长。 - **证据**:Python `client.py:682-689`;Java `Client.java:797-805`;Go `send.go:186-195`;JS `client.ts:528-533`。 - **文档依据**:DEVELOPMENT 9“按退避自动重交”。 - **为何不是故意设计**:没有偏差记录,文档明确要求退避。 - **解决方案**:每条发送记录连续限流次数,按 1 秒起、翻倍、上限 30 秒、±30% 抖动退避,成功后清零。 - **改动文件**:四套 send/pump 相关文件 - **与其他模块的交互/冲突风险**:无。 - **需补测试**:连续回 3 次 rate_limited,断言重发间隔约为 1、2、4 秒。 - **置信度**:代码阅读确定。 #### [S-15] 重连退避与连接超时四套不一致(Go / Python / Java) - **严重级**:medium - **分类**:设计 / 协议一致性 - **现象与影响**: - Go 断线后立即重连(`Func(0)=0`);paho 的 `PacketTimeout` 默认 10 秒,限制了等 CONNACK 的时间,SDK 设置的 30 秒连接超时实际不生效——这正是文档点名要改的情况。 - Python/Java 断线后立即重连;连上又很快被断开(闪断)时退避不增长,和 Go/JS“未稳定在线则跨周期继续抬升”(S1.3)的做法不同;首次连接也会上报 reconnecting。 - JS 基本符合。 - **证据**:Go `backoff.go:24-39`、autopaho `net.go:43-52`、`transport_mqtt.go:115-133`(没设 PacketTimeout)、paho `client.go:186-188,272`;Python `client.py:458-499`;Java `Client.java:522-581`。 - **文档依据**:DEVELOPMENT 9 第 3、4 条;第 15 节“不要在四种 SDK 里各写一套不一样的重连”。 - **为何不是故意设计**:S1.3 只说明了 Go/JS 自管退避,没有声明断线后立即重连或 Python/Java 闪断不抬升。 - **解决方案**:Go 记录“是否曾连上”,连上过之后首次重连也等待;设 `PacketTimeout = ConnectTimeout`。Python/Java 移植与 Go/JS 相同的退避对象。 - **改动文件**:Go `backoff.go`、`transport_mqtt.go`;Python `client.py`;Java `Client.java` - **与其他模块的交互/冲突风险**:无。 - **需补测试**:退避对象单测(在线 5 秒断开约 2 秒、在线 61 秒断开约 1 秒);CONNACK 延迟 15 秒时仍能连上。 - **置信度**:代码阅读确定。 #### [S-19] 本地整帧检查有缺口(四套) - **严重级**:low - **分类**:协议一致性 - **现象与影响**:JS 用字符串长度(UTF-16 码元)比较字节上限;四套在离线时入队的帧都不检查,握手后也不复查。超限帧会被服务器直接断开,重连后重交同一帧,形成无限循环。 - **证据**:JS `client.ts:478-482`;Go `send.go:96-102`。 - **文档依据**:DEVELOPMENT 9 本地检查一段。 - **为何不是故意设计**:文档明确要求本地拦截,正是为了避免这个循环。 - **解决方案**:JS 按 UTF-8 字节计长;四套在入队和 drain 前都按握手给出的 max_frame_bytes 检查。 - **改动文件**:四套 send 相关文件 - **与其他模块的交互/冲突风险**:无。 - **需补测试**:离线时发送超大 meta 应报 frame_too_large;用中文 meta 构造“UTF-16 未超、UTF-8 超限”的用例。 - **置信度**:代码阅读确定。 #### [S-20] Go / JS 的回执去重集合没有上限(Go / JS) - **严重级**:low - **分类**:数据(资源) - **现象与影响**:去重集合只增不删,长期运行的发送端内存持续增长。 - **证据**:Go `client.go:72`、`receive.go:201-208`;JS `client.ts:93`。 - **文档依据**:DEVELOPMENT 9 去重容量 10000 的约定(Python/Java 已按此为回执设上限)。 - **为何不是故意设计**:Python/Java 已按 10000 条设上限。 - **解决方案**:改为 10000 条的 LRU。 - **改动文件**:Go `client.go`、`receive.go`;JS `client.ts` - **与其他模块的交互/冲突风险**:无。 - **需补测试**:注入 10001 个不同回执,断言集合大小不超过 10000。 - **置信度**:代码阅读确定。 --- <sub>复审基线:main `4059a15`(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。</sub>
nixevol added the P0-criticallane/sdkreview-2026-09-30 labels 2026-09-30 13:57:06 +08:00
Author
Owner

按第二轮 SDK 审查补充 4 条(SendAtMs 恒为 0、去重顺序表重复记键、认证失败白等 3 秒、限速重交复用 rid),并按 K-00 (#57) 修订后的退避算法更新了 S-15 的改法和退避测试。补充条目已由总审查人对照代码核实,详见正文。

按第二轮 SDK 审查补充 4 条(`SendAtMs` 恒为 0、去重顺序表重复记键、认证失败白等 3 秒、限速重交复用 rid),并按 K-00 (#57) 修订后的退避算法更新了 S-15 的改法和退避测试。补充条目已由总审查人对照代码核实,详见正文。
Author
Owner

已合入 origin/main 0c9b459。落地提交 f6f8ccf fix: 按 K-00 约定修复 Go SDK 断线重交与退避 (#58)。

已合入 origin/main `0c9b459`。落地提交 `f6f8ccf` fix: 按 K-00 约定修复 Go SDK 断线重交与退避 (#58)。
Sign in to join this conversation.