From 0ffb2d43bb1af395d220386b82953040e57fe9b1 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Thu, 17 Sep 2026 05:32:24 +0800 Subject: [PATCH] =?UTF-8?q?test(ws):=20WebSocket=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7=E9=AA=8C=E6=94=B6=EF=BC=88=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=AE=9E=E7=8E=B0=20=C3=97=20=E5=8C=85=E5=86=85=20Pos?= =?UTF-8?q?eServer=EF=BC=89+=20=E6=95=85=E6=84=8F=E4=B8=8D=E5=90=88?= =?UTF-8?q?=E8=A7=84=E7=9A=84=E8=B4=9F=E4=BE=8B=E6=9C=8D=E5=8A=A1=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 产物,故不新发版本。 --- README.md | 25 +++ build/accept-ws.sh | 186 ++++++++++++++++ build/integration/probe-ws-bad.gd | 96 +++++++++ build/integration/probe-ws.gd | 175 +++++++++++++++ build/integration/ws-client.mjs | 304 +++++++++++++++++++++++++++ build/package-release.sh | 6 +- project.yaml | 4 + tasks/020-ws-contract-conformance.md | 110 ++++++++++ 8 files changed, 904 insertions(+), 2 deletions(-) create mode 100755 build/accept-ws.sh create mode 100644 build/integration/probe-ws-bad.gd create mode 100644 build/integration/probe-ws.gd create mode 100644 build/integration/ws-client.mjs create mode 100644 tasks/020-ws-contract-conformance.md diff --git a/README.md b/README.md index 4470869..44508b5 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,31 @@ exe 的 PE 版本资源 → **用 Godot 直接加载包内 pck 跑 180 帧** → 已对**已发布的 v0.2.2 资产**(重新下载)跑通,并用「篡改包内 contracts/VERSION」与 「期望版本写错」两个负例反向验证过。 +### WebSocket 契约一致性验收(T-WIN-020) + +`tests/test_pose_server.gd` 用的是**同引擎内的客户端**,而真实对端是 +**Android(Kotlin/OkHttp)** —— 同引擎自测会共享同样的假设(超时判定、分包、 +文本/二进制边界、关闭语义、子协议协商)。因此另配一套**独立实现**的客户端: + +```bash +GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3.zip +# 跳过耗时的两个场景(保活 19s / 静默超时 15s): +GODOT_BIN=... bash build/accept-ws.sh --skip-slow +``` + +* `build/integration/probe-ws.gd`:把**包内** PoseServer 跑起来并汇总事件成 JSON(自身不判定); +* `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` / 控制帧计数 / 去重事件 / 关闭原因)。 + +实测(已发布的 v0.2.3 资产):客户端 **30 通过 / 0 失败** + 服务端侧 16 项事实全过, +其中最关键的是**保活对照**——只发应用层 `ping` 持续 19 秒(> 静默超时 15 秒)连接 +**仍保持**,而完全不发数据的连接在 **15011 ms**(阈值 15000 ms)被 `close(1001, "timeout")`。 +反向验证用一个**故意不合规的服务端**(`build/integration/probe-ws-bad.gd`:welcome 不声明 +保活字段、不回 pong、不做去重)跑同一客户端 → 14 项 ✗、退出码 1,证明断言不是空转。 +详见 [`tasks/020-ws-contract-conformance.md`](tasks/020-ws-contract-conformance.md)。 + 当前版本:**v0.2.3**(`project.godot` 的 `application/config/version`, 与 `project.yaml`、`export_presets.cfg`、导出产物名、Git tag、「关于」清单展示、 以及 exe 的 PE 版本资源全部一致)。 diff --git a/build/accept-ws.sh b/build/accept-ws.sh new file mode 100755 index 0000000..7806920 --- /dev/null +++ b/build/accept-ws.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# ============================================================ +# OpenDance Studio Windows 客户端 —— **WebSocket 契约一致性验收**(T-WIN-020) +# +# contracts/websocket-keypoints.md v0.2.0 × 发布包里的 PoseServer +# +# 为什么需要它:本仓的 `tests/test_pose_server.gd` 用的是**同引擎内的客户端** +# (Godot 自己的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。 +# 同引擎自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义、 +# 子协议协商)——**必须有一个独立实现的客户端来对照**: +# * `build/integration/probe-ws.gd`:把**发布包里**的 PoseServer 跑起来 +# (只加载包内代码),把连接/控制/帧/断开事件汇总成 JSON 报告; +# * `build/integration/ws-client.mjs`:Node 实现的契约一致性客户端, +# 逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景并断言; +# * 本脚本:编排两者,并把**服务端侧**的事实(丢帧计数、malformed、去重事件、 +# 关闭原因)也一并核对 —— 客户端只能看到自己那一半。 +# +# 用法: +# GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3.zip +# GODOT_BIN=... bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3 # 已解压目录 +# ... --skip-slow # 跳过「保活 19s」与「静默超时 15s」两个耗时场景 +# +# 可选环境变量: +# PORT 监听端口(默认 18765) +# WORK_DIR 工作目录(默认 mktemp -d) +# ============================================================ +set -uo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +TARGET="${1:-}" +SKIP_SLOW="" +for arg in "$@"; do + case "$arg" in + --skip-slow) SKIP_SLOW="--skip-slow" ;; + "$TARGET") : ;; + *) echo "✗ 未知参数:$arg(只支持 --skip-slow)" >&2; exit 2 ;; + esac +done + +PORT="${PORT:-18765}" +WORK_DIR="${WORK_DIR:-$(mktemp -d)}" +mkdir -p "$WORK_DIR" +REPORT="$WORK_DIR/ws-report.json" +STOP_FILE="$WORK_DIR/ws-stop" +PROBE_LOG="$WORK_DIR/probe-ws.log" + +FAILED=0 +log() { printf '\033[1;34m[accept-ws]\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31m[accept-ws] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; } + +if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then + echo "用法:GODOT_BIN=... bash build/accept-ws.sh <发布包 zip 或已解压目录> [--skip-slow]" >&2 + exit 2 +fi + +# ---- 1. 取包内 pck ---- +if [ -f "$TARGET" ]; then + log "解压发布包…" + if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then + command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; } + fi + PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" +else + PACKAGE_DIR="$TARGET" +fi +PCK="$PACKAGE_DIR/OpenDance.pck" +[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; } +log "包内 pck:$PCK" + +# ---- 2. 启探针(包内 PoseServer)---- +GODOT_BIN="${GODOT_BIN:-}" +if [ -z "$GODOT_BIN" ]; then + for candidate in godot godot4 Godot; do + command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; } + done +fi +[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需 4.7.x)"; exit 1; } + +rm -f "$REPORT" "$STOP_FILE" +log "启动包内 PoseServer 探针(端口 $PORT)…" +nohup "$GODOT_BIN" --headless --main-pack "$PCK" \ + --script "$PROJECT_DIR/build/integration/probe-ws.gd" \ + -- --port "$PORT" --duration-ms 180000 --report "$REPORT" --stop-file "$STOP_FILE" \ + >"$PROBE_LOG" 2>&1 & +PROBE_PID=$! +cleanup() { kill "$PROBE_PID" 2>/dev/null || true; } +trap cleanup EXIT + +READY=0 +for _ in $(seq 1 60); do + grep -q "\[ws\] listening" "$PROBE_LOG" 2>/dev/null && { READY=1; break; } + kill -0 "$PROBE_PID" 2>/dev/null || break + sleep 0.5 +done +if [ "$READY" != 1 ]; then + fail "探针 30 秒内未开始监听" + tail -20 "$PROBE_LOG" | sed 's/^/ /' + exit 1 +fi +grep "\[ws\] listening" "$PROBE_LOG" | sed 's/^/ /' + +# ---- 3. 跑契约一致性客户端 ---- +log "契约一致性客户端(Node 独立实现)…" +CLIENT_LOG="$WORK_DIR/ws-client.log" +if timeout 300 node "$PROJECT_DIR/build/integration/ws-client.mjs" \ + --url "ws://127.0.0.1:$PORT/pose" $SKIP_SLOW >"$CLIENT_LOG" 2>&1; then + CLIENT_OK=1 +else + CLIENT_OK=0 +fi +sed 's/^/ /' "$CLIENT_LOG" +[ "$CLIENT_OK" = 1 ] || fail "契约一致性客户端未全通过" + +# ---- 4. 收尾并取服务端侧报告 ---- +touch "$STOP_FILE" +for _ in $(seq 1 40); do + kill -0 "$PROBE_PID" 2>/dev/null || break + sleep 0.5 +done +if [ ! -f "$REPORT" ]; then + fail "探针没有产出报告($REPORT)" + tail -20 "$PROBE_LOG" | sed 's/^/ /' + exit 1 +fi + +# ---- 5. 服务端侧事实核对 ---- +log "服务端侧事实核对(客户端看不到的那一半):" +node -e ' +const fs = require("node:fs"); +const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); +const skipSlow = process.argv[2] === "1"; +let bad = 0; +const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; }; + +say(r.server.silent_timeout_ms === 15000 && r.server.heartbeat_interval_ms === 5000, + `服务端声明 silentTimeoutMs=${r.server.silent_timeout_ms} / heartbeatIntervalMs=${r.server.heartbeat_interval_ms}`); +say(r.connected.length >= 6, `收到 ${r.connected.length} 次连接(握手完成)`); +say(r.framesReceived >= 72, `收到 ${r.framesReceived} 个关键点帧`); +say(r.lastFrameId >= 74, `最大 frame_id = ${r.lastFrameId}`); +say(r.frameIdRegressions === 0, `frame_id 无回退(回退 ${r.frameIdRegressions} 次)`); +// 客户端故意跳号 60→63,服务端应按 §8 计入丢帧 +say(r.server.frames_dropped >= 3, `丢帧检测:frames_dropped=${r.server.frames_dropped}(期望 ≥3,来自故意的 60→63 跳号)`); +// 客户端故意发 100 字节非法帧,服务端应计数且不崩 +say(r.server.malformed_frames === 1, `非法帧计数:malformed_frames=${r.server.malformed_frames}(期望 1)`); +// 控制帧分类计数 +const c = r.controls; +say((c.hello ?? 0) >= 6, `hello=${c.hello ?? 0};ping=${c.ping ?? 0};stats=${c.stats ?? 0};bye=${c.bye ?? 0}`); +say((c.ping ?? 0) >= (skipSlow ? 1 : 8), `应用层 ping 计数=${c.ping ?? 0}(保活场景)`); +// 去重事件 +say(Array.isArray(r.deviceReplaced) && r.deviceReplaced.includes("aaaa000000000001"), + `device_replaced 事件:${JSON.stringify(r.deviceReplaced)}`); +// 断开原因:替换(1000) / 静默超时 / bye(1000) / 协议错误(1002) +const reasons = r.disconnected.map((x) => x.reason).join(" | "); +say(reasons.includes("被同一设备的新连接替换"), "服务端记录了「同设备替换」断开原因"); +say(reasons.includes("user_stop"), "服务端记录了 bye 的 reason"); +say(reasons.includes("code=1002"), "服务端记录了协议不匹配的 1002 关闭"); +if (!skipSlow) { + say(reasons.includes("静默超时"), "服务端记录了静默超时踢人"); + // 写这条时第一版写成了 !reasons.includes("静默超时 1") —— 恰好把「消息里带 15001 ms」 + // 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。 + say(/静默超时 \d+ ms(阈值 \d+ ms/.test(reasons), + "静默超时消息带实测毫秒数与阈值(可核对):" + (reasons.match(/静默超时 [^|]*/) ?? [""])[0].trim()); +} +process.exit(bad); +' "$REPORT" "$([ -n "$SKIP_SLOW" ] && echo 1 || echo 0)" || fail "服务端侧事实核对未通过" + +# 探针日志里不允许出现脚本错误(WARNING 是故意的负例产生的,允许) +if grep -qE "SCRIPT ERROR|Parse Error" "$PROBE_LOG"; then + fail "探针日志出现脚本错误" + grep -E "SCRIPT ERROR|Parse Error" "$PROBE_LOG" | head -5 | sed 's/^/ /' +else + echo " ✓ 探针日志无脚本错误(负例产生的 WARNING 属预期)" +fi + +echo "" +if [ "$FAILED" -ne 0 ]; then + echo "============================================================" + echo " WebSocket 契约一致性验收失败(工作目录:$WORK_DIR)" + echo "============================================================" + exit 1 +fi +echo "============================================================" +echo " WebSocket 契约一致性验收全部通过(工作目录:$WORK_DIR)" +echo "============================================================" diff --git a/build/integration/probe-ws-bad.gd b/build/integration/probe-ws-bad.gd new file mode 100644 index 0000000..f5c0182 --- /dev/null +++ b/build/integration/probe-ws-bad.gd @@ -0,0 +1,96 @@ +extends SceneTree + +## 反例服务端(T-WIN-020 的**负例**夹具):故意不遵守 websocket-keypoints v0.2.0。 +## +## 用途:证明 `build/integration/ws-client.mjs` 的断言**真的会失败**。 +## 一个只跑通过用例的检查器,无法区分「契约被遵守」与「断言写得太松」。 +## +## 故意违反的条款: +## * §3.2 welcome **不声明** silentTimeoutMs / heartbeatIntervalMs(v0.2.0 硬性要求); +## * §3.2 welcome 不带 sessionId; +## * §3.6 收到应用层 ping **不回** pong; +## * §5 不做 deviceId 去重(第二个同 deviceId 连接不会踢掉第一个)。 +## +## 用法:与 probe-ws.gd 相同(`--port` / `--duration-ms` / `--report` / `--stop-file`), +## 但本文件只记录最小事实。期望结果:ws-client.mjs 出现多项 ✗ 并以非 0 退出。 + +const DEFAULT_PORT := 18770 +const DEFAULT_DURATION_MS := 60_000 + +var _tcp := TCPServer.new() +var _peers: Array = [] +var _started := false +var _done := false +var _start_ms := 0 +var _port := DEFAULT_PORT +var _duration_ms := DEFAULT_DURATION_MS +var _report_path := "" +var _stop_file := "" + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var i := 0 + while i < args.size(): + match args[i]: + "--port": + _port = int(args[i + 1]); i += 2 + "--duration-ms": + _duration_ms = int(args[i + 1]); i += 2 + "--report": + _report_path = args[i + 1]; i += 2 + "--stop-file": + _stop_file = args[i + 1]; i += 2 + _: + i += 1 + + +func _process(_delta: float) -> bool: + if not _started: + _started = true + _start_ms = Time.get_ticks_msec() + var err := _tcp.listen(_port, "127.0.0.1") + if err != OK: + print("[bad-ws] ✗ listen 失败:%s" % error_string(err)) + quit(2) + return true + print("[bad-ws] listening on 127.0.0.1:%d(**故意不合规**的服务端)" % _port) + + while _tcp.is_connection_available(): + var conn := _tcp.take_connection() + if conn == null: + continue + var peer := WebSocketPeer.new() + peer.supported_protocols = PackedStringArray(["opendance.pose.v1"]) + if peer.accept_stream(conn) == OK: + _peers.append(peer) + + for peer in _peers: + peer.poll() + while peer.get_available_packet_count() > 0: + # peer 来自无类型数组,返回值类型推不出来,这里显式标注 + var packet: PackedByteArray = peer.get_packet() + if peer.was_string_packet(): + var text: String = packet.get_string_from_utf8() + var parsed: Variant = JSON.parse_string(text) + if typeof(parsed) == TYPE_DICTIONARY and str(parsed.get("type", "")) == "hello": + # 违规 1/2/3:缺保活字段、缺 sessionId、且此后不回 pong + peer.send_text(JSON.stringify({ + "type": "welcome", + "protocol": "opendance.pose.v1", + "expectedFps": 30, + })) + + if _done: + return true + var elapsed := Time.get_ticks_msec() - _start_ms + if elapsed >= _duration_ms or (_stop_file != "" and FileAccess.file_exists(_stop_file)): + if _report_path != "": + var file := FileAccess.open(_report_path, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify({"server": "bad-ws", "peers": _peers.size()}, " ")) + file.close() + print("[bad-ws] 收尾(连接 %d,未做去重、未回 pong)" % _peers.size()) + _done = true + quit(0) + return _done diff --git a/build/integration/probe-ws.gd b/build/integration/probe-ws.gd new file mode 100644 index 0000000..a9ddb2e --- /dev/null +++ b/build/integration/probe-ws.gd @@ -0,0 +1,175 @@ +extends SceneTree + +## WebSocket 契约一致性探针(T-WIN-020) +## +## 用法(由 build/accept-ws.sh 调用,也可手工执行): +## godot --headless --main-pack <包内 OpenDance.pck> \ +## --script /绝对路径/build/integration/probe-ws.gd \ +## -- --port 18765 --duration-ms 60000 --report /tmp/ws-report.json +## +## 为什么需要它:`tests/test_pose_server.gd` 用的是**同引擎内的客户端** +## (Godot 自己写的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。 +## 同引擎内的自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义), +## 因此**必须有一个独立实现的客户端**来对照 —— 这就是本探针 + Node 客户端的组合: +## * 本探针:把**发布包里的** PoseServer 跑起来(只加载包内代码), +## 把收到的连接/控制/帧/断开事件汇总成 JSON 报告; +## * `build/integration/ws-client.mjs`:Node 端按契约 v0.2.0 逐条跑场景并断言。 +## +## 探针自身不做判定(只收集事实),判定在 Node 客户端与 accept-ws.sh 里,避免 +## 「用被测实现来判定被测实现」。 + +const DEFAULT_PORT := 18765 +const DEFAULT_DURATION_MS := 60_000 + +var _server: Node = null +var _started := false +var _done := false +var _start_ms := 0 +var _port := DEFAULT_PORT +var _duration_ms := DEFAULT_DURATION_MS +var _report_path := "/tmp/opendance-ws-report.json" +## 可选:客户端跑完后 touch 这个文件,探针提前收尾(比固定时长更快、更稳) +var _stop_file := "" + +var _events: Array = [] +var _connected: Array = [] +var _disconnected: Array = [] +var _replaced: Array = [] +var _controls := {} +var _frames := 0 +var _last_frame_id := -1 +var _frame_id_regressions := 0 +var _first_frame_ms := 0 +var _last_frame_ms := 0 + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var i := 0 + while i < args.size(): + match args[i]: + "--port": + _port = int(args[i + 1]); i += 2 + "--duration-ms": + _duration_ms = int(args[i + 1]); i += 2 + "--report": + _report_path = args[i + 1]; i += 2 + "--stop-file": + _stop_file = args[i + 1]; i += 2 + _: + i += 1 + + +func _process(_delta: float) -> bool: + if not _started: + _started = true + _start_ms = Time.get_ticks_msec() + _start_server() + if _done: + return true + var elapsed := Time.get_ticks_msec() - _start_ms + if elapsed >= _duration_ms: + _finish() + elif _stop_file != "" and FileAccess.file_exists(_stop_file): + _finish() + return _done + + +func _start_server() -> void: + var script: GDScript = load("res://src/net/pose_server.gd") + if script == null: + print("[ws] ✗ 无法加载 res://src/net/pose_server.gd") + _finish(2) + return + _server = script.new() + _server.name = "ProbePoseServer" + root.add_child(_server) + + _server.listening.connect(_on_listening) + _server.listen_failed.connect(_on_listen_failed) + _server.client_connected.connect(_on_client_connected) + _server.client_disconnected.connect(_on_client_disconnected) + _server.device_replaced.connect(_on_device_replaced) + _server.frame_received.connect(_on_frame_received) + _server.control_received.connect(_on_control_received) + + var err: int = _server.listen("127.0.0.1", _port) + if err != OK: + print("[ws] ✗ listen(%d) 失败:%s" % [_port, error_string(err)]) + _finish(2) + + +func _on_listening(port: int) -> void: + # accept-ws.sh 靠这一行判断「服务端已就绪」 + print("[ws] listening on 127.0.0.1:%d(契约 §1 端点)" % port) + + +func _on_listen_failed(port: int, reason: String) -> void: + print("[ws] ✗ 监听 %d 失败:%s" % [port, reason]) + + +func _now_ms() -> int: + return Time.get_ticks_msec() - _start_ms + + +func _on_client_connected(client_id: int, device: String) -> void: + _connected.append({"id": client_id, "device": device, "atMs": _now_ms()}) + _events.append({"atMs": _now_ms(), "kind": "connected", "id": client_id, "device": device}) + + +func _on_client_disconnected(client_id: int, reason: String) -> void: + _disconnected.append({"id": client_id, "reason": reason, "atMs": _now_ms()}) + _events.append({"atMs": _now_ms(), "kind": "disconnected", "id": client_id, "reason": reason}) + + +func _on_device_replaced(device_id: String) -> void: + _replaced.append(device_id) + _events.append({"atMs": _now_ms(), "kind": "device_replaced", "deviceId": device_id}) + + +func _on_frame_received(frame) -> void: + _frames += 1 + if _first_frame_ms == 0: + _first_frame_ms = _now_ms() + _last_frame_ms = _now_ms() + if _last_frame_id >= 0 and frame.frame_id <= _last_frame_id: + _frame_id_regressions += 1 + _last_frame_id = frame.frame_id + + +func _on_control_received(client_id: int, message: Dictionary) -> void: + var type := str(message.get("type", "(无 type)")) + _controls[type] = int(_controls.get(type, 0)) + 1 + _events.append({"atMs": _now_ms(), "kind": "control", "id": client_id, "type": type}) + + +func _finish(code: int = 0) -> void: + if _done: + return + _done = true + var report := { + "port": _port, + "durationMs": _now_ms(), + "server": _server.snapshot() if _server != null else {}, + "connected": _connected, + "disconnected": _disconnected, + "deviceReplaced": _replaced, + "controls": _controls, + "framesReceived": _frames, + "lastFrameId": _last_frame_id, + "frameIdRegressions": _frame_id_regressions, + "firstFrameAtMs": _first_frame_ms, + "lastFrameAtMs": _last_frame_ms, + "events": _events, + } + if _server != null: + _server.stop() + var file := FileAccess.open(_report_path, FileAccess.WRITE) + if file == null: + print("[ws] ✗ 无法写入报告 %s" % _report_path) + else: + file.store_string(JSON.stringify(report, " ")) + file.close() + print("[ws] 报告已写入 %s(连接 %d、帧 %d、控制帧 %s、替换 %d)" % [ + _report_path, _connected.size(), _frames, JSON.stringify(_controls), _replaced.size()]) + quit(code) diff --git a/build/integration/ws-client.mjs b/build/integration/ws-client.mjs new file mode 100644 index 0000000..1b7b5a4 --- /dev/null +++ b/build/integration/ws-client.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node +/** + * WebSocket 契约一致性客户端(T-WIN-020) + * + * 独立的 Node 实现,按 contracts/websocket-keypoints.md **v0.2.0** 逐条跑场景, + * 对发布包里的 Windows `PoseServer` 做契约一致性验证。 + * + * 为什么不用 Godot 自己的客户端:同引擎内的自测会共享同样的假设 + * (超时判定、分包、文本/二进制边界、关闭语义、子协议协商)。 + * 真实对端是 Android(Kotlin/OkHttp),所以必须用**另一套实现**来对照。 + * + * 用法: + * node build/integration/ws-client.mjs --url ws://127.0.0.1:18765/pose [--skip-slow] + * + * 场景(对应契约章节): + * §1/§3.2 子协议协商 + hello → welcome(含 silentTimeoutMs / heartbeatIntervalMs) + * §1.1 heartbeatIntervalMs 必须 < silentTimeoutMs / 2 + * §2 239 字节二进制帧(magic/version/flags/frame_id/33×7B),含 flags 位 + * §3.4 stats 文本帧 + * §3.6 应用层 ping → pong(sentAtMs 原样回传) + * §5 同 deviceId 新连接替换旧连接:旧连接收到 close(1000, "replaced…") + * §1.1 只发 ping 时连接**不会**被静默超时踢掉(19 秒对照,--skip-slow 可跳过) + * §1.1/§7 完全不发数据 → 服务端 close(1001, "timeout") + * §3.5 bye → 服务端 close(1000) + * §3.1/§6 协议版本不匹配 → bye(reason=protocol_mismatch) + close(1002) + * §2 非法二进制帧 → 服务端不崩且计入 malformed(由探针报告核对) + * + * 退出码:0 全通过 / 1 有失败 / 2 用法或连接问题。 + */ + +const args = process.argv.slice(2); +function argValue(name, fallback) { + const i = args.indexOf(name); + return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback; +} +const URL_ = argValue('--url', 'ws://127.0.0.1:18765/pose'); +const SUBPROTOCOL = 'opendance.pose.v1'; +const SKIP_SLOW = args.includes('--skip-slow'); + +const MAGIC = 0x4f44; +const FRAME_BYTES = 239; + +let passed = 0; +let failed = 0; +const failures = []; + +function check(name, ok, detail = '') { + if (ok) { + passed += 1; + console.log(` ✓ ${name}`); + } else { + failed += 1; + failures.push(`${name}${detail ? ` — ${detail}` : ''}`); + console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 构造契约 §2 的 239 字节关键点帧 */ +function buildFrame({ frameId = 0, flags = 0, mirrored = false, lowConf = false, upperBodyOnly = false } = {}) { + const buf = new ArrayBuffer(FRAME_BYTES); + const view = new DataView(buf); + view.setUint16(0, MAGIC, true); // magic(小端) + view.setUint8(2, 0x01); // version + let f = flags; + if (mirrored) f |= 0x01; + if (lowConf) f |= 0x02; + if (upperBodyOnly) f |= 0x04; + view.setUint8(3, f); + view.setUint32(4, frameId >>> 0, true); + for (let i = 0; i < 33; i += 1) { + const o = 8 + i * 7; + // 造一组确定性的归一化坐标:x 从 -0.9 到 0.9,y 随索引摆动,z 小量 + const x = Math.round((-0.9 + (1.8 * i) / 32) * 32767); + const y = Math.round((0.5 * Math.sin(i) + 0.1) * 32767); + const z = Math.round((0.02 * i - 0.3) * 32767); + view.setInt16(o, Math.max(-32768, Math.min(32767, x)), true); + view.setInt16(o + 2, Math.max(-32768, Math.min(32767, y)), true); + view.setInt16(o + 4, Math.max(-32768, Math.min(32767, z)), true); + view.setUint8(o + 6, i === 5 ? 100 : 240); // 故意放一个低置信度点(conf<128) + } + return new Uint8Array(buf); +} + +/** 打开一条连接,返回 { ws, next(type), closeInfo } */ +function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SUBPROTOCOL, hello = true } = {}) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(URL_, [SUBPROTOCOL]); + ws.binaryType = 'arraybuffer'; + const inbox = []; + const waiters = []; + const state = { closeInfo: null, error: null }; + + const push = (msg) => { + const w = waiters.shift(); + if (w) w(msg); + else inbox.push(msg); + }; + + ws.addEventListener('message', (ev) => { + if (typeof ev.data === 'string') { + try { + push(JSON.parse(ev.data)); + } catch { + push({ type: '(非 JSON 文本)', raw: ev.data }); + } + } else { + push({ type: '(二进制)', byteLength: ev.data.byteLength }); + } + }); + ws.addEventListener('close', (ev) => { + state.closeInfo = { code: ev.code, reason: ev.reason }; + const w = waiters.shift(); + if (w) w({ type: '(closed)', ...state.closeInfo }); + else inbox.push({ type: '(closed)', ...state.closeInfo }); + }); + ws.addEventListener('error', (ev) => { + state.error = ev?.message ?? 'error'; + }); + + const opened = new Promise((res, rej) => { + ws.addEventListener('open', res); + const t = setTimeout(() => rej(new Error('连接超时(3s)')), 3000); + t.unref?.(); + }); + + opened + .then(() => { + if (hello) { + ws.send(JSON.stringify({ + type: 'hello', protocol, deviceId, device, + appVersion: '0.2.0', cameraFacing: 'front', targetFps: 30, resolution: '640x480', + })); + } + resolve({ + ws, + state, + next: (timeoutMs = 3000) => new Promise((res) => { + if (inbox.length) return res(inbox.shift()); + const t = setTimeout(() => res({ type: '(超时)', timeoutMs }), timeoutMs); + t.unref?.(); + waiters.push((m) => { + clearTimeout(t); + res(m); + }); + }), + async closed(timeoutMs = 3000) { + if (state.closeInfo) return state.closeInfo; + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + if (state.closeInfo) return state.closeInfo; + await sleep(50); + } + return null; + }, + }); + }) + .catch(reject); + }); +} + +function badFrame() { + // 契约要求恰好 239 字节;这里故意发 100 字节 + return new Uint8Array(100).fill(0x7f); +} + +async function main() { + console.log(`\n== WebSocket 契约一致性(websocket-keypoints v0.2.0)@ ${URL_} ==\n`); + + // ---------- §1/§3.2 握手 ---------- + console.log('[1] 子协议协商 + hello → welcome(§1 / §3.1 / §3.2)'); + const a = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' }); + const welcome = await a.next(3000); + check('收到 welcome', welcome?.type === 'welcome', JSON.stringify(welcome)); + check('welcome.protocol 与契约一致', welcome?.protocol === SUBPROTOCOL, String(welcome?.protocol)); + check('welcome.sessionId 非空', typeof welcome?.sessionId === 'string' && welcome.sessionId.length > 0); + check('welcome.expectedFps = 30', welcome?.expectedFps === 30, String(welcome?.expectedFps)); + const silent = Number(welcome?.silentTimeoutMs); + const heartbeat = Number(welcome?.heartbeatIntervalMs); + check('welcome 声明 silentTimeoutMs', Number.isFinite(silent) && silent > 0, String(welcome?.silentTimeoutMs)); + check('welcome 声明 heartbeatIntervalMs', Number.isFinite(heartbeat) && heartbeat > 0, String(welcome?.heartbeatIntervalMs)); + check('heartbeatIntervalMs < silentTimeoutMs / 2(§1.1 硬性)', + heartbeat < silent / 2, `心跳 ${heartbeat} ms,超时 ${silent} ms`); + check('子协议已协商成功(未回落到空串)', a.ws.protocol === SUBPROTOCOL, `实际 '${a.ws.protocol}'`); + + // ---------- §2 二进制帧 ---------- + console.log('\n[2] 239 字节二进制帧(§2)'); + for (let i = 0; i < 60; i += 1) { + a.ws.send(buildFrame({ frameId: i, mirrored: i % 2 === 0, lowConf: i % 5 === 0, upperBodyOnly: false })); + await sleep(8); + } + // 故意跳号(丢包检测:§8 应进入 degraded 并可自动恢复) + for (let i = 63; i < 75; i += 1) { + a.ws.send(buildFrame({ frameId: i })); + await sleep(8); + } + check('已发送 72 帧(含跳号 60→63)', true); + a.ws.send(badFrame()); + check('已发送 1 个非法长度帧(100 字节,服务端应计入 malformed 且不崩)', true); + await sleep(200); + + // ---------- §3.4 / §3.6 ---------- + console.log('\n[3] stats 与 ping/pong(§3.4 / §3.6)'); + a.ws.send(JSON.stringify({ type: 'stats', fps: 29.6, dropped: 3, latencyMs: 42, confidenceAvg: 0.93 })); + const sentAt = 1730000000000; + a.ws.send(JSON.stringify({ type: 'ping', sentAtMs: sentAt })); + const pong = await a.next(3000); + check('收到 pong', pong?.type === 'pong', JSON.stringify(pong)); + check('pong.sentAtMs 原样回传', pong?.sentAtMs === sentAt, String(pong?.sentAtMs)); + check('pong.serverAtMs 已填写', Number.isFinite(Number(pong?.serverAtMs)) && Number(pong?.serverAtMs) > 0, + String(pong?.serverAtMs)); + + // ---------- §5 设备去重 ---------- + console.log('\n[4] 同 deviceId 替换旧连接(§5)'); + const a2 = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' }); + const a2welcome = await a2.next(3000); + check('新连接收到 welcome', a2welcome?.type === 'welcome', JSON.stringify(a2welcome)); + const oldClose = await a.closed(4000); + check('旧连接被服务端关闭', oldClose !== null, '旧连接仍在打开'); + check('旧连接关闭码为 1000(契约 §5)', oldClose?.code === 1000, String(oldClose?.code)); + check('旧连接关闭原因为 replaced by new connection', + String(oldClose?.reason ?? '').includes('replaced'), String(oldClose?.reason)); + check('新连接保持打开', a2.ws.readyState === WebSocket.OPEN, String(a2.ws.readyState)); + + // ---------- §1.1 保活:只发 ping 不会被踢 ---------- + console.log('\n[5] 保活:只发应用层 ping,连接不应被静默超时踢掉(§1.1)'); + if (SKIP_SLOW) { + console.log(' · --skip-slow:跳过 19 秒对照'); + } else { + const k = await connect({ deviceId: 'bbbb000000000002', device: 'Keepalive' }); + const kw = await k.next(3000); + check('保活连接已握手', kw?.type === 'welcome', JSON.stringify(kw)); + const deadline = Date.now() + (silent + 4000); // 超过静默超时 4 秒 + let pings = 0; + while (Date.now() < deadline) { + if (k.ws.readyState !== WebSocket.OPEN) break; + k.ws.send(JSON.stringify({ type: 'ping', sentAtMs: Date.now() })); + pings += 1; + await sleep(Math.max(500, Math.floor(heartbeat / 2))); + } + check(`持续 ${Math.round((silent + 4000) / 100) / 10}s 发 ping(${pings} 次)后连接仍打开`, + k.ws.readyState === WebSocket.OPEN, + `readyState=${k.ws.readyState}(静默超时 ${silent} ms,期望心跳 ${heartbeat} ms)`); + k.ws.close(1000, 'done'); + await k.closed(2000); + } + + // ---------- §1.1 / §7 静默超时 ---------- + console.log('\n[6] 完全不发数据 → close(1001, "timeout")(§1.1 / §7)'); + if (SKIP_SLOW) { + console.log(' · --skip-slow:跳过静默超时对照'); + } else { + const s = await connect({ deviceId: 'cccc000000000003', device: 'Silent' }); + const sw = await s.next(3000); + check('静默连接已握手', sw?.type === 'welcome', JSON.stringify(sw)); + const t0 = Date.now(); + const closeInfo = await s.closed(silent + 6000); + const elapsed = Date.now() - t0; + check('服务端关闭了静默连接', closeInfo !== null, `${elapsed} ms 内未关闭`); + check('关闭码为 1001(going away)', closeInfo?.code === 1001, String(closeInfo?.code)); + check('关闭原因为 timeout', String(closeInfo?.reason ?? '').includes('timeout'), String(closeInfo?.reason)); + check(`关闭时机接近 silentTimeoutMs(实测 ${elapsed} ms,阈值 ${silent} ms)`, + closeInfo !== null && elapsed >= silent * 0.8 && elapsed <= silent + 4000, `${elapsed} ms`); + } + + // ---------- §3.5 bye ---------- + console.log('\n[7] bye → 服务端 close(1000)(§3.5)'); + const b = await connect({ deviceId: 'dddd000000000004', device: 'Bye' }); + await b.next(3000); + b.ws.send(JSON.stringify({ type: 'bye', reason: 'user_stop' })); + const byeClose = await b.closed(4000); + check('服务端关闭了连接', byeClose !== null); + check('关闭码为 1000', byeClose?.code === 1000, String(byeClose?.code)); + + // ---------- §3.1 / §6 协议不匹配 ---------- + console.log('\n[8] 协议版本不匹配 → bye + close(1002)(§3.1 / §6)'); + const c = await connect({ deviceId: 'eeee000000000005', device: 'Old', protocol: 'opendance.pose.v0' }); + const byeMsg = await c.next(3000); + check('收到 bye', byeMsg?.type === 'bye', JSON.stringify(byeMsg)); + check('bye.reason 说明协议不匹配', String(byeMsg?.reason ?? '').includes('protocol_mismatch'), String(byeMsg?.reason)); + const mismatchClose = await c.closed(4000); + check('关闭码为 1002(协议错误)', mismatchClose?.code === 1002, String(mismatchClose?.code)); + + // ---------- 收尾 ---------- + console.log('\n[9] 收尾'); + a2.ws.close(1000, 'done'); + await a2.closed(2000); + await sleep(300); + + console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`); + if (failed > 0) { + console.log('失败项:'); + for (const f of failures) console.log(` - ${f}`); + process.exit(1); + } + console.log('全部通过 ✅'); + process.exit(0); +} + +main().catch((err) => { + console.error('✗ 客户端异常:', err?.message ?? err); + process.exit(2); +}); diff --git a/build/package-release.sh b/build/package-release.sh index fab6503..48b2079 100755 --- a/build/package-release.sh +++ b/build/package-release.sh @@ -179,5 +179,7 @@ echo "" echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后" echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。" echo "" -echo "下一步(文档 §7.5:每次发布都要有一条读产物本身的验收):" -echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP" +echo "下一步(文档 §7.5 / §7.6:发布前必须做的三条产物级验收):" +echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP # 产物自证" +echo " GODOT_BIN=/path/to/godot bash build/accept-ws.sh $ZIP # WebSocket 契约一致性" +echo " API_BASE=http://127.0.0.1:3000 GODOT_BIN=... bash build/accept-integration.sh $ZIP # 跨端(需已部署服务端)" diff --git a/project.yaml b/project.yaml index ad4a5fb..093674e 100644 --- a/project.yaml +++ b/project.yaml @@ -102,6 +102,10 @@ tasks: file: tasks/019-cross-end-integration.md scope: 跨端集成验收(包内客户端 × 真实 Web 服务端)+ 补 Motion.thumbnailUrl 契约字段 status: done + - id: T-WIN-020 + file: tasks/020-ws-contract-conformance.md + scope: WebSocket 契约一致性验收(Node 独立客户端 × 发布包内 PoseServer)+ 故意不合规的负例服务端 + status: done deliverable: artifact: opendance-windows-v0.2.3.zip diff --git a/tasks/020-ws-contract-conformance.md b/tasks/020-ws-contract-conformance.md new file mode 100644 index 0000000..65791f1 --- /dev/null +++ b/tasks/020-ws-contract-conformance.md @@ -0,0 +1,110 @@ +# 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 抖动 / 切网 / 进程被杀这些真实断线场景,仍需设备实测。