[K-04][critical] Java SDK:把"连不上服务器"判成密码错误并永久停止重连、持锁等待 PUBACK 与回调重入会死锁、断线后发送不重交 #61

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

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

结论与统一方案

本 issue 汇总涉及 Java SDK 的条目,由 Java SDK 负责人一次改完(集中在 Client.java、Transport.java、Types.java)。下方"问题明细"是跨四套的原文,请只看 Java 的部分。

条目 严重级 Java SDK 的改法
S-02 连接被拒、超时、DNS 失败被按异常文本判为 bad_credentials 并永久停止重连(已复现) critical 只以 extractConnAckReason 取到的 BAD_USER_NAME_OR_PASSWORD / NOT_AUTHORIZED / BANNED 判定停止;删除按文本判断的兜底,其余一律视为网络问题并继续重连
S-01 断线后发送不重交(PUBACK 前断线直接判失败出队) critical publish 失败在未停止重连时回退为未在途,不判失败;断线时在途条目按原 id 重交
S-05 setState 在锁内调回调、request() 在 synchronized(lock) 里等 PUBACK 最多 10 秒(已复现 onConnection 死锁) high 锁内只记录状态,锁外按序派发回调;request() 锁内只登记 pending,锁外 publish,不在调用线程等 PUBACK
S-06 自动确认失败被吞、重复回执不 ack、手动 ack 失败 future 正常完成 high 回调成功即置为 acked 再 ack;重复回执也 ack;手动 ack 失败时 future 以异常完成
S-10 定时发送不做时钟偏差校正 medium 增加本机时间语义的 sendAt(epoch 毫秒或 java.util.Date,Android API 24 没有 java.time),入队时加偏差;Javadoc 写明 sendAtMs 是服务器时间;两者同时设置报 bad_request
S-11 心跳用库默认 60 秒 medium .keepAlive(30)
S-12 fatal 与断线顺序导致先报 session_invalid 再报真实原因 medium 收包路径识别 fatal 后立即置停止标志、失败发送与挂起请求、只报一次原因
S-13 断线时挂起请求要等满 60 秒 medium 断线钩子以 not_connected 立即失败非发送请求
S-14 rate_limited 后立即重交形成热循环 medium 按 K-00 的退避参数
S-15 断线后立即重连、闪断不抬升退避 medium 按 K-00 (#57) 修订后的"重连退避"算法实现(不要照搬 JS 现有代码,它有双重翻倍,见 K-02 (#59))
S-16 / S-24 对外语义 medium / low 按 K-00 统一
S-18 https 走明文、mqtt:// 未开启也走裸 TCP low 按 K-00 的 URL 映射
S-19 离线入队的帧不检查整帧大小 low 入队和 drain 前都检查
S-23 工程问题(Java 部分) low 假传输不随 jar 发布;pom 把 LICENSE 放进 META-INF
补充:Types.java:4,63-71 用 java.util.Base64,Android 从 API 26 才提供,而 D30 规定最低 API 24;在 API 24/25 上 Body.ofBytes 和 base64 正文的本地长度检查会抛 NoClassDefFoundError medium SDK 内置小型 Base64 编解码(标准字母表、带填充),解码长度按字符串长度和填充计算;不以接入方开启 core library desugaring 为前提;可选加 animal-sniffer 的 Android API 24 签名检查
补充:logout() 吞掉请求失败(Client.java:227-232),离线时服务端没有作废令牌,应用却以为已经退出 medium 按 K-00 (#57) 新增的"logout"一行:future 以请求错误异常完成,本地照常停止重连、清空令牌、结束队列
补充:updateSelf(String, Integer defaultDelayMs)(Client.java:391-403)最多约 24.8 天,达不到 max_schedule_seconds 默认的 365 天 low 按 K-00 新增的"时长参数"一行改为 Long(公开 API 变更,README 同步)

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

改动文件

sdk/java/src/main/java/asia/asio/nixmsg/Client.java、Transport.java、Types.java、Protocol.java,pom.xml,sdk/java/src/test/*,README。

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

  • 只改 sdk/java;对外语义条目以 K-00 为准;公开 API 增项需在发布前完成。
  • 测试结束后删除 target 等产物。

验收与测试

  • 连接未监听的端口:多次重试、不出现 AUTH_FAILED;ChecklistTest 增加"服务端停 3 秒后重启能自动恢复"。
  • onConnection(ONLINE) 里调 sendSync 不卡住;用 -Drx2.computation-threads=1 并发 ack 压测不出现 busy。
  • 模拟 publish 抛异常:重交而非失败。
  • hello 返回的服务器时间比本机快 60 秒时,帧里 send_at_ms 约等于本机时间加 62 秒。
  • 连接参数中 keepalive 为 30。
  • 退避单测(比较去掉抖动的标称值):连续失败 6 次,间隔为 1、2、4、8、16、30 秒;断线后第一次重连约 1 秒。
  • Base64 单测:编码结果和解码长度(0、1、2 个填充)与标准实现一致。
  • 离线时调用 logout():future 以异常完成,之后不再重连。
  • updateSelf(null, 30L * 24 * 3600 * 1000):帧里 default_delay_ms 为 2592000000。

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

以下是本次复审各区审查报告的原文段落。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-02] Java 把“连不上服务器”判为 bad_credentials 并永久停止重连(Java)

  • 严重级:critical
  • 分类:逻辑 / 与PRD不符
  • 现象与影响:拿不到 CONNACK 原因码时,SDK 按异常文本判断,文本含 connack 或 connectionfailed 就判为认证失败并停止重连。HiveMQ 对连接被拒、连接超时、DNS 失败都抛 ConnectionFailedException,于是服务器重启、断网、切网时,Java/Android 端会永久掉线,并对用户报“密码错误”。
  • 证据:
    • Transport.java:343-351、Client.java:708-724。
    • 复现:在临时目录编译 SDK 后连接 ws://127.0.0.1:1/mqtt,状态依次为 RECONNECTING network → AUTH_FAILED bad_credentials,之后 6 秒内不再重试,connectSync 抛“认证失败”。
  • 文档依据:
    • PRD F02:“服务器暂时不可用……SDK 继续重连”;验收要求“不会进入「密码错误」状态”。
    • DEVELOPMENT 9 第 6 条:“网络错误……连接被直接关闭,都继续重连”。
  • 为何不是故意设计:DEVIATIONS S2 4–5 第 4 条只写了 BAD_USER_* 归为认证失败,没有把 connack/connectionfailed 列进去。
  • 解决方案:只以 extractConnAckReason 取到的 BAD_USER_NAME_OR_PASSWORD / NOT_AUTHORIZED / BANNED 判定停止;删除按文本判断的兜底,其余情况一律视为网络问题并继续重连。
  • 改动文件:sdk/java/.../Transport.java
  • 与其他模块的交互/冲突风险:无。
  • 需补测试:连接未监听端口时应多次重试、不出现 AUTH_FAILED;ChecklistTest 增加“服务端停 3 秒后重启能自动恢复”。
  • 置信度:已用测试复现。

[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-06] 确认失败后没有恢复:重推被忽略、重复回执不再确认、手动 ack 吞掉失败(Python / Java / JS)

  • 严重级:high
  • 分类:协议一致性 / 逻辑
  • 现象与影响:
    1. 自动确认:回调成功后 ack 失败,Python/Java 吞掉异常,去重状态停在“已交未确认”;JS 跳过了置为 acked 这一步。之后服务器重推同一条,SDK 走“已交未确认就忽略”的分支,永不再确认。结果是:保留消息最终过期,不保留消息被丢弃,发送方拿到 expired/dropped,但应用其实已经处理过;确认超时之前这条还占着 32 个推送窗口中的一个。Go 在回调成功后直接置为 acked,重推时会再 ack,这是正确做法。
    2. 重复回执:Python/Java 遇到重复的 receipt_id 直接 return,不发 receipt_ack。服务器每次 PushPending(每个接收方 ack 都会唤醒发送方)都会重推 receipt_id 最小的 64 条未确认回执。一旦首次确认失败(例如断线后还在消化积压,_request 直接返回 NotConnected),这条回执就永远占着窗口;积满 64 条后,该发送方再也收不到新回执。
    3. 手动确认:Python 的 ack() 和 Java 的 ack() 失败时不报错(Java 的 future 正常完成),应用无从得知。
  • 证据:
    • Python:client.py:756-758,798-803,836-851。
    • Java:Client.java:884-887,928-934,965-984。
    • JS:client.ts:352-360。
    • 对照 Go:receive.go:136-141,202-206。
    • 服务端:push.go:88-237,456-495,549-560。
  • 文档依据:DEVELOPMENT 9“已确认过的再次到达:直接再发一次 ack”;6.4“回执……可能重复,SDK 按 receipt_id 去重”。
  • 为何不是故意设计:文档里的“忽略”针对的是回调还在执行、或手动模式等应用确认的情形。Go 的实现与此一致,另外三套偏离了。
  • 解决方案:
    1. 自动模式下回调成功就置为 acked,再发 ack。
    2. 重复回执同样发 receipt_ack(可按 id 节流)。
    3. 手动 ack 失败时 Python 抛异常、Java 的 future 以异常完成。
  • 改动文件:Python client.py、Java Client.java、JS client.ts
  • 与其他模块的交互/冲突风险:与 S-1、S-13 配合,让断线时的 ack 快速失败,而不是等 60 秒。服务端“每次唤醒重推 64 条回执”的放大问题见待核实第 3 条。
  • 需补测试:ack 失败一次后再注入同一条 msg,断言发出 ack 且不重复回调;重复回执每次都 ack;手动 ack 失败时报错。
  • 置信度:代码阅读确定。

[S-10] Java 定时发送不做时钟偏差校正(Java;Python 的 send_at_ms 也原样透传)

  • 严重级:medium
  • 分类:与PRD不符
  • 现象与影响:Java 只有 sendAtMs,写入帧时不加 clockSkewMs。设备时钟偏差多少,定时消息就早到或晚到多少。Python 的 send_at 会校正,但 send_at_ms 原样透传,而且两者的语义没有说明。
  • 证据:Java Client.java:288-289、Types.java:90-100;Python client.py:292-299;对照 Go send.go:83-87、JS client.ts:472-473。
  • 文档依据:PRD F11、F19“本机时间与服务器偏差由 SDK 校正”;DEVELOPMENT 9。
  • 为何不是故意设计:没有偏差记录,Go/JS 和 Python 的 send_at 都做了校正。
  • 解决方案:Java 增加本机时间语义的 sendAt(epoch 毫秒或 java.util.Date,Android API 24 没有 java.time),入队时加上偏差;在 Javadoc 明确 sendAtMs 表示服务器时间,两者同时设置时报 bad_request。Python 在文档里注明 send_at_ms 是服务器时间。
  • 改动文件:Java Types.java、Client.java;Python 文档
  • 与其他模块的交互/冲突风险:Java 公开 API 增项,需在发布前完成。
  • 需补测试:hello 返回的服务器时间比本机快 60 秒时,断言帧里 send_at_ms 约等于本机时间加 62 秒。
  • 置信度:代码阅读确定。

[S-11] JS 和 Java 的心跳是 60 秒,文档规定 30 秒(JS / Java)

  • 严重级:medium
  • 分类:协议一致性
  • 现象与影响:两套都没设 keepalive,用的是库默认值 60 秒。离线判定因此变成约 90 秒(PRD 要求约 45 秒),影响在线状态和宽限期。
  • 证据:mqtt.ts:57-69 加 MQTT.js client.js:63;Transport.java:313-321 加 Mqtt5Connect.DEFAULT_KEEP_ALIVE=60。
  • 文档依据:DEVELOPMENT 5“SDK 默认 30”;PRD F03。
  • 为何不是故意设计:没有偏差记录,Go/Python 都是 30。
  • 解决方案:JS 设 keepalive: 30,Java 设 .keepAlive(30)。
  • 改动文件:mqtt.ts、Transport.java
  • 与其他模块的交互/冲突风险:无。
  • 需补测试:断言连接参数中的 keepalive 为 30。
  • 置信度:代码阅读确定(库默认值已核对源码和字节码)。

[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-18] Python / Java 的 URL scheme 处理:https 走明文、mqtt:// 未开启也走裸 TCP(Python / Java)

  • 严重级:low
  • 分类:安全
  • 现象与影响:只有 wss 会开 TLS。传 https:// 会以明文连接(没写端口时连 80);服务器仅接受 TLS 时连接会失败(默认安全),但若服务器同时允许明文,凭据就会明文传输。mqtt:// 不需要开启选项就走裸 TCP。
  • 证据:transport.py:204-245、Transport.java:239-301;对照 Go transport_mqtt.go:206-237、JS mqtt.ts:151-164。
  • 文档依据:DEVELOPMENT 9“裸 TCP 只在选项里显式打开时使用”;6.9 地址映射。
  • 为何不是故意设计:Go/JS 已把 http/https 映射为 ws/wss 并拒绝未开启的裸 TCP。
  • 解决方案:统一 scheme 映射表:http→ws、https→wss,mqtt/mqtts 只在开启选项时允许。
  • 改动文件:Python transport.py、protocol.py;Java Transport.java、Protocol.java
  • 与其他模块的交互/冲突风险:无。
  • 需补测试:URL 解析单测。
  • 置信度:代码阅读确定。

[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 超限”的用例。
  • 置信度:代码阅读确定。

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

**编号**:K-04 **严重级**:critical **工作线**:SDK(sdk/*) **来源**:审查 S-01、S-02、S-05、S-06、S-10、S-11、S-12、S-13、S-14、S-15、S-18、S-19 **依赖**:K-00 (#57)(对外语义条目) **被依赖**:无 ### 结论与统一方案 本 issue 汇总涉及 Java SDK 的条目,由 Java SDK 负责人一次改完(集中在 `Client.java`、`Transport.java`、`Types.java`)。下方"问题明细"是跨四套的原文,请只看 Java 的部分。 | 条目 | 严重级 | Java SDK 的改法 | |---|---|---| | S-02 连接被拒、超时、DNS 失败被按异常文本判为 `bad_credentials` 并永久停止重连(已复现) | critical | 只以 `extractConnAckReason` 取到的 BAD_USER_NAME_OR_PASSWORD / NOT_AUTHORIZED / BANNED 判定停止;删除按文本判断的兜底,其余一律视为网络问题并继续重连 | | S-01 断线后发送不重交(PUBACK 前断线直接判失败出队) | critical | publish 失败在未停止重连时回退为未在途,不判失败;断线时在途条目按原 id 重交 | | S-05 `setState` 在锁内调回调、`request()` 在 `synchronized(lock)` 里等 PUBACK 最多 10 秒(已复现 onConnection 死锁) | high | 锁内只记录状态,锁外按序派发回调;`request()` 锁内只登记 pending,锁外 publish,不在调用线程等 PUBACK | | S-06 自动确认失败被吞、重复回执不 ack、手动 ack 失败 future 正常完成 | high | 回调成功即置为 acked 再 ack;重复回执也 ack;手动 ack 失败时 future 以异常完成 | | S-10 定时发送不做时钟偏差校正 | medium | 增加本机时间语义的 `sendAt`(epoch 毫秒或 `java.util.Date`,Android API 24 没有 java.time),入队时加偏差;Javadoc 写明 `sendAtMs` 是服务器时间;两者同时设置报 `bad_request` | | S-11 心跳用库默认 60 秒 | medium | `.keepAlive(30)` | | S-12 fatal 与断线顺序导致先报 `session_invalid` 再报真实原因 | medium | 收包路径识别 fatal 后立即置停止标志、失败发送与挂起请求、只报一次原因 | | S-13 断线时挂起请求要等满 60 秒 | medium | 断线钩子以 `not_connected` 立即失败非发送请求 | | S-14 rate_limited 后立即重交形成热循环 | medium | 按 K-00 的退避参数 | | S-15 断线后立即重连、闪断不抬升退避 | medium | 按 K-00 (#57) 修订后的"重连退避"算法实现(不要照搬 JS 现有代码,它有双重翻倍,见 K-02 (#59)) | | S-16 / S-24 对外语义 | medium / low | 按 K-00 统一 | | S-18 https 走明文、mqtt:// 未开启也走裸 TCP | low | 按 K-00 的 URL 映射 | | S-19 离线入队的帧不检查整帧大小 | low | 入队和 drain 前都检查 | | S-23 工程问题(Java 部分) | low | 假传输不随 jar 发布;pom 把 LICENSE 放进 `META-INF` | | 补充:`Types.java:4,63-71` 用 `java.util.Base64`,Android 从 API 26 才提供,而 D30 规定最低 API 24;在 API 24/25 上 `Body.ofBytes` 和 base64 正文的本地长度检查会抛 `NoClassDefFoundError` | medium | SDK 内置小型 Base64 编解码(标准字母表、带填充),解码长度按字符串长度和填充计算;不以接入方开启 core library desugaring 为前提;可选加 animal-sniffer 的 Android API 24 签名检查 | | 补充:`logout()` 吞掉请求失败(`Client.java:227-232`),离线时服务端没有作废令牌,应用却以为已经退出 | medium | 按 K-00 (#57) 新增的"logout"一行:future 以请求错误异常完成,本地照常停止重连、清空令牌、结束队列 | | 补充:`updateSelf(String, Integer defaultDelayMs)`(`Client.java:391-403`)最多约 24.8 天,达不到 `max_schedule_seconds` 默认的 365 天 | low | 按 K-00 新增的"时长参数"一行改为 `Long`(公开 API 变更,README 同步) | 标"补充"的条目来自第二轮 SDK 审查,总审查人已对照代码核实,证据写在条目里(没有对应的原文段落)。 ### 改动文件 `sdk/java/src/main/java/asia/asio/nixmsg/Client.java`、`Transport.java`、`Types.java`、`Protocol.java`,`pom.xml`,`sdk/java/src/test/*`,README。 ### 与其他问题的交互 / 冲突说明 - 只改 sdk/java;对外语义条目以 K-00 为准;公开 API 增项需在发布前完成。 - 测试结束后删除 `target` 等产物。 ### 验收与测试 - 连接未监听的端口:多次重试、不出现 AUTH_FAILED;ChecklistTest 增加"服务端停 3 秒后重启能自动恢复"。 - onConnection(ONLINE) 里调 `sendSync` 不卡住;用 `-Drx2.computation-threads=1` 并发 ack 压测不出现 busy。 - 模拟 publish 抛异常:重交而非失败。 - hello 返回的服务器时间比本机快 60 秒时,帧里 `send_at_ms` 约等于本机时间加 62 秒。 - 连接参数中 keepalive 为 30。 - 退避单测(比较去掉抖动的标称值):连续失败 6 次,间隔为 1、2、4、8、16、30 秒;断线后第一次重连约 1 秒。 - Base64 单测:编码结果和解码长度(0、1、2 个填充)与标准实现一致。 - 离线时调用 `logout()`:future 以异常完成,之后不再重连。 - `updateSelf(null, 30L * 24 * 3600 * 1000)`:帧里 `default_delay_ms` 为 2592000000。 --- ### 问题明细(各区审查原文,证据含文件与行号) > 以下是本次复审各区审查报告的原文段落。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-02] Java 把“连不上服务器”判为 bad_credentials 并永久停止重连(Java) - **严重级**:critical - **分类**:逻辑 / 与PRD不符 - **现象与影响**:拿不到 CONNACK 原因码时,SDK 按异常文本判断,文本含 `connack` 或 `connectionfailed` 就判为认证失败并停止重连。HiveMQ 对连接被拒、连接超时、DNS 失败都抛 `ConnectionFailedException`,于是服务器重启、断网、切网时,Java/Android 端会永久掉线,并对用户报“密码错误”。 - **证据**: - `Transport.java:343-351`、`Client.java:708-724`。 - 复现:在临时目录编译 SDK 后连接 `ws://127.0.0.1:1/mqtt`,状态依次为 `RECONNECTING network` → `AUTH_FAILED bad_credentials`,之后 6 秒内不再重试,`connectSync` 抛“认证失败”。 - **文档依据**: - PRD F02:“服务器暂时不可用……SDK 继续重连”;验收要求“不会进入「密码错误」状态”。 - DEVELOPMENT 9 第 6 条:“网络错误……连接被直接关闭,都继续重连”。 - **为何不是故意设计**:DEVIATIONS S2 4–5 第 4 条只写了 BAD_USER_* 归为认证失败,没有把 connack/connectionfailed 列进去。 - **解决方案**:只以 `extractConnAckReason` 取到的 BAD_USER_NAME_OR_PASSWORD / NOT_AUTHORIZED / BANNED 判定停止;删除按文本判断的兜底,其余情况一律视为网络问题并继续重连。 - **改动文件**:`sdk/java/.../Transport.java` - **与其他模块的交互/冲突风险**:无。 - **需补测试**:连接未监听端口时应多次重试、不出现 AUTH_FAILED;ChecklistTest 增加“服务端停 3 秒后重启能自动恢复”。 - **置信度**:已用测试复现。 #### [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-06] 确认失败后没有恢复:重推被忽略、重复回执不再确认、手动 ack 吞掉失败(Python / Java / JS) - **严重级**:high - **分类**:协议一致性 / 逻辑 - **现象与影响**: 1. **自动确认**:回调成功后 ack 失败,Python/Java 吞掉异常,去重状态停在“已交未确认”;JS 跳过了置为 acked 这一步。之后服务器重推同一条,SDK 走“已交未确认就忽略”的分支,永不再确认。结果是:保留消息最终过期,不保留消息被丢弃,发送方拿到 expired/dropped,但应用其实已经处理过;确认超时之前这条还占着 32 个推送窗口中的一个。Go 在回调成功后直接置为 acked,重推时会再 ack,这是正确做法。 2. **重复回执**:Python/Java 遇到重复的 receipt_id 直接 return,不发 receipt_ack。服务器每次 PushPending(每个接收方 ack 都会唤醒发送方)都会重推 receipt_id 最小的 64 条未确认回执。一旦首次确认失败(例如断线后还在消化积压,`_request` 直接返回 NotConnected),这条回执就永远占着窗口;积满 64 条后,该发送方再也收不到新回执。 3. **手动确认**:Python 的 `ack()` 和 Java 的 `ack()` 失败时不报错(Java 的 future 正常完成),应用无从得知。 - **证据**: - Python:`client.py:756-758,798-803,836-851`。 - Java:`Client.java:884-887,928-934,965-984`。 - JS:`client.ts:352-360`。 - 对照 Go:`receive.go:136-141,202-206`。 - 服务端:`push.go:88-237,456-495,549-560`。 - **文档依据**:DEVELOPMENT 9“已确认过的再次到达:直接再发一次 ack”;6.4“回执……可能重复,SDK 按 receipt_id 去重”。 - **为何不是故意设计**:文档里的“忽略”针对的是回调还在执行、或手动模式等应用确认的情形。Go 的实现与此一致,另外三套偏离了。 - **解决方案**: 1. 自动模式下回调成功就置为 acked,再发 ack。 2. 重复回执同样发 receipt_ack(可按 id 节流)。 3. 手动 ack 失败时 Python 抛异常、Java 的 future 以异常完成。 - **改动文件**:Python `client.py`、Java `Client.java`、JS `client.ts` - **与其他模块的交互/冲突风险**:与 S-1、S-13 配合,让断线时的 ack 快速失败,而不是等 60 秒。服务端“每次唤醒重推 64 条回执”的放大问题见待核实第 3 条。 - **需补测试**:ack 失败一次后再注入同一条 msg,断言发出 ack 且不重复回调;重复回执每次都 ack;手动 ack 失败时报错。 - **置信度**:代码阅读确定。 #### [S-10] Java 定时发送不做时钟偏差校正(Java;Python 的 `send_at_ms` 也原样透传) - **严重级**:medium - **分类**:与PRD不符 - **现象与影响**:Java 只有 `sendAtMs`,写入帧时不加 `clockSkewMs`。设备时钟偏差多少,定时消息就早到或晚到多少。Python 的 `send_at` 会校正,但 `send_at_ms` 原样透传,而且两者的语义没有说明。 - **证据**:Java `Client.java:288-289`、`Types.java:90-100`;Python `client.py:292-299`;对照 Go `send.go:83-87`、JS `client.ts:472-473`。 - **文档依据**:PRD F11、F19“本机时间与服务器偏差由 SDK 校正”;DEVELOPMENT 9。 - **为何不是故意设计**:没有偏差记录,Go/JS 和 Python 的 `send_at` 都做了校正。 - **解决方案**:Java 增加本机时间语义的 `sendAt`(epoch 毫秒或 `java.util.Date`,Android API 24 没有 java.time),入队时加上偏差;在 Javadoc 明确 `sendAtMs` 表示服务器时间,两者同时设置时报 bad_request。Python 在文档里注明 `send_at_ms` 是服务器时间。 - **改动文件**:Java `Types.java`、`Client.java`;Python 文档 - **与其他模块的交互/冲突风险**:Java 公开 API 增项,需在发布前完成。 - **需补测试**:hello 返回的服务器时间比本机快 60 秒时,断言帧里 `send_at_ms` 约等于本机时间加 62 秒。 - **置信度**:代码阅读确定。 #### [S-11] JS 和 Java 的心跳是 60 秒,文档规定 30 秒(JS / Java) - **严重级**:medium - **分类**:协议一致性 - **现象与影响**:两套都没设 keepalive,用的是库默认值 60 秒。离线判定因此变成约 90 秒(PRD 要求约 45 秒),影响在线状态和宽限期。 - **证据**:`mqtt.ts:57-69` 加 MQTT.js `client.js:63`;`Transport.java:313-321` 加 `Mqtt5Connect.DEFAULT_KEEP_ALIVE=60`。 - **文档依据**:DEVELOPMENT 5“SDK 默认 30”;PRD F03。 - **为何不是故意设计**:没有偏差记录,Go/Python 都是 30。 - **解决方案**:JS 设 `keepalive: 30`,Java 设 `.keepAlive(30)`。 - **改动文件**:`mqtt.ts`、`Transport.java` - **与其他模块的交互/冲突风险**:无。 - **需补测试**:断言连接参数中的 keepalive 为 30。 - **置信度**:代码阅读确定(库默认值已核对源码和字节码)。 #### [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-18] Python / Java 的 URL scheme 处理:https 走明文、mqtt:// 未开启也走裸 TCP(Python / Java) - **严重级**:low - **分类**:安全 - **现象与影响**:只有 wss 会开 TLS。传 `https://` 会以明文连接(没写端口时连 80);服务器仅接受 TLS 时连接会失败(默认安全),但若服务器同时允许明文,凭据就会明文传输。`mqtt://` 不需要开启选项就走裸 TCP。 - **证据**:`transport.py:204-245`、`Transport.java:239-301`;对照 Go `transport_mqtt.go:206-237`、JS `mqtt.ts:151-164`。 - **文档依据**:DEVELOPMENT 9“裸 TCP 只在选项里显式打开时使用”;6.9 地址映射。 - **为何不是故意设计**:Go/JS 已把 http/https 映射为 ws/wss 并拒绝未开启的裸 TCP。 - **解决方案**:统一 scheme 映射表:http→ws、https→wss,mqtt/mqtts 只在开启选项时允许。 - **改动文件**:Python `transport.py`、`protocol.py`;Java `Transport.java`、`Protocol.java` - **与其他模块的交互/冲突风险**:无。 - **需补测试**:URL 解析单测。 - **置信度**:代码阅读确定。 #### [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 超限”的用例。 - **置信度**:代码阅读确定。 --- <sub>复审基线:main `4059a15`(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。</sub>
nixevol added the P0-criticallane/sdkreview-2026-09-30 labels 2026-09-30 13:57:07 +08:00
Author
Owner

按第二轮 SDK 审查补充 3 条(java.util.Base64 需要 Android API 26、logout() 吞掉请求失败、updateSelf 的默认延迟用 Integer);S-15 改为按 K-00 (#57) 修订后的退避算法实现。补充条目已由总审查人对照代码核实,详见正文。

按第二轮 SDK 审查补充 3 条(`java.util.Base64` 需要 Android API 26、`logout()` 吞掉请求失败、`updateSelf` 的默认延迟用 `Integer`);S-15 改为按 K-00 (#57) 修订后的退避算法实现。补充条目已由总审查人对照代码核实,详见正文。
Author
Owner

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

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