订单跟踪系统BUG27
订单跟踪严重 Bug 分析
1. 架构与数据流简述
订单跟踪核心在 src/trading/websocket_order_manager.py:
- 唯一解析路径:
_resolve()在持锁内将订单从 PENDING 转为终态(FILLED/CANCELED/REJECTED/TIMEOUT),并result_event.set()。 - WS 驱动:
_on_order_update(orderUpdates)、_on_user_fill(userFills)在锁内设置tracking._ws_status等,释放锁后再调用_resolve(oid)(仅传 oid,在 _resolve 内再次按 oid 查表)。 - HTTP/超时驱动:监控线程或 shutdown 传入
(oid, tracking, status, px, sz),_resolve内做 identity check(current is tracking)再解析。
调用关系简要如下:
sequenceDiagram
participant Exec as executor
participant Mgr as WebSocketOrderManager
participant WS as WS消息
Exec->>Mgr: track_order(oid) -> wait_for_order(tracking)
WS->>Mgr: handle_message(orderUpdates/userFills)
Mgr->>Mgr: _on_* 内设 _ws_status,释放锁后 _resolve(oid)
Mgr->>Mgr: _resolve(oid) 内 get(oid) 再解析
2. 严重 Bug 1:WS 解析的 TOCTOU 竞态(错误终态 + 可能误报超时)
位置:src/trading/websocket_order_manager.py 中 _on_order_update(约 374–376 行)、_on_user_fill(约 416–418 行)。
现象:在锁内对某个 tracking 设置好 _ws_status(如 FILLED)后释放锁,再调用 _resolve(oid)。在「释放锁」到「_resolve 内再次加锁并 self._tracking.get(oid)」之间,若其他线程对同一 oid 调用 track_order(oid),会替换掉当前条目。
后果:
-
已成交订单被报成「取消」+「超时」
- 替换时,旧 tracking 在
track_order里被设为CANCELED并result_event.set(),对应调用方得到「取消」。 _resolve(oid)随后拿到的是新 tracking(_ws_status仍为 None),解析条件不满足、返回 False,新 tracking 之后只会被超时路径解析为 TIMEOUT。- 订单实际已成交,但两方结果都错:一方 CANCELED,一方 TIMEOUT。
- 替换时,旧 tracking 在
-
逻辑上「应被解析」的 tracking 未被解析
- 设置
_ws_status的是旧 tracking,但解析时按 oid 查表得到的是新 tracking,旧 tracking 已不在表中且已被上面步骤标记为 CANCELED,因此 WS 路径永远不会用 FILLED 正确解析任一对象。
- 设置
根因:WS 路径只用 oid 查表,没有像 HTTP/超时路径那样传入 tracking 引用并做 identity 校验,存在典型的 TOCTOU(time-of-check to time-of-use)窗口。
修复方向:
- WS 路径也改为「按引用解析」:在
_on_order_update/_on_user_fill中,在持锁内得到并更新tracking后,在释放锁后调用_resolve(oid, tracking=tracking)(不传status,或约定status=None表示用tracking._ws_status)。 - 扩展
_resolve语义:当tracking is not None且status is None时,视为「WS 按引用解析」:在持锁内做current = self._tracking.get(oid),仅当current is tracking时继续,终态用tracking._ws_status(及现有 fallback 价格逻辑)。这样与现有 HTTP/超时路径的 identity check 一致,避免替换导致的错解析。
3. 严重 Bug 2:宽限期定时器只传 oid,存在相同竞态
位置:src/trading/websocket_order_manager.py 约 359–361 行。
代码:threading.Timer(self._FILL_GRACE_SEC, self._resolve, [oid]),即 5 秒后只带 oid 调用 _resolve(oid)。
问题:定时器触发时再次按 oid 查表,若在此期间同一 oid 被 track_order 替换,会解析到新 tracking(通常 _ws_status 仍为 None),导致本次 _resolve 无效;本应在此定时器里被解析的「旧」tracking 已在替换时被标记 CANCELED 并 set_event,结果与 Bug 1 一致(已成交订单被报成取消/超时)。
修复方向:定时器回调改为携带当前 tracking 引用(例如 functools.partial(self._resolve, oid, tracking) 或等价 lambda),在 _resolve 中支持「按引用 + 使用 tracking._ws_status」的 WS 解析模式(与 Bug 1 的扩展一致),这样定时器触发时解析的是当时启动定时器的那个 tracking,不受后续替换影响。
4. 与现有设计的一致性
- 文档注释(文件头)已说明:HTTP/超时路径用 identity check 防止 tracking 被
track_order()替换;WS 路径当前是「仅 oid 查表」,未做同样保护。 - 修复后:所有 调用
_resolve的路径要么传(oid, tracking, status, ...)(HTTP/超时),要么传(oid, tracking)且status=None(WS 按引用),统一依赖 identity 校验,避免替换导致的错解析与错误终态。
5. 其他已注意到的点(非本次「严重」结论)
- 重复 track_order:代码已显式处理「同一 oid 重复追踪则旧追踪被替代」并打日志;在未替换的常态下,WS 解析竞态窗口较小,但一旦发生替换(如重试或误用),就会触发上述错误终态。
- result_event 永久阻塞:当前逻辑下,替换时旧 tracking 会在
track_order里被设 CANCELED 并result_event.set(),因此不会出现「永远不 set」的阻塞;问题主要是错误终态(CANCELED/TIMEOUT 误报),而不是死锁。 - verify_pending_orders / shutdown:前者按 oid 查表做 HTTP 补查,后者显式传
(oid, tracking, OrderStatus.CANCELED),仅 shutdown 与监控/超时路径一样是「按引用」解析,设计上合理;verify 若在重连后使用且极少与同一 oid 的 track_order 替换重叠,风险相对可控,可与上述两处修复一并考虑是否在补查时也改为按引用(若需要可单独列任务)。
6. 修复项汇总与建议顺序
| 序号 | 问题 | 位置 | 建议修复 |
|---|---|---|---|
| 1 | WS 解析 TOCTOU:释放锁后 _resolve(oid) 按 oid 查表,可能解析到被替换后的新 tracking,导致已成交报成取消/超时 |
_on_order_update、_on_user_fill |
释放锁后调用 _resolve(oid, tracking=tracking);扩展 _resolve 支持「tracking 非空且 status 为 None」时按引用 + _ws_status 解析 |
| 2 | 宽限期定时器只传 oid,存在与 1 相同的替换竞态 | 创建 Timer 处 | 定时器回调传入当前 tracking,_resolve 支持按引用解析(同上) |
建议先实现 _resolve 的「WS 按引用」扩展(一次改动、两处受益),再改 _on_order_update / _on_user_fill 与 Timer 的调用方式,并补单元/集成测试覆盖:同一 oid 先 WS filled 再 track_order 替换,或先 track_order 再 WS filled 再替换,验证终态与 result_event 行为正确。