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,形成“发送成功但收件人
永久收不到”的静默丢失。
已验证假设¶
- 假设:发号器的唯一状态仅存在进程内。
- 证据:旧
client.rs::build用墙钟构造next_cmid的 seed,Handle::next_client_message_id只对Mutex<u128>加一;没有访问LocalStore。 - 结果:成立。
- 假设:现有跨重启用例没有覆盖同毫秒或回拨。
- 证据:当前用例在第二次
build前主动把TestClock前进 1,000 ms。 - 结果:成立;该用例证明了不同毫秒,不证明持久唯一性。
- 假设:本地 SQLite 能提供跨进程的原子高水位。
- 证据:SDK 已将本地游标和去重置于 SQLite 事务;
LocalStore只有 SQLite 与 MemoryStore 两个实现,均在 SDK crate 内。 - 结果:成立,且不需要修改 crate 外 API。
- 假设:将设备 ID 左移 64 位可保留设备命名空间。
- 证据:对 u128 左移 64 位只保留原值的低 64 位;高 64 位不同而低 64 位 相同的两个设备会得到相同候选值。
- 结果:不成立。服务端去重 scope 不含 device,因此此碰撞同样会吞掉新消息。
根因¶
近因是将可回拨、低分辨率的墙钟当作跨进程唯一性来源,并截断了设备身份。根因是
幂等键的分配没有持久化的、每设备原子保留的单调高水位;进程内 mutex 只能序列化
一个 Handle,不能覆盖重启或第二个进程。
最小修复¶
- 在
LocalStore增加每设备 CMID ordinal 区间的原子保留语义;返回的区间 起点严格高于已持久高水位,并对边界/u128溢出返回明确错误。 - SQLite schema v2 新增独立的每设备高水位表,并用
BEGIN IMMEDIATE事务完成 读高水位、选择新区间与写回;MemoryStore 实现同一语义供确定性测试。 build取得持久化租约后才初始化同步发号器;对每个 ordinal 以blake3("qim.client-message-id.v1\\0" || device_id_be16 || ordinal_be16)[0..16]导出不透明 CMID。完整 u128device_id参与输入;同一输入跨 native/wasm 稳定。 正常调用者仍使用既有同步Handle::next_client_message_id,不改变绑定层 API。0是协议保留的无效 ID,作为无法预留或已耗尽时的无歧义同步哨兵;send在入队前拒绝该值及不可用状态,返回SdkError::Store,不得创建 pending 或写帧。 若哈希恰为全零,跳过该 ordinal。- 每次
next_client_message_id成功时,把 ID 放入有界的“已签发未消费”集合;send(SendMessage)在入队前、同一 mutex 内校验并消费。未签发、重复普通发送 与0全部失败关闭;Resend保持独立路径。号段耗尽不会否定已签发 ID。 达到 outstanding 上限时只暂时返回0,消费后恢复分配。 - 新回归覆盖同毫秒重启、回拨到先前时刻、同库并发预留、ordinal 溢出、及高 64 位不同而低 64 位相同设备在同 ordinal 的映射;旧实现在同毫秒/回拨用例中重复。
影响文件¶
crates/qim-sdk/src/port.rs(实际的LocalStore定义;仓库中没有store.rs)crates/qim-sdk/src/sqlite.rscrates/qim-sdk/src/testing.rscrates/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均通过。