跳转至

SDK client_message_id 重启碰撞诊断

日期:2026-08-23

缺陷摘要

SDK 曾以 device_id + startup_wall_ms + 进程内序号 初始化 client_message_id。 同设备在同一毫秒重启、或墙钟回拨时,新的进程会从同一个数重新发号;其 device_id << 64 还会截掉完整 u128 设备 ID 的高 64 位。该 ID 是服务端幂等键; 服务端会把新消息误当成旧请求重放 ACK,而不再 fanout,形成“发送成功但收件人 永久收不到”的静默丢失。

已验证假设

  1. 假设:发号器的唯一状态仅存在进程内。
  2. 证据:旧 client.rs::build 用墙钟构造 next_cmid 的 seed, Handle::next_client_message_id 只对 Mutex<u128> 加一;没有访问 LocalStore。
  3. 结果:成立。
  4. 假设:现有跨重启用例没有覆盖同毫秒或回拨。
  5. 证据:当前用例在第二次 build 前主动把 TestClock 前进 1,000 ms。
  6. 结果:成立;该用例证明了不同毫秒,不证明持久唯一性。
  7. 假设:本地 SQLite 能提供跨进程的原子高水位。
  8. 证据:SDK 已将本地游标和去重置于 SQLite 事务;LocalStore 只有 SQLite 与 MemoryStore 两个实现,均在 SDK crate 内。
  9. 结果:成立,且不需要修改 crate 外 API。
  10. 假设:将设备 ID 左移 64 位可保留设备命名空间。
  11. 证据:对 u128 左移 64 位只保留原值的低 64 位;高 64 位不同而低 64 位 相同的两个设备会得到相同候选值。
  12. 结果:不成立。服务端去重 scope 不含 device,因此此碰撞同样会吞掉新消息。

根因

近因是将可回拨、低分辨率的墙钟当作跨进程唯一性来源,并截断了设备身份。根因是 幂等键的分配没有持久化的、每设备原子保留的单调高水位;进程内 mutex 只能序列化 一个 Handle,不能覆盖重启或第二个进程。

最小修复

  1. 在 LocalStore 增加每设备 CMID ordinal 区间的原子保留语义;返回的区间 起点严格高于已持久高水位,并对边界/u128 溢出返回明确错误。
  2. SQLite schema v2 新增独立的每设备高水位表,并用 BEGIN IMMEDIATE 事务完成 读高水位、选择新区间与写回;MemoryStore 实现同一语义供确定性测试。
  3. build 取得持久化租约后才初始化同步发号器;对每个 ordinal 以 blake3("qim.client-message-id.v1\\0" || device_id_be16 || ordinal_be16)[0..16] 导出不透明 CMID。完整 u128 device_id 参与输入;同一输入跨 native/wasm 稳定。 正常调用者仍使用既有同步 Handle::next_client_message_id,不改变绑定层 API。
  4. 0 是协议保留的无效 ID,作为无法预留或已耗尽时的无歧义同步哨兵;send 在入队前拒绝该值及不可用状态,返回 SdkError::Store,不得创建 pending 或写帧。 若哈希恰为全零,跳过该 ordinal。
  5. 每次 next_client_message_id 成功时,把 ID 放入有界的“已签发未消费”集合; send(SendMessage) 在入队前、同一 mutex 内校验并消费。未签发、重复普通发送 与 0 全部失败关闭;Resend 保持独立路径。号段耗尽不会否定已签发 ID。 达到 outstanding 上限时只暂时返回 0,消费后恢复分配。
  6. 新回归覆盖同毫秒重启、回拨到先前时刻、同库并发预留、ordinal 溢出、及高 64 位不同而低 64 位相同设备在同 ordinal 的映射;旧实现在同毫秒/回拨用例中重复。

影响文件

  • crates/qim-sdk/src/port.rs(实际的 LocalStore 定义;仓库中没有 store.rs)
  • crates/qim-sdk/src/sqlite.rs
  • crates/qim-sdk/src/testing.rs
  • crates/qim-sdk/src/client.rs

兼容性与风险

  • Schema 仅前向从 v1 升级到 v2;旧库会创建新表,较新库被旧 SDK 打开仍沿用现有 SchemaIncompatible 安全重置路径。
  • 重置消息与游标时不删除 CMID 高水位:它是发送幂等身份而非可重建缓存;删除会 重新引入重启碰撞。
  • SQLite 读取 local_client_message_id.high_water 时必须严格为 16 字节;损坏值 fail-closed,而不能补零回退并复用 ordinal。v1→v2 前向迁移保留既有 cursor、 pending 与消息,并新建该表。
  • 预留但未发送的 ordinal 会被安全跳过。它只消耗本设备的 ID 空间,不会导致服务端 消息重复或丢失。BLAKE3 是已有 workspace 依赖,优点是跨平台稳定、完整身份域 分离与 128-bit 输出;代价是每次同步发号的一次小哈希,仍有密码学摘要固有的理论 碰撞概率,但不依赖随机数或时钟碰撞概率。
  • outstanding 集合固定上限为命令队列深度(256);上限只提供暂时背压,消费后恢复。 号段固定为 2^40 且当前不续租:耗尽时安全失败但会影响长期可用性,后续应在 保持同步 API 语义的前提下设计异步续租/双租约切换。

测试证据

  • 修复前,同毫秒第二次 build 的首个 CMID 与第一次相同;回拨到先前同一墙钟时 也会重新得到旧值。旧测试靠推进 1 秒掩盖了这一条件。
  • 修复前,高 64 位不同、低 64 位相同的设备对同一 ordinal 计算的候选值相同。
  • 修复前,任意非零但未签发 ID 会在 lease 尚可用时通过 send;而取完最后一个 ID 后再次调用 next 会让该合法 ID 也被拒绝。新回归在旧实现中分别于 “未签发 ID 必须拒绝”和“号段耗尽后已签发 ID 仍可发送”断言失败。
  • 修复后,回归覆盖同毫秒重启、时钟回拨、同 SQLite 库并发 build、持久 ordinal 边界、零哨兵拒绝发送、未签发/重复普通发送拒绝、outstanding 消费后恢复、完整设备 身份映射、损坏高水位 fail-closed 与真 v1 磁盘迁移;cargo test -p qim-sdk、Clippy 与 wasm32-unknown-unknown --no-default-features 均通过。