Files
opendance-windows/build/accept-ws.sh
T
zijunchiang e0ba4ea833 fix(ws): config 改为变更驱动(原每 2 秒无条件广播)+ 契约「可选/兜底」语义定向验收(v0.2.5)
一、缺陷:config 被当成心跳
_maybe_push_config() 原来每 CONFIG_PUSH_INTERVAL_MS(2 秒)无条件广播一次 config。
契约 §3.3 的语义是「运行中**调整**时下发」,不是心跳 —— 一次 30 分钟推流就是 900 条
没有任何信息量的消息;更糟的是它让「收到 config」无法被当作「配置变了」的信号。
实测 v0.2.4 产物:17 秒内收到 9 条。
修复:
  * 抽出 config_payload() 作为唯一载荷来源(同时用作「是否变化」的指纹);
  * hello 应答顺序固定为「先 welcome、再 config」(若靠周期 tick 补发,
    新对端第一条消息可能变成 config,只认握手应答的实现会困惑);
  * _maybe_push_config() 仅在载荷真的变化时广播,并用 CONFIG_PUSH_INTERVAL_MS 去抖;
  * 显式 broadcast_config()(UI 改设置)同步更新指纹,避免下一 tick 重发。
实测 v0.2.5:17 秒内 1 条。

二、契约「可选/兜底」语义的定向验收(沿用 Android 侧的教训:
   契约里可选的字段必须用**不提供**它的对端来测,否则等于没测)
新增 3 个场景(客户端断言 30 → 40 项):
  * v0.1.0 风格对端:只发二进制帧(每 500ms)、从不 ping/stats、hello 不带 deviceId,
    持续 17 秒(> 静默超时 15 秒)→ 连接必须保持(契约 §1.1 的向后兼容承诺);
  * 两条都不带 deviceId 的连接同时在线 → 不做去重、按连接计数(§5),
    device_replaced 只应有那一次真正重复 deviceId 的事件;
  * 完全不发 hello、只发 5 帧的对端 → 不断开、不崩、帧仍计入。

三、验收脚本自身修掉两处误判
  * 「跨连接的 frame_id 回退」被当成缺陷:frame_id 是**每条连接各自从 0 递增**,
    新连接重新开始完全合规 —— 指标用错了。已改名并标注「仅参考」,
    真正相关的判据是逐连接的 frames_dropped == 3;
  * 「静默超时次数」被当成固定值:套件里本来就有"完全不发数据"的对端,
    空闲的替换连接也会被正常超时。已改为**定向断言**:由 hello 记录建立
    client_id → device 映射(探针新增 hellos[]),只检查 v0.1.0 风格对端没被误踢。

四、测试客户端改为顺序无关
固定「welcome 之后紧跟 config」后,"第一条消息就是 welcome"的假设立刻暴露。
契约没规定消息顺序,客户端必须顺序无关 —— 已加 nextOfType(type) 按类型取消息
并跳过其它类型(同时计入 counts)。

实测(v0.2.5 发布包,四条验收全绿):
  * accept-release.sh:pck 无开发残留 / PE 版本资源 0.2.5 / pck 启动 180 帧 / 运行时探针
  * accept-ws.sh:客户端 40 通过 / 0 失败 + 服务端侧全部事实
    (10 次连接、111 帧、frames_dropped=3、malformed=1、device_replaced 仅 1 次、
     v0.1.0 风格对端未被静默超时踢掉)
  * accept-frame-vectors.sh:解码 12/12、396 点 0 差异、编码器 0 差异
  * verify.sh:785 通过 / 0 失败
2026-09-18 02:47:00 +08:00

206 lines
10 KiB
Bash
Executable File
Raw 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.
#!/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 >= 10, `收到 ${r.connected.length} 次连接(含 v0.1.0 风格、无 deviceId、无 hello 的对端)`);
say(r.framesReceived >= 100, `收到 ${r.framesReceived} 个关键点帧(基础 72 + v0.1.0 风格对端 34 + 无 hello 对端 5)`);
say(r.lastFrameId >= 74, `最大 frame_id = ${r.lastFrameId}`);
// frame_id 是「每条连接各自从 0 递增」,新连接重新从 0 开始是合规的,
// 因此这里只打印跨连接计数作参考,不用它做判定(第一版误当成缺陷)。
console.log(` · 跨连接 frame_id 回退计数 ${r.frameIdRegressionsAcrossConnections ?? "?"}(仅参考)`);
// 客户端故意跳号 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.length === 1
&& r.deviceReplaced[0] === "aaaa000000000001",
`device_replaced 只应发生一次且只针对重复的 deviceId(实际 ${JSON.stringify(r.deviceReplaced)}):`
+ "契约 §5 要求缺 deviceId 的连接**不做去重**、按连接计数");
// 断开原因:替换(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) {
// 只有"专门完全不发数据"的那条连接该被静默超时踢掉。
// v0.1.0 风格的对端只发二进制帧、从不 ping —— 契约 §1.1 明确"每帧同样算活动",
// 若它也被踢掉,静默超时计数就会 >1(这是"契约可选字段/兜底路径"最容易漏的一处)。
// **定向**断言:契约 §1.1 承诺"只发帧、不发 ping 的 v0.1.0 风格对端也能正常互通"。
// 报告里的断开原因没有设备名,因此靠 hello 记录建立 client_id → device 映射,
// 再检查该连接有没有被静默超时踢掉(全局计数会混入"空闲的连接"这类正常超时)。
const legacyIds = (r.hellos ?? []).filter((h) => String(h.device).includes("Legacy")).map((h) => h.id);
say(legacyIds.length === 1, `报告里应能识别出 v0.1.0 风格对端(client_id=${JSON.stringify(legacyIds)})`);
const legacyTimedOut = r.disconnected.some(
(x) => legacyIds.includes(x.id) && String(x.reason).includes("静默超时"),
);
say(!legacyTimedOut,
"只发二进制帧、从不 ping 的对端**没有**被静默超时踢掉(契约 §1.1:每帧同样算活动)");
const silentTimeouts = r.disconnected.filter((x) => String(x.reason).includes("静默超时")).length;
console.log(` · 静默超时共 ${silentTimeouts} 次(含专门"完全不发数据"的对端与空闲连接,属预期)`);
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 "============================================================"