一、缺陷: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 失败
206 lines
10 KiB
Bash
Executable File
206 lines
10 KiB
Bash
Executable File
#!/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 "============================================================"
|