Files
opendance-windows/tasks/020-ws-contract-conformance.md
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

111 lines
6.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 抖动 / 切网 / 进程被杀这些真实断线场景,仍需设备实测。