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
+40 -12
View File
@@ -76,6 +76,8 @@ var _next_client_id: int = 1
var _port: int = 0
var _running: bool = false
var _last_config_push_ms: int = 0
## 最近一次广播出去的配置指纹(用于「配置是否变化」判断)
var _last_config_signature: String = ""
## 见 DEGRADED_HOLD_MS;运行期可调(测试用)
var degraded_hold_ms: int = DEGRADED_HOLD_MS
@@ -372,7 +374,11 @@ func _handle_control(entry: Dictionary, text: String) -> void:
if message.has("targetFps"):
expected_fps = int(message["targetFps"])
entry["state"] = ClientState.STREAMING if int(entry["first_frame_at_ms"]) > 0 else ClientState.HANDSHAKING
# 顺序固定:先 welcome(对 hello 的应答),再 config(当前镜像/帧率/保活参数)。
# 也可以等周期 tick 广播,但那样"新连接的第一条消息"可能变成 config,
# 让只认"握手应答"的实现困惑 —— 契约没规定顺序,但顺序自然更好排障。
_send_json(entry, welcome_payload())
_send_json(entry, config_payload())
"ping":
# 契约 §3.6:应用层心跳(协议层 ping 对应用层不可见,无法续命)
_send_json(entry, {
@@ -401,18 +407,9 @@ func welcome_payload() -> Dictionary:
}
func _maybe_push_config() -> void:
if CONFIG_PUSH_INTERVAL_MS <= 0:
return
var now := Time.get_ticks_msec()
if now - _last_config_push_ms < CONFIG_PUSH_INTERVAL_MS:
return
_last_config_push_ms = now
broadcast_config()
func broadcast_config() -> void:
var payload := {
## 契约 §3.3 的 config 载荷(同时用作「配置是否变化」的指纹来源)
func config_payload() -> Dictionary:
return {
"type": "config",
"mirror": mirror,
"targetFps": expected_fps,
@@ -422,6 +419,37 @@ func broadcast_config() -> void:
"silentTimeoutMs": CLIENT_TIMEOUT_MS,
"heartbeatIntervalMs": HEARTBEAT_INTERVAL_MS,
}
## 契约 §3.3 的语义是「**运行中调整**时下发」,不是心跳。
##
## 原实现每 `CONFIG_PUSH_INTERVAL_MS`(2 秒)**无条件**广播一次 config ——
## 对一次 30 分钟的推流是 900 条没有任何信息量的消息,也让「收到 config」
## 无法被当成「配置变了」的信号(发送端想据此做点什么就无从判断)。
## 现在改为:
## 1. 有新客户端完成握手 → 立刻补发一次(它需要知道镜像/目标帧率);
## 2. 之后仅当**载荷真的变化**才发,并用 `CONFIG_PUSH_INTERVAL_MS` 做去抖
## (连续拖动滑块时不会每个中间值都发一条)。
func _maybe_push_config() -> void:
if CONFIG_PUSH_INTERVAL_MS <= 0:
return
var now := Time.get_ticks_msec()
var signature := JSON.stringify(config_payload())
if signature == _last_config_signature:
return
if now - _last_config_push_ms < CONFIG_PUSH_INTERVAL_MS:
return
_last_config_signature = signature
_last_config_push_ms = now
broadcast_config()
func broadcast_config() -> void:
var payload := config_payload()
# 显式广播(UI 改了镜像/帧率等)也要更新指纹,否则下一个 tick 会再发一遍同样的内容
_last_config_signature = JSON.stringify(payload)
_last_config_push_ms = Time.get_ticks_msec()
for entry in _clients:
if entry["handshaken"]:
_send_json(entry, payload)