Files
xiaozhi-esp32-server/benchmark/check_huoshan_3440.py
T
hrz ddc441bc59 补 huoshan_double_stream 4 bug 复现脚本
四个独立脚本各覆盖一个 issue:start_session 等 future 确认、句尾残留与编码器
冲刷、monitor snapshot 隔离、parser_response 四处协议解析。共享 _huoshan_mock
构造一个绕开父类 init 的假 provider 实例。
2026-10-17 07:39:51 +08:00

167 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PR-issue 3440 monitor snapshot 隔离 + 旧会话事件不影响当前回归测试。
复现:
1. 旧会话结束事件(SessionCanceled/Failed/Finished)即使 sessionId 不匹配也会
reset 当前 activate_session,破坏 start_session 的"上个会话激活时关闭
新建"判断
2. monitor 循环直接用 self.ws.recv(),连接被重建后旧任务读新连接下行;
旧任务因读旧句柄异常退出时又会把新连接误关
修复:
- monitor 入口绑定 monitor_ws = self.ws / monitor_task = current_task();
recv 用 monitor_ws;finally 清理判 id(self.ws) is id(monitor_ws) 才关
- 旧 session 事件 continue 即可,不再 reset activate_session
Usage:
python benchmark/check_huoshan_3440.py
"""
from __future__ import annotations
import asyncio
import importlib.util
import os
import sys
from unittest.mock import AsyncMock, MagicMock
import websockets
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load_mock():
spec = importlib.util.spec_from_file_location(
"_huoshan_mock", os.path.join(os.path.dirname(__file__), "_huoshan_mock.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
_mock = _load_mock()
build_provider = _mock.build_provider
run = _mock.run
FULL_SERVER_RESPONSE = 0b1001
MsgTypeFlagWithEvent = 0b100
EVENT_SessionFinished = 152
EVENT_SessionCanceled = 151
def _build_session_event_frame(event: int, session_id: str, meta: str = "{}") -> bytes:
res = bytearray()
res.append((0b0001 << 4) | 1)
res.append((FULL_SERVER_RESPONSE << 4) | MsgTypeFlagWithEvent)
res.append((0b0001 << 4) | 0)
res.append(0)
res.extend(event.to_bytes(4, "big", signed=True))
enc = session_id.encode("utf-8")
res.extend(len(enc).to_bytes(4, "big", signed=True))
res.extend(enc)
meta_bytes = meta.encode("utf-8")
res.extend(len(meta_bytes).to_bytes(4, "big", signed=True))
res.extend(meta_bytes)
return bytes(res)
def _async_iter_mock(items):
m = AsyncMock()
it = iter(items)
async def _next(*a, **k):
try:
v = next(it)
except StopIteration:
raise websockets.ConnectionClosed(None, None)
if isinstance(v, Exception):
raise v
return v
m.side_effect = _next
return m
def test_old_session_finished_does_not_reset_activate():
"""旧 session 的 SessionFinished 不应 reset 当前 activate_session。"""
p = build_provider()
p.activate_session = True
p.ws.recv = _async_iter_mock(
[_build_session_event_frame(EVENT_SessionFinished, "OLD-sid-not-mine")]
)
run(p._start_monitor_tts_response())
assert p.activate_session is True, (
f"旧 session 结束事件不应 reset 当前 activate_session,实际 {p.activate_session}"
)
print("OK: 旧 session SessionFinished 不 reset 当前 activate_session")
def test_old_session_canceled_does_not_reset_activate():
p = build_provider()
p.activate_session = True
p.ws.recv = _async_iter_mock(
[_build_session_event_frame(EVENT_SessionCanceled, "OLD-sid-not-mine")]
)
run(p._start_monitor_tts_response())
assert p.activate_session is True
print("OK: 旧 session SessionCanceled 不 reset 当前 activate_session")
def test_monitor_uses_snapshot_ws():
"""monitor 入口绑定 monitor_ws,recv 用 monitor_ws,外部换 self.ws 不影响。"""
p = build_provider()
# 把 self.ws 与 mock monitor 内部用的 ws 解耦
calls = []
async def _recv_log(*a, **k):
calls.append("recv_called")
raise websockets.ConnectionClosed(None, None)
ws_old = MagicMock()
ws_old.recv = _async_iter_mock([websockets.ConnectionClosed(None, None)])
ws_old.close = AsyncMock(side_effect=lambda: calls.append("old_close"))
ws_new = MagicMock()
ws_new.close = AsyncMock(side_effect=lambda: calls.append("new_close_should_not_appear"))
p.ws = ws_old
p._monitor_task = None # 防 finally 比较
async def _drive_then_swap():
# 立即把 p.ws 换到 ws_new,模拟重建连接
async def _swap():
await asyncio.sleep(0.01)
p.ws = ws_new
await asyncio.gather(p._start_monitor_tts_response(), _swap())
run(_drive_then_swap())
assert "new_close_should_not_appear" not in calls, (
f"旧 monitor 不应误关新连接;calls={calls}"
)
print(f"OK: 旧 monitor 用 snapshot 隔离,未误关新连接(calls={calls})")
def test_source_has_snapshot_check():
"""静态检查:_start_monitor_tts_response 入口有 monitor_ws = self.ws,
finally 清理有 self.ws is monitor_ws 判断。"""
src = open(
os.path.join(REPO, "main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py")
).read()
assert "monitor_ws = self.ws" in src, "monitor 入口应绑定 monitor_ws = self.ws"
assert "if self.ws is monitor_ws" in src, "finally 清理应判 self.ws is monitor_ws 才关"
assert "if self._monitor_task is monitor_task" in src, (
"finally 清理应判 self._monitor_task is monitor_task 才清空引用"
)
print("OK: monitor 入口绑定 snapshot,finally 清理有 id 比较")
def main() -> int:
test_old_session_finished_does_not_reset_activate()
test_old_session_canceled_does_not_reset_activate()
test_monitor_uses_snapshot_ws()
test_source_has_snapshot_check()
print("\nALL PASS: 3440 monitor snapshot 隔离 + 旧会话事件不影响当前(修复点 3)")
return 0
if __name__ == "__main__":
sys.exit(main())