# 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 抖动 / 切网 / 进程被杀这些真实断线场景,仍需设备实测。