Files
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

398 lines
18 KiB
JavaScript
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.
#!/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, state, next(), closed(), counts }
*
* @param omitDeviceId 为 true 时 `hello` **不带 deviceId 字段**(契约 §3.1 标注为可选,
* §5 要求此时接收端"不做去重,按连接计数")
* @param hello 为 false 时连 `hello` 都不发(测接收端对"只发二进制帧"的对端是否宽容)
*/
function connect({
deviceId = '',
device = 'Node 契约客户端',
protocol = SUBPROTOCOL,
hello = true,
omitDeviceId = false,
} = {}) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(URL_, [SUBPROTOCOL]);
ws.binaryType = 'arraybuffer';
const inbox = [];
const waiters = [];
const state = { closeInfo: null, error: null };
/** 收到的消息类型计数(用于断言"config 不是周期性刷屏"这类事实) */
const counts = {};
const push = (msg) => {
const t = msg?.type ?? '(未知)';
counts[t] = (counts[t] ?? 0) + 1;
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) {
const payload = {
type: 'hello', protocol, device,
appVersion: '0.2.0', cameraFacing: 'front', targetFps: 30, resolution: '640x480',
};
// 契约 §3.1:deviceId 可选 —— 这里刻意支持"整个字段都不出现"的合规对端
if (!omitDeviceId) payload.deviceId = deviceId;
ws.send(JSON.stringify(payload));
}
resolve({
ws,
state,
counts,
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);
});
}),
/**
* 按**类型**取消息,跳过其它类型(并把它们计入 counts)。
* 契约没有规定消息顺序,客户端必须顺序无关 —— 尤其是接收端现在会在
* `welcome` 之后立刻补一条 `config`,"第一条消息是 welcome" 不再是保证。
*/
async nextOfType(type, timeoutMs = 3000) {
const skipped = [];
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const msg = await this.next(Math.max(50, deadline - Date.now()));
if (msg?.type === type) return msg;
skipped.push(msg?.type);
if (msg?.type === '(closed)') return msg;
}
return { type: '(超时)', expected: type, skipped };
},
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.nextOfType('welcome');
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.nextOfType('pong');
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.nextOfType('welcome');
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.nextOfType('welcome');
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.nextOfType('welcome');
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.nextOfType('welcome');
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.nextOfType('bye');
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));
// ---------- §1.1 向后兼容:v0.1.0 风格对端(只发帧,从不 ping)----------
console.log('\n[9] v0.1.0 风格对端:只发二进制帧、不发 ping / stats、hello 不带 deviceId(§1.1 / §3.1)');
if (SKIP_SLOW) {
console.log(' · --skip-slow:跳过 17 秒帧保活对照');
} else {
const legacy = await connect({ omitDeviceId: true, device: 'Legacy v0.1.0' });
const lw = await legacy.nextOfType('welcome');
check('v0.1.0 风格对端也收到 welcome', lw?.type === 'welcome', JSON.stringify(lw));
const legacyFrames = Math.ceil((silent + 2000) / 500); // 每 500ms 一帧
for (let i = 0; i < legacyFrames; i += 1) {
if (legacy.ws.readyState !== WebSocket.OPEN) break;
legacy.ws.send(buildFrame({ frameId: i }));
await sleep(500);
}
check(`只发帧(每 500ms 一帧、共 ${legacyFrames} 帧)持续 ${((silent + 2000) / 1000).toFixed(1)}s 后连接仍打开`,
legacy.ws.readyState === WebSocket.OPEN,
`readyState=${legacy.ws.readyState}(契约 §1.1:每帧都算活动,ping 只是兜底)`);
check('没有收到过 pong 也能正常推流(本场景从不发 ping)',
(legacy.counts.pong ?? 0) === 0, JSON.stringify(legacy.counts));
// §3.3 的语义是"运行中调整时下发",不是心跳:17 秒内不应出现周期性刷屏
const cfgCount = legacy.counts.config ?? 0;
check(`17 秒内 config 条数应在合理范围(收到 ${cfgCount} 条;周期广播会是 ~8 条)`,
cfgCount <= 3, `实际 ${cfgCount} 条 —— config 应为变更驱动,而非心跳`);
legacy.ws.close(1000, 'done');
await legacy.closed(2000);
}
// ---------- §5 缺 deviceId → 不做去重 ----------
console.log('\n[10] 两个都不带 deviceId 的连接不应互相顶掉(§5)');
const n1 = await connect({ omitDeviceId: true, device: 'No-Id A' });
const n1w = await n1.nextOfType('welcome');
check('第一条无 deviceId 连接握手成功', n1w?.type === 'welcome', JSON.stringify(n1w));
const n2 = await connect({ omitDeviceId: true, device: 'No-Id B' });
const n2w = await n2.nextOfType('welcome');
check('第二条无 deviceId 连接握手成功', n2w?.type === 'welcome', JSON.stringify(n2w));
await sleep(500);
check('第一条仍在打开(未被去重顶掉)', n1.ws.readyState === WebSocket.OPEN, String(n1.ws.readyState));
check('第二条仍在打开', n2.ws.readyState === WebSocket.OPEN, String(n2.ws.readyState));
n1.ws.close(1000, 'done');
n2.ws.close(1000, 'done');
await n1.closed(2000);
await n2.closed(2000);
// ---------- 不打招呼的对端(连 hello 都不发)----------
console.log('\n[11] 完全不发 hello、只发二进制帧的对端应被宽容对待');
const raw = await connect({ hello: false });
const rawFrames = 5;
for (let i = 0; i < rawFrames; i += 1) {
raw.ws.send(buildFrame({ frameId: 1000 + i }));
await sleep(30);
}
await sleep(300);
check('接收端没有因此断开连接', raw.ws.readyState === WebSocket.OPEN, String(raw.ws.readyState));
check('一个控制帧都没发过', Object.keys(raw.counts).length === 0, JSON.stringify(raw.counts));
raw.ws.close(1000, 'done');
await raw.closed(2000);
// ---------- 收尾 ----------
console.log('\n[12] 收尾');
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);
});