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」
|
||||
// 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。
|
||||
|
||||
Reference in New Issue
Block a user