Files
opendance-windows/tasks/020-ws-contract-conformance.md
T
zijunchiang 0ffb2d43bb test(ws): WebSocket 契约一致性验收(独立实现 × 包内 PoseServer)+ 故意不合规的负例服务端
tests/test_pose_server.gd 用的是**同引擎内的客户端**(Godot WebSocketPeer),而真实
对端是 Android(Kotlin/OkHttp)——同引擎自测会共享同样的假设(超时判定、分包、
文本/二进制边界、关闭语义、子协议协商),于是「测试全绿」不等于「Android 连得上」。
契约 §7 也写明:静默超时要用保活避免,而不是靠重连掩盖;这句话只有用独立实现的
客户端实测才算验过。

新增三个文件:
  * build/integration/probe-ws.gd —— 把**包内** PoseServer 跑起来(--main-pack +
    只加载包内代码),把连接/控制帧/帧/断开事件汇总成 JSON 报告;自身不做判定
    (避免「用被测实现判定被测实现」)。支持 --stop-file 提前收尾。
  * build/integration/ws-client.mjs —— Node 实现的契约一致性客户端(用内置
    WebSocket,零依赖),逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景。
  * build/accept-ws.sh —— 编排:解包 → 起探针 → 等监听 → 跑客户端 → 收报告 →
    核对客户端看不到的那一半(frames_dropped / malformed_frames / 控制帧计数 /
    device_replaced / 关闭原因 / 静默超时消息带毫秒数与阈值)。

实测(**已发布**的 v0.2.3 资产重新下载后跑):客户端 30 通过 / 0 失败 + 服务端侧
16 项事实全过。关键两条:
  * 保活对照:只发应用层 ping 持续 19 秒(> 静默超时 15 秒)连接**仍保持打开**;
  * 静默超时:完全不发数据 → close(1001,"timeout"),实测 15011 ms(阈值 15000 ms);
另有:子协议协商、welcome 声明保活参数且 heartbeat(5000) < silentTimeout(15000)/2、
pong 原样回传 sentAtMs、同 deviceId 替换旧连接(1000 "replaced by new connection")、
bye → 1000、协议不匹配 → bye + 1002、72 帧含故意跳号(服务端 frames_dropped=3)
与故意 100 字节非法帧(malformed_frames=1)。

反向验证:新增**故意不合规的服务端** build/integration/probe-ws-bad.gd
(welcome 不声明保活字段、不带 sessionId、收到 ping 不回 pong、不做 deviceId 去重),
对同一客户端 → 14 项 ✗、退出码 1,证明断言不是空转。

踩坑记录(已写入任务文档):
  1. --script 模式下从无类型数组取 peer,peer.get_packet() 返回类型推不出来,
     var x := … 直接 Parse Error;
  2. 复核静默超时消息时写了否定式断言 !includes("静默超时 1"),把正确行为判成失败 ——
     断言应写成正向模式匹配;
  3. 探针原本只能按固定时长跑,编排脚本得猜时间;改成客户端跑完 touch stop 文件。

顺带:package-release.sh 结尾把三条发布前验收(产物自证 / WS 契约一致性 / 跨端集成)
一次列全。不影响已发布的 v0.2.3 产物,故不新发版本。
2026-09-17 05:32:24 +08:00

6.6 KiB
Raw Blame History

T-WIN-020:WebSocket 契约一致性验收(独立实现 × 发布包内的 PoseServer)

  • 状态: done
  • 依赖: T-WIN-015(保活 / 去重 / 状态机)/ T-WIN-018(产物级验收)
  • 文档: v1.6 §6.5 / §7.6
  • 契约: websocket-keypoints 0.2.0

为什么需要

tests/test_pose_server.gd 用的是同引擎内的客户端(Godot 自己的 WebSocketPeer),而真实对端是 Android(Kotlin/OkHttp)。 同引擎自测会共享同样的假设 —— 超时判定、分包、文本/二进制边界、关闭语义、 子协议协商、ping 是否真的续命 —— 于是「测试全绿」并不等于「Android 连得上」。

契约第 7 节也明确写了:静默超时必须由保活避免,而不是靠重连掩盖。 这句话只有用一个独立实现的客户端实测才算验过。

实现(三个文件)

文件 角色
build/integration/probe-ws.gd 把发布包里的 PoseServer 跑起来(--main-pack + 只加载包内代码),把连接 / 控制帧 / 帧 / 断开事件汇总成 JSON 报告。自身不做判定,避免「用被测实现判定被测实现」
build/integration/ws-client.mjs Node 实现的契约一致性客户端(用 Node 内置 WebSocket,无依赖),逐条跑场景并断言,退出码即结论
build/accept-ws.sh 编排:解包 → 起探针 → 等监听 → 跑客户端 → 收报告 → 核对客户端看不到的那一半(服务端侧计数)

客户端场景(契约章节 → 断言)

场景 断言要点
§1 / §3.1 / §3.2 握手 子协议协商成功;welcome.protocol / sessionId / expectedFps;声明 silentTimeoutMs 与 heartbeatIntervalMs
§1.1 保活参数 heartbeatIntervalMs < silentTimeoutMs / 2(硬性)
§2 二进制帧 239 字节(magic 0x4F44 / version 1 / flags 位 / u32 frame_id / 33×7B 小端),发 72 帧含故意跳号与故意非法长度帧
§3.4 stats 文本 stats 计入活动
§3.6 ping/pong 收到 pong,sentAtMs 原样回传,serverAtMs 已填
§5 设备去重 同 deviceId 新连接 → 旧连接 close(1000, "replaced by new connection"),新连接保持打开
§1.1 保活对照 只发应用层 ping 持续 19 秒(> 静默超时 15 秒)连接仍打开
§1.1 / §7 静默超时 完全不发数据 → close(1001, "timeout"),且时机 ≈ silentTimeoutMs
§3.5 bye bye → 服务端 close(1000)
§3.1 / §6 协议不匹配 bye.reason 含 protocol_mismatch + close(1002)

服务端侧事实核对(accept-ws.sh)

客户端只能看到自己那一半;服务端的计数与原因由探针报告核对: frames_dropped ≥ 3(来自故意的 60→63 跳号)、malformed_frames == 1(来自 100 字节帧)、 frame_id 无回退、控制帧分类计数、device_replaced 事件、 断开原因含「同设备替换 / 静默超时 / user_stop / code=1002」、 且静默超时消息带实测毫秒数与阈值(可核对)。

实测(已发布的 v0.2.3 资产重新下载后跑)

== WebSocket 契约一致性(websocket-keypoints v0.2.0)@ ws://127.0.0.1:18765/pose ==
[1] ✓ 收到 welcome ✓ protocol ✓ sessionId ✓ expectedFps=30 ✓ 声明两个保活参数
    ✓ heartbeat(5000) < silentTimeout(15000)/2 ✓ 子协议已协商
[2] ✓ 已发送 72 帧(含跳号 60→63)✓ 已发送 1 个非法长度帧
[3] ✓ 收到 pong ✓ sentAtMs 原样回传 ✓ serverAtMs 已填写
[4] ✓ 旧连接关闭码 1000 ✓ 原因 replaced by new connection ✓ 新连接保持打开
[5] ✓ 持续 19s 发 ping 后连接仍打开
[6] ✓ 关闭码 1001 ✓ 原因 timeout ✓ 实测 15011 ms(阈值 15000 ms)
[7] ✓ bye → 关闭码 1000
[8] ✓ bye.reason=protocol_mismatch ✓ 关闭码 1002
== 结果:30 通过 / 0 失败 ==
服务端侧:✓ silentTimeoutMs=15000/heartbeatIntervalMs=5000 ✓ 6 次连接 ✓ 72 帧
          ✓ frame_id 最大 74 无回退 ✓ frames_dropped=3 ✓ malformed_frames=1
          ✓ hello=6 ping=9 stats=1 bye=1 ✓ device_replaced=["aaaa000000000001"]
          ✓ 断开原因:替换 / user_stop / code=1002 / 静默超时 15001 ms(阈值 15000 ms)
WebSocket 契约一致性验收全部通过

探针日志里出现的两条 WARNING 是故意的负例产生的(非法帧、协议不匹配), 属预期;脚本只把 SCRIPT ERROR 判为失败。

反向验证(证明断言真的会失败)

只跑通过用例的检查器无法区分「契约被遵守」与「断言写得太松」。 因此配了一个故意不合规的服务端 build/integration/probe-ws-bad.gd: welcome 不声明保活字段、不带 sessionId、收到 ping 不回 pong、不做 deviceId 去重。

对它的运行结果(ws-client.mjs):

✗ welcome.sessionId 非空
✗ welcome 声明 silentTimeoutMs — undefined
✗ welcome 声明 heartbeatIntervalMs — undefined
✗ heartbeatIntervalMs < silentTimeoutMs / 2 — 心跳 NaN ms,超时 NaN ms
✗ 收到 pong — {"type":"(超时)"}   ✗ pong.sentAtMs 原样回传
✗ 旧连接被服务端关闭  ✗ 旧连接关闭码为 1000  ✗ 原因 replaced…
✗ 收到 bye(拿到的是 welcome)  ✗ bye.reason 说明协议不匹配
客户端退出码=1

踩坑记录

  1. --script 模式下的类型推断:探针从无类型数组里取 peer,peer.get_packet() 的返回类型推不出来,var packet := … 直接 Parse Error(且在 --headless 下只表现为「脚本加载失败 + 退出码 1」,没有别的线索)。显式标注类型即可。
  2. 不要写否定式断言:核对静默超时消息时第一版写成 say(!reasons.includes("静默超时 1"), …),本意是「消息里带 15001 ms」, 实际把正确行为判成了失败。断言要写成正向的模式匹配 (/静默超时 \d+ ms(阈值 \d+ ms/)。
  3. 固定时长 vs 提前收尾:探针一开始只能按 --duration-ms 跑满, 编排脚本就得猜时间。改成「客户端跑完 touch 一个 stop 文件,探针提前收尾」, 既快又不依赖机器速度。

未覆盖(诚实声明)

  • Android 端自身的重连状态机与退避(§7.1 的 0.5s→1s→2s→4s→8s→15s ±20% 抖动) 在本端无法执行:它在 Kotlin 里,由 Android 仓的 JVM 测试覆盖(本任务验的是 它对端的行为是否与契约一致 —— 也就是「重连之后对面会怎么反应」);
  • 真机 Wi-Fi 抖动 / 切网 / 进程被杀这些真实断线场景,仍需设备实测。