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 失败
This commit is contained in:
+24
-5
@@ -136,10 +136,12 @@ const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (
|
||||
|
||||
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.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}`);
|
||||
say(r.frameIdRegressions === 0, `frame_id 无回退(回退 ${r.frameIdRegressions} 次)`);
|
||||
// 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 字节非法帧,服务端应计数且不崩
|
||||
@@ -149,14 +151,31 @@ 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)}`);
|
||||
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」
|
||||
// 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。
|
||||
|
||||
@@ -36,6 +36,10 @@ var _connected: Array = []
|
||||
var _disconnected: Array = []
|
||||
var _replaced: Array = []
|
||||
var _controls := {}
|
||||
## 每条连接的 hello 标识(client_id → {device, deviceId}),
|
||||
## 用来在报告核对阶段回答「某类对端有没有被误踢」——断开原因里没有设备名,
|
||||
## 只有 client_id 可用,所以必须在这里建立映射。
|
||||
var _hellos: Array = []
|
||||
var _frames := 0
|
||||
var _last_frame_id := -1
|
||||
var _frame_id_regressions := 0
|
||||
@@ -141,6 +145,13 @@ 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})
|
||||
if type == "hello":
|
||||
_hellos.append({
|
||||
"id": client_id,
|
||||
"device": str(message.get("device", "")),
|
||||
"deviceId": str(message.get("deviceId", "")),
|
||||
"atMs": _now_ms(),
|
||||
})
|
||||
|
||||
|
||||
func _finish(code: int = 0) -> void:
|
||||
@@ -155,9 +166,12 @@ func _finish(code: int = 0) -> void:
|
||||
"disconnected": _disconnected,
|
||||
"deviceReplaced": _replaced,
|
||||
"controls": _controls,
|
||||
"hellos": _hellos,
|
||||
"framesReceived": _frames,
|
||||
"lastFrameId": _last_frame_id,
|
||||
"frameIdRegressions": _frame_id_regressions,
|
||||
# 注意:这是**跨连接**的全局计数。frame_id 是「每条连接各自从 0 单调递增」,
|
||||
# 新连接重新从 0 开始完全合规,因此该字段只作参考,不能用来判定丢帧/回退。
|
||||
"frameIdRegressionsAcrossConnections": _frame_id_regressions,
|
||||
"firstFrameAtMs": _first_frame_ms,
|
||||
"lastFrameAtMs": _last_frame_ms,
|
||||
"events": _events,
|
||||
|
||||
+106
-13
@@ -83,16 +83,32 @@ function buildFrame({ frameId = 0, flags = 0, mirrored = false, lowConf = false,
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/** 打开一条连接,返回 { ws, next(type), closeInfo } */
|
||||
function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SUBPROTOCOL, hello = true } = {}) {
|
||||
/**
|
||||
* 打开一条连接,返回 { 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);
|
||||
@@ -128,14 +144,18 @@ function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SU
|
||||
opened
|
||||
.then(() => {
|
||||
if (hello) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello', protocol, deviceId, device,
|
||||
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);
|
||||
@@ -145,6 +165,22 @@ function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SU
|
||||
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();
|
||||
@@ -171,7 +207,7 @@ async function main() {
|
||||
// ---------- §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);
|
||||
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);
|
||||
@@ -205,7 +241,7 @@ async function main() {
|
||||
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);
|
||||
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,
|
||||
@@ -214,7 +250,7 @@ async function main() {
|
||||
// ---------- §5 设备去重 ----------
|
||||
console.log('\n[4] 同 deviceId 替换旧连接(§5)');
|
||||
const a2 = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
||||
const a2welcome = await a2.next(3000);
|
||||
const a2welcome = await a2.nextOfType('welcome');
|
||||
check('新连接收到 welcome', a2welcome?.type === 'welcome', JSON.stringify(a2welcome));
|
||||
const oldClose = await a.closed(4000);
|
||||
check('旧连接被服务端关闭', oldClose !== null, '旧连接仍在打开');
|
||||
@@ -229,7 +265,7 @@ async function main() {
|
||||
console.log(' · --skip-slow:跳过 19 秒对照');
|
||||
} else {
|
||||
const k = await connect({ deviceId: 'bbbb000000000002', device: 'Keepalive' });
|
||||
const kw = await k.next(3000);
|
||||
const kw = await k.nextOfType('welcome');
|
||||
check('保活连接已握手', kw?.type === 'welcome', JSON.stringify(kw));
|
||||
const deadline = Date.now() + (silent + 4000); // 超过静默超时 4 秒
|
||||
let pings = 0;
|
||||
@@ -252,7 +288,7 @@ async function main() {
|
||||
console.log(' · --skip-slow:跳过静默超时对照');
|
||||
} else {
|
||||
const s = await connect({ deviceId: 'cccc000000000003', device: 'Silent' });
|
||||
const sw = await s.next(3000);
|
||||
const sw = await s.nextOfType('welcome');
|
||||
check('静默连接已握手', sw?.type === 'welcome', JSON.stringify(sw));
|
||||
const t0 = Date.now();
|
||||
const closeInfo = await s.closed(silent + 6000);
|
||||
@@ -267,7 +303,7 @@ async function main() {
|
||||
// ---------- §3.5 bye ----------
|
||||
console.log('\n[7] bye → 服务端 close(1000)(§3.5)');
|
||||
const b = await connect({ deviceId: 'dddd000000000004', device: 'Bye' });
|
||||
await b.next(3000);
|
||||
await b.nextOfType('welcome');
|
||||
b.ws.send(JSON.stringify({ type: 'bye', reason: 'user_stop' }));
|
||||
const byeClose = await b.closed(4000);
|
||||
check('服务端关闭了连接', byeClose !== null);
|
||||
@@ -276,14 +312,71 @@ async function main() {
|
||||
// ---------- §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);
|
||||
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[9] 收尾');
|
||||
console.log('\n[12] 收尾');
|
||||
a2.ws.close(1000, 'done');
|
||||
await a2.closed(2000);
|
||||
await sleep(300);
|
||||
|
||||
Reference in New Issue
Block a user