Skip to content

[网络] WS 帧/重连契约审计(seq/重放/去重全量核对) #2101

Description

@DeliciousBuding

基线

  • master commit: 4b3f236b66cd417b600beb032e0c278891849ce9 (2026-08-29)
  • 审计范围:Hub /client/ws + Edge /v1/events 两条 WS 链路;Hub↔Edge 无 WS(HTTP outbox/callback)
  • 方法:只读代码审计 + api/events.md 契约对照;不跑测试、不改代码

契约矩阵

# 链路 方向 帧/事件 type 代表 seq 分配方 seq 持久化 重连握手参数 重放机制与窗口 去重责任方 投递语义 file:line
A1 Hub /client/ws S→C auth.ok / message.new / agent.stream / device.kicked …(31 常量) Hub PushToConnc.seq.Add(1)(sendMu 临界区) 纯内存 per-connection atomic.Int64;不落库、不跨连接 last_seq/resume/cursor;仅重做 Sec-WebSocket-Protocol bearer auth 服务端无重放;PushToConn 不缓存历史帧。客户端对 agent.stream 单独走 REST gap-fill(GET /web/agent-tasks/:id/events?after_seq= 业务键 UPSERT / idempotent-on-apply / watermark(见 api/events.md 表);seq_id 不参与去重 at-least-once(fanout + outbox redispatch) frame.go:5-17, fanout.go:65-91, conn.go:41-45, handler/ws.go:103-108, api/events.md:27-29
A2 Hub /client/ws C→S typing(唯一合法 C→S) n/a n/a n/a n/a processIncoming switch 只接受 TypeTyping,其余丢弃;typing 自身 ephemeral 无需去重 ephemeral handler/ws.go:142-179, frame.go:21
B1 Edge /v1/events S→C run.agent.* / system.gap / error / artifact.* …(EventEnvelope) Bus.Publish atomic.AddInt64(&b.seq, 1) 内存 + 磁盘 eventLog;重启从 eventLog.orderedSeq 末尾续接;history ring 10000 + eventLog 50 MiB(保留 75%) query ?cursor=<lastSeq>(或 pageCursor) Subscribe(cursor) merge history + eventLog;cursor predates log → 注入 system.gap;handler 收到 gap 发 WS close code 4001 强制客户端 resync 客户端水位 drop seq<=lastSeq(system.gap 不污染 cursor)+ 业务键 UPSERT at-least-once(replay + live fanout + gap→resync) bus.go:149-194, bus.go:138-144, bus.go:333-388, eventlog.go:48, handlers_events.go:24-90, handlers_events.go:152-161, handlers.go:110-113, types.go:4
B2 Edge /v1/events C→S ping(clientControl chan) n/a n/a n/a n/a websocketClientControlResponse 只识别 "type":"ping",回 {type:"pong", ts};其余忽略 ephemeral(heartbeat) handlers_events.go:180-195
C Hub↔Edge 无 WS;HTTP outbox + callback + relay-via-PushToUser n/a(delivery_id 是 outbox journal key,非 wire seq) outbox PG journal(pending/sent/retrying/delivered/dead);callback fire-and-forget 3 attempts / 30s timeout / 10s budget n/a outbox ScanRetryableDeliveries 每 15s;PendingTimeout 30s / SentTimeout 60s / DefaultMaxAttempts 3 / backoff 2s base cap 30s ±25% jitter;relay CreateCommand PushToUser 到 client WS(不是独立 edge WS) delivery_id + task_id 幂等;MarkDeliverySent CAS;AckDelivery 幂等 at-least-once(journal + redispatch + dead-letter) deliveryoutbox/outbox.go:50-140, retry.go:11-49, dispatchsvc/agent_dispatch.go:395-445, relay/relay.go:39-67, hub/callback.go:38-70

缺口清单(按严重度排序)

G1 [High] Hub /client/ws 客户端未实现 seq_id gap 检测 → IM/session/device 事件静默丢失

  • 现象fanout.go:64-65 注释明示 "dropped frames consume a seq too: the resulting gap is the client-side loss signal",但 app/shared/src/hub/hubWS.ts 全文不解析 seq_id、不维护期望 seq、不检测 gap。buffer-full drop(fanout.go:105-119)和 marshal error 产生的 seq hole 在客户端完全不可见。
  • 影响面:message.new/recall/pin、session.member_joined/left、device.online/offline、notification.new、friend.* 等所有非 agent.stream 事件在拥塞/抖动下可永久丢失;用户看到过期会话列表、未读计数漂移、pin/reaction 状态不一致。仅 agent.stream 有 REST gap-fill 兜底(runEventReplay.ts),其他族无任何补救。
  • 与文档出入:api/events.md:27 声明 "同连 gap = 丢帧" 作为设计意图,但客户端从未消费该信号——契约声明与实现脱节。
  • 修复切片:hubWS.ts 增加 per-conn expectedSeq 水位 + gap 检测;检测到 gap 时按事件族触发对应 REST sync(messages/sync、sessions/members、notifications/unread-count 等),或至少向上层 emit hub:gap 让 UI 显示同步指示器。

G2 [High] Hub outbox redispatch 与 WS PushToConn 双通道可对同一 task 产生并发重复投递,Edge 端缺少 delivery_id 级去重契约文档

  • 现象:dispatchsvc/agent_dispatch.go:395-445 redispatchDelivery 走 HTTP/WS/offline 三路重试;同时初始 dispatch 也经 PushToUser/PushToConn 推 WS。outbox MarkDeliverySent 仅在成功 dispatch 后标记(outbox.go:89-119),但 WS PushToConn 本身无 delivery_id 回执;Edge 收到同一 task 的多次 agent.dispatch 时,仅靠 task_id UPSERT 去重(api/events.md:94)。
  • 影响面:若 Edge 在首次 dispatch 后崩溃、outbox 未 ack、redispatch 再推,Edge 重启后会再次执行同一 task(除非 Edge 本地有 pending_agent_tasks 幂等检查)。当前契约文档只声明 "task_id UPSERT",未明确 Edge 是否必须持久化已接收 delivery_id 以拒收 redispatch。
  • fix(dispatch): 孤儿任务恢复重投与 Edge hubTaskId 去重 #2074 关联fix(dispatch): 孤儿任务恢复重投与 Edge hubTaskId 去重 #2074(孤儿恢复+Edge 去重)正在补 Edge 侧去重,但本审计发现 Hub 侧契约也未闭合——即使 Edge 完美去重,Hub 仍可能在 outbox sent-timeout 60s 窗口内反复推同一帧,浪费带宽并放大乱序风险。
  • 修复切片:① api/events.md agent.dispatch 行补充 "Edge MUST dedupe by delivery_id when present; task_id alone is insufficient for redispatch windows";② Hub PushToConn 增加可选 delivery_id 字段透传;③ Edge adapter 层记录已见 delivery_id(LRU + TTL ≥ SentTimeout)。

G3 [Medium] Edge eventLog 50 MiB 截断 + history 10000 条在高频 stream 下窗口过窄,gap 触发全量 resync 代价高

  • 现象:eventlog.go:48 defaultEventLogMaxSize=50MiB,truncateLocked 保留 75%(~37.5MiB);types.go:4 defaultMaxHistory=10000。agent.stream 在长 coding session 中可达数千事件/分钟;50MiB 可能仅覆盖数十分钟。cursor predates → system.gap → handler 关 WS(CloseCodeEventGap=4001)→ 客户端重连带旧 cursor → 再次 gap → 循环直到 cursor=0 全量 resync。
  • 影响面:长时间运行的 agent task 在桌面端短暂断线后可能触发完整事件重载;UI 卡顿、transcript 闪烁;若 Hub REST /web/agent-tasks/:id/events 也有上限,可能进一步丢中段事件。
  • 修复切片:① eventLog maxSize 改为可配置(环境变量 / config),默认提升到 200-500 MiB;② gap payload 携带 firstDroppedSeq/lastDroppedSeq(已有字段但 handler 未利用),客户端可精准 REST backfill 而非全量 resync;③ 增加 eventLog 压缩(zstd/jsonlines)延长有效窗口。

G4 [Medium] Hub /client/ws reconnect 无 resume 语义,多端 fanout 下离线期间消息仅靠 REST syncMessages 补齐,syncMessages 自身无 after_seq 游标契约

  • 现象:hubWS.ts reconnect 仅重做 auth;离线期间 message.new 等事件丢失。webHubRealtime.ts 依赖 RQ invalidation + pinMapStore.handleFrame 等 ad-hoc 补救;syncMessages API(hubClient.ts:133)虽有 after_seq 参数,但 api/events.md 未声明其语义是 "message seq" 还是 "ws seq_id"(实际应是 message 表的内部 seq,与 ws seq_id 无关)。
  • 影响面:多端场景下,一端离线再上线时可能漏掉其他端发送的消息、reaction、recall;当前靠 RQ refetch 整页,粒度粗、延迟高。
  • 修复切片:① api/events.md 补充 syncMessages.after_seq 的精确语义(message table seq,非 ws seq_id);② hubWS.ts reconnect 成功后自动触发 syncMessages(after_seq=lastKnownMessageSeq);③ 考虑 Hub 侧为每个 user 维护 per-device 投递水位(类似 Edge cursor),使 WS reconnect 可直接 resume。

G5 [Low] 两条链路 envelope 字段命名不对称(seq_id vs seq),客户端 parser 易混用

  • 现象:Hub Frame 用 seq_id(frame.go:14),Edge EventEnvelope 用 seq(bus.go:163)。前端 hubWS.ts 完全不读 seq_id;eventClient.ts 只读 seq。但若未来 hubWS.ts 加 gap 检测,开发者可能误用 seq 字段名导致静默失败。
  • 影响面:维护成本;新 contributor 易踩坑。
  • 修复切片:① api/events.md 顶部加显式对照表;② hubEvents.ts / eventClient.ts 加 JSDoc 强调字段差异;③ 长期考虑统一为 seq(breaking change,需版本化)。

G6 [Low] relay CreateCommand 经 PushToUser(targetEdgeID) 推送,targetEdgeID 被当作 userID 路由

  • 现象:relay/relay.go:63 s.mgr.PushToUser(targetEdgeID, frame)。ws.Manager.byUser 以 userID 为键(fanout.go:128),但此处传入的是 edge device ID。若 edge device ID ≠ userID(通常如此),帧将被 fanout 到零连接,静默丢失。
  • 影响面:relay 命令投递可能永远不到 Edge;当前无测试覆盖此路径的实际送达。
  • 修复切片:① 确认 relay 设计意图(是否应走 PushToConn 或新增 PushToDevice);② 若确需按 device 路由,Manager 增加 byDevice 索引;③ 补集成测试验证 relay 帧到达目标 Edge。

修复切片建议(≤3 条优先级最高)

  1. G1 → hubWS.ts seq_id gap 检测 + 按族 REST sync:这是当前最大的数据完整性缺口,影响所有 IM/session/device 事件。预计 1-2 PR。
  2. G2 → api/events.md 补 delivery_id 去重契约 + Edge adapter 记录已见 delivery_id:与 fix(dispatch): 孤儿任务恢复重投与 Edge hubTaskId 去重 #2074 协同,闭合 Hub↔Edge 重复投递窗口。预计 1 PR(文档)+ 1 PR(Edge)。
  3. G3 → eventLog maxSize 可配置 + gap payload 精准 backfill:提升长 session 韧性,避免全量 resync 风暴。预计 1 PR。

关联 Issue/PR

审计局限性

  • 未跑测试套件;file:line 基于 master 静态阅读,若近期 PR 改了行号需重新定位
  • 未验证 relay PushToUser(targetEdgeID) 在实际部署中是否真的丢帧(需集成测试或日志佐证)
  • Edge eventLog 50MiB 窗口评估基于经验估算,未实测具体事件速率
  • 未审计 Mobile RN 的 WS 客户端(app/mobile-rn),其行为可能与 web/desktop 不同

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High prioritysize-MMedium effort后端Hub Server、Edge-Hub 通信和后端服务

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions