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:
2026-09-18 02:47:00 +08:00
parent 8faaa2a8a5
commit e0ba4ea833
10 changed files with 306 additions and 50 deletions
+67
View File
@@ -0,0 +1,67 @@
# T-WIN-024:config 改为变更驱动 + 契约「可选/兜底」语义的定向验收
- **状态**: done
- **依赖**: T-WIN-020(WS 契约一致性验收)
- **文档**: v1.6 §6.5 / §7.6.1
- **契约**: `websocket-keypoints 0.2.0` §1.1 / §3.1 / §3.3 / §5
## 一、缺陷:config 被当成心跳周期性广播
`_maybe_push_config()` 原来每 `CONFIG_PUSH_INTERVAL_MS`(2 秒)**无条件**广播一次 `config`:
* 契约 §3.3 的语义是「**运行中调整**时下发」,不是心跳。一次 30 分钟推流 = **900 条**
没有任何信息量的消息;
* 更糟的是它让「收到 config」无法被当作「配置变了」的信号 —— 发送端想据此做点什么
就无从判断。实测(v0.2.4 产物):17 秒内收到 **9 条** config。
**修复**:
1. 抽出 `config_payload()` 作为唯一载荷来源(同时用作「是否变化」的指纹);
2. `hello` 应答时**顺序固定**为「先 `welcome`、再 `config`」—— 契约没规定顺序,
但若让周期 tick 补发,新对端的第一条消息可能变成 `config`,只认握手应答的实现会困惑;
3. `_maybe_push_config()` 只在**载荷真的变化**时广播,并用 `CONFIG_PUSH_INTERVAL_MS`
做去抖(连续拖动滑块不会每个中间值都发一条);
4. 显式 `broadcast_config()`(UI 改镜像/帧率时调用)也同步更新指纹,避免下一 tick 重发。
实测(v0.2.5 产物):17 秒内 **1 条**。
## 二、契约「可选/兜底」语义的定向验收(新增 3 个场景)
上一轮 Android 侧的教训是「契约里**可选**的字段必须用**不提供**它的对端来测,
否则等于没测」(自家两端总是互相迎合)。这轮把它用到接收端:
| 场景 | 契约条款 | 断言 |
|--|--|--|
| **v0.1.0 风格对端**:只发二进制帧(每 500ms 一帧)、**从不发 `ping`/`stats`**、`hello` **不带 `deviceId`**,持续 17 秒(> 静默超时 15 秒) | §1.1「仅实现 v0.1.0 的一端仍可正常互通(它不发 ping、不读 silentTimeoutMs,但每帧与每秒 stats 已足够保活)」 | 连接**保持打开**;服务端**没有**把它静默超时踢掉(靠 hello 记录建立 `client_id → device` 映射后定向核对) |
| **两条都不带 `deviceId`** 的连接同时在线 | §5「`deviceId` 缺失或为空 → 不做去重,按连接计数」 | 两条都保持打开,`device_replaced` **只有**那次真正重复 `deviceId` 的事件 |
| **完全不发 `hello`**、只发 5 帧的对端 | 契约未规定(不该崩、不该断) | 服务端不断开、不崩,帧仍被计入 |
## 三、验收脚本自身修掉的两处误判
1. **把"跨连接的 frame_id 回退"当成缺陷**:`frame_id` 是**每条连接各自从 0 递增**,
新连接重新从 0 开始完全合规。探针原来做的是全局计数,属**指标用错**。
已改名为 `frameIdRegressionsAcrossConnections` 并标明「仅参考」,
不再作为判定依据(真正相关的判据是逐连接的 `frames_dropped == 3`)。
2. **把"静默超时次数"当成固定值**:套件里本来就有一条专门"完全不发数据"的对端,
另外那条空闲的替换连接也会被正常超时 —— 全局计数会混入这些**预期**超时。
已改为**定向断言**:先由 hello 记录找出 v0.1.0 风格对端的 `client_id`,
再检查它**没有**被静默超时踢掉。
## 四、测试客户端也要"顺序无关"
固定「先 welcome 再 config」之后,`ws-client.mjs` 里"取到的第一条消息就是 welcome"
的假设暴露了:接收端在 `welcome` 之后紧跟 `config`,断言就会拿到 config。
契约没有规定消息顺序,**客户端必须顺序无关** —— 已加 `nextOfType(type)`,
按类型取消息并跳过其它类型(同时计入 `counts`)。
## 五、实测
```
== 结果:40 通过 / 0 失败 == # 原 30 项,新增 10 项
✓ 只发帧(每 500ms 一帧、共 34 帧)持续 17.0s 后连接仍打开
✓ 17 秒内 config 条数应在合理范围(收到 1 条;周期广播会是 ~8 条)
✓ 报告里应能识别出 v0.1.0 风格对端(client_id=[7])
✓ 只发二进制帧、从不 ping 的对端没有被静默超时踢掉(契约 §1.1:每帧同样算活动)
✓ 第一条仍在打开(未被去重顶掉)/✓ 第二条仍在打开
✓ 一个控制帧都没发过
服务端侧:10 次连接 / 111 帧 / frames_dropped=3 / malformed_frames=1 / device_replaced 仅 1 次
```