一、缺陷: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 失败
190 lines
6.4 KiB
GDScript
190 lines
6.4 KiB
GDScript
extends SceneTree
|
||
|
||
## WebSocket 契约一致性探针(T-WIN-020)
|
||
##
|
||
## 用法(由 build/accept-ws.sh 调用,也可手工执行):
|
||
## godot --headless --main-pack <包内 OpenDance.pck> \
|
||
## --script /绝对路径/build/integration/probe-ws.gd \
|
||
## -- --port 18765 --duration-ms 60000 --report /tmp/ws-report.json
|
||
##
|
||
## 为什么需要它:`tests/test_pose_server.gd` 用的是**同引擎内的客户端**
|
||
## (Godot 自己写的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。
|
||
## 同引擎内的自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义),
|
||
## 因此**必须有一个独立实现的客户端**来对照 —— 这就是本探针 + Node 客户端的组合:
|
||
## * 本探针:把**发布包里的** PoseServer 跑起来(只加载包内代码),
|
||
## 把收到的连接/控制/帧/断开事件汇总成 JSON 报告;
|
||
## * `build/integration/ws-client.mjs`:Node 端按契约 v0.2.0 逐条跑场景并断言。
|
||
##
|
||
## 探针自身不做判定(只收集事实),判定在 Node 客户端与 accept-ws.sh 里,避免
|
||
## 「用被测实现来判定被测实现」。
|
||
|
||
const DEFAULT_PORT := 18765
|
||
const DEFAULT_DURATION_MS := 60_000
|
||
|
||
var _server: Node = null
|
||
var _started := false
|
||
var _done := false
|
||
var _start_ms := 0
|
||
var _port := DEFAULT_PORT
|
||
var _duration_ms := DEFAULT_DURATION_MS
|
||
var _report_path := "/tmp/opendance-ws-report.json"
|
||
## 可选:客户端跑完后 touch 这个文件,探针提前收尾(比固定时长更快、更稳)
|
||
var _stop_file := ""
|
||
|
||
var _events: Array = []
|
||
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
|
||
var _first_frame_ms := 0
|
||
var _last_frame_ms := 0
|
||
|
||
|
||
func _initialize() -> void:
|
||
var args := OS.get_cmdline_user_args()
|
||
var i := 0
|
||
while i < args.size():
|
||
match args[i]:
|
||
"--port":
|
||
_port = int(args[i + 1]); i += 2
|
||
"--duration-ms":
|
||
_duration_ms = int(args[i + 1]); i += 2
|
||
"--report":
|
||
_report_path = args[i + 1]; i += 2
|
||
"--stop-file":
|
||
_stop_file = args[i + 1]; i += 2
|
||
_:
|
||
i += 1
|
||
|
||
|
||
func _process(_delta: float) -> bool:
|
||
if not _started:
|
||
_started = true
|
||
_start_ms = Time.get_ticks_msec()
|
||
_start_server()
|
||
if _done:
|
||
return true
|
||
var elapsed := Time.get_ticks_msec() - _start_ms
|
||
if elapsed >= _duration_ms:
|
||
_finish()
|
||
elif _stop_file != "" and FileAccess.file_exists(_stop_file):
|
||
_finish()
|
||
return _done
|
||
|
||
|
||
func _start_server() -> void:
|
||
var script: GDScript = load("res://src/net/pose_server.gd")
|
||
if script == null:
|
||
print("[ws] ✗ 无法加载 res://src/net/pose_server.gd")
|
||
_finish(2)
|
||
return
|
||
_server = script.new()
|
||
_server.name = "ProbePoseServer"
|
||
root.add_child(_server)
|
||
|
||
_server.listening.connect(_on_listening)
|
||
_server.listen_failed.connect(_on_listen_failed)
|
||
_server.client_connected.connect(_on_client_connected)
|
||
_server.client_disconnected.connect(_on_client_disconnected)
|
||
_server.device_replaced.connect(_on_device_replaced)
|
||
_server.frame_received.connect(_on_frame_received)
|
||
_server.control_received.connect(_on_control_received)
|
||
|
||
var err: int = _server.listen("127.0.0.1", _port)
|
||
if err != OK:
|
||
print("[ws] ✗ listen(%d) 失败:%s" % [_port, error_string(err)])
|
||
_finish(2)
|
||
|
||
|
||
func _on_listening(port: int) -> void:
|
||
# accept-ws.sh 靠这一行判断「服务端已就绪」
|
||
print("[ws] listening on 127.0.0.1:%d(契约 §1 端点)" % port)
|
||
|
||
|
||
func _on_listen_failed(port: int, reason: String) -> void:
|
||
print("[ws] ✗ 监听 %d 失败:%s" % [port, reason])
|
||
|
||
|
||
func _now_ms() -> int:
|
||
return Time.get_ticks_msec() - _start_ms
|
||
|
||
|
||
func _on_client_connected(client_id: int, device: String) -> void:
|
||
_connected.append({"id": client_id, "device": device, "atMs": _now_ms()})
|
||
_events.append({"atMs": _now_ms(), "kind": "connected", "id": client_id, "device": device})
|
||
|
||
|
||
func _on_client_disconnected(client_id: int, reason: String) -> void:
|
||
_disconnected.append({"id": client_id, "reason": reason, "atMs": _now_ms()})
|
||
_events.append({"atMs": _now_ms(), "kind": "disconnected", "id": client_id, "reason": reason})
|
||
|
||
|
||
func _on_device_replaced(device_id: String) -> void:
|
||
_replaced.append(device_id)
|
||
_events.append({"atMs": _now_ms(), "kind": "device_replaced", "deviceId": device_id})
|
||
|
||
|
||
func _on_frame_received(frame) -> void:
|
||
_frames += 1
|
||
if _first_frame_ms == 0:
|
||
_first_frame_ms = _now_ms()
|
||
_last_frame_ms = _now_ms()
|
||
if _last_frame_id >= 0 and frame.frame_id <= _last_frame_id:
|
||
_frame_id_regressions += 1
|
||
_last_frame_id = frame.frame_id
|
||
|
||
|
||
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:
|
||
if _done:
|
||
return
|
||
_done = true
|
||
var report := {
|
||
"port": _port,
|
||
"durationMs": _now_ms(),
|
||
"server": _server.snapshot() if _server != null else {},
|
||
"connected": _connected,
|
||
"disconnected": _disconnected,
|
||
"deviceReplaced": _replaced,
|
||
"controls": _controls,
|
||
"hellos": _hellos,
|
||
"framesReceived": _frames,
|
||
"lastFrameId": _last_frame_id,
|
||
# 注意:这是**跨连接**的全局计数。frame_id 是「每条连接各自从 0 单调递增」,
|
||
# 新连接重新从 0 开始完全合规,因此该字段只作参考,不能用来判定丢帧/回退。
|
||
"frameIdRegressionsAcrossConnections": _frame_id_regressions,
|
||
"firstFrameAtMs": _first_frame_ms,
|
||
"lastFrameAtMs": _last_frame_ms,
|
||
"events": _events,
|
||
}
|
||
if _server != null:
|
||
_server.stop()
|
||
var file := FileAccess.open(_report_path, FileAccess.WRITE)
|
||
if file == null:
|
||
print("[ws] ✗ 无法写入报告 %s" % _report_path)
|
||
else:
|
||
file.store_string(JSON.stringify(report, " "))
|
||
file.close()
|
||
print("[ws] 报告已写入 %s(连接 %d、帧 %d、控制帧 %s、替换 %d)" % [
|
||
_report_path, _connected.size(), _frames, JSON.stringify(_controls), _replaced.size()])
|
||
quit(code)
|