fix(gateway): pool-route voice RPCs so STT lazy install can't block WS sends
voice.toggle (status) triggers check_voice_requirements() -> STT provider auto-detect -> a synchronous faster-whisper lazy install (uv/pip subprocess with a 300s timeout). Inline on the WS reader thread it stalls handle_ws before it reads the next frame, so prompt.submit / session.list queued behind a voice.toggle sit unread and the desktop 'send message' appears dead for minutes while the install churns (reproduced: voice.toggle -> session.list 40s+ timeout). Route voice.toggle/voice.record/voice.tts to the RPC pool (same bug class as #21123 / #50005) so a slow lazy install can't block message handling. Adapt the voice handler tests to drive the handler inline via a small _dispatch_sync helper (preserving transport-binding semantics) since dispatch() now returns None for pool-routed methods, and add a regression test asserting the voice RPCs stay pool-routed.
This commit is contained in:
parent
2ffed55c32
commit
6a9d2dc2f3
|
|
@ -16,6 +16,24 @@ from hermes_cli.active_sessions import active_session_registry_snapshot
|
|||
from hermes_cli.browser_connect import ChromeDebugLaunch
|
||||
from tools import async_delegation as ad
|
||||
from tui_gateway import server
|
||||
from tui_gateway.transport import bind_transport, reset_transport
|
||||
|
||||
|
||||
def _dispatch_sync(req: dict, transport=None) -> dict | None:
|
||||
"""Run one RPC to completion synchronously, regardless of pool routing.
|
||||
|
||||
Voice RPCs (voice.toggle/record/tts) are in ``_LONG_HANDLERS`` so they run
|
||||
on the RPC pool and ``server.dispatch`` returns None — the pool worker
|
||||
writes the response via the bound transport. These tests exercise the
|
||||
handler's business logic, not the routing, so they drive the handler
|
||||
inline while preserving the transport-binding semantics ``dispatch``
|
||||
applies around a real request.
|
||||
"""
|
||||
token = bind_transport(transport)
|
||||
try:
|
||||
return server.handle_request(req)
|
||||
finally:
|
||||
reset_transport(token)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -1234,10 +1252,10 @@ def test_voice_toggle_returns_configured_record_key(monkeypatch):
|
|||
# review on #19835).
|
||||
monkeypatch.setenv("HERMES_VOICE", "0")
|
||||
|
||||
on_resp = server.dispatch(
|
||||
on_resp = _dispatch_sync(
|
||||
{"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}}
|
||||
)
|
||||
status_resp = server.dispatch(
|
||||
status_resp = _dispatch_sync(
|
||||
{"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}}
|
||||
)
|
||||
|
||||
|
|
@ -1260,7 +1278,7 @@ def test_voice_toggle_on_carries_stop_hint(monkeypatch):
|
|||
)
|
||||
monkeypatch.setenv("HERMES_VOICE", "0")
|
||||
|
||||
on_resp = server.dispatch(
|
||||
on_resp = _dispatch_sync(
|
||||
{"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}}
|
||||
)
|
||||
assert on_resp["result"]["stop_hint"] == 'Say "halt" to end the voice chat.'
|
||||
|
|
@ -1274,13 +1292,13 @@ def test_voice_toggle_on_carries_stop_hint(monkeypatch):
|
|||
voice_stop_hint=lambda: "",
|
||||
),
|
||||
)
|
||||
on_resp = server.dispatch(
|
||||
on_resp = _dispatch_sync(
|
||||
{"id": "voice-on2", "method": "voice.toggle", "params": {"action": "on"}}
|
||||
)
|
||||
assert on_resp["result"]["stop_hint"] == ""
|
||||
|
||||
# off carries no hint text (mode is ending).
|
||||
off_resp = server.dispatch(
|
||||
off_resp = _dispatch_sync(
|
||||
{"id": "voice-off", "method": "voice.toggle", "params": {"action": "off"}}
|
||||
)
|
||||
assert off_resp["result"]["stop_hint"] == ""
|
||||
|
|
@ -1306,7 +1324,7 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
|
|||
for bad in (True, "cmd+b", None, 42, ["ctrl+b"]):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
|
||||
|
||||
status_resp = server.dispatch(
|
||||
status_resp = _dispatch_sync(
|
||||
{
|
||||
"id": "voice-status",
|
||||
"method": "voice.toggle",
|
||||
|
|
@ -1325,7 +1343,7 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
|
|||
for bad_root in (True, None, [], "ctrl+b", 42):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda r=bad_root: r)
|
||||
|
||||
status_resp = server.dispatch(
|
||||
status_resp = _dispatch_sync(
|
||||
{
|
||||
"id": "voice-status-root",
|
||||
"method": "voice.toggle",
|
||||
|
|
@ -1366,7 +1384,7 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
|
|||
captured.clear()
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
|
||||
|
||||
resp = server.dispatch(
|
||||
resp = _dispatch_sync(
|
||||
{
|
||||
"id": "voice-record",
|
||||
"method": "voice.record",
|
||||
|
|
@ -1395,7 +1413,7 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
|
|||
captured.clear()
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda c=bad_bool_cfg: {"voice": c})
|
||||
|
||||
resp = server.dispatch(
|
||||
resp = _dispatch_sync(
|
||||
{
|
||||
"id": "voice-record-bool",
|
||||
"method": "voice.record",
|
||||
|
|
@ -1609,7 +1627,7 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc
|
|||
"method": "wake.stop",
|
||||
"params": {},
|
||||
}, transport=second)
|
||||
denied_voice_stop = server.dispatch({
|
||||
denied_voice_stop = _dispatch_sync({
|
||||
"id": "voice-stop-2",
|
||||
"method": "voice.record",
|
||||
"params": {"action": "stop"},
|
||||
|
|
@ -1640,7 +1658,7 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc
|
|||
)]
|
||||
assert state["paused"] is True
|
||||
|
||||
voice_started = server.dispatch({
|
||||
voice_started = _dispatch_sync({
|
||||
"id": "voice-start-1",
|
||||
"method": "voice.record",
|
||||
"params": {"action": "start", "session_id": "first-session"},
|
||||
|
|
@ -1863,7 +1881,7 @@ def test_voice_record_start_forwards_max_recording_seconds(monkeypatch):
|
|||
captured.clear()
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda c=cfg: {"voice": c})
|
||||
|
||||
resp = server.dispatch(
|
||||
resp = _dispatch_sync(
|
||||
{
|
||||
"id": "voice-record-cap",
|
||||
"method": "voice.record",
|
||||
|
|
@ -1893,7 +1911,7 @@ def test_voice_record_stop_forces_transcription(monkeypatch):
|
|||
),
|
||||
)
|
||||
|
||||
resp = server.dispatch(
|
||||
resp = _dispatch_sync(
|
||||
{
|
||||
"id": "voice-record-stop",
|
||||
"method": "voice.record",
|
||||
|
|
@ -1916,7 +1934,7 @@ def test_voice_record_stop_updates_event_session_id(monkeypatch):
|
|||
)
|
||||
monkeypatch.setattr(server, "_voice_event_sid", "old-session")
|
||||
|
||||
resp = server.dispatch(
|
||||
resp = _dispatch_sync(
|
||||
{
|
||||
"id": "voice-record-stop-session",
|
||||
"method": "voice.record",
|
||||
|
|
@ -1940,7 +1958,7 @@ def test_voice_record_start_reports_busy_when_stop_is_in_progress(monkeypatch):
|
|||
monkeypatch.setenv("HERMES_VOICE", "1")
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}})
|
||||
|
||||
resp = server.dispatch(
|
||||
resp = _dispatch_sync(
|
||||
{
|
||||
"id": "voice-record-busy",
|
||||
"method": "voice.record",
|
||||
|
|
@ -1978,7 +1996,7 @@ def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch):
|
|||
# later test in the file (which now spins up the streaming TTS pipeline).
|
||||
monkeypatch.setenv("HERMES_VOICE_TTS", "0")
|
||||
|
||||
tts_resp = server.dispatch(
|
||||
tts_resp = _dispatch_sync(
|
||||
{"id": "voice-tts", "method": "voice.toggle", "params": {"action": "tts"}}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -658,6 +658,20 @@ def test_completion_handlers_are_pool_routed(completion_method, server):
|
|||
assert completion_method in server._LONG_HANDLERS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("voice_method", ["voice.toggle", "voice.record", "voice.tts"])
|
||||
def test_voice_handlers_are_pool_routed(voice_method, server):
|
||||
"""Voice RPCs must run on the pool, never the WS reader thread.
|
||||
|
||||
Regression: voice.toggle (status) triggers check_voice_requirements() →
|
||||
STT provider auto-detect → a SYNCHRONOUS faster-whisper lazy install (uv/pip
|
||||
subprocess, up to a 300s timeout). Inline on the WS reader loop it blocked
|
||||
prompt.submit / session.list frames queued behind it — the desktop showed
|
||||
sent messages that never reached the agent. Same bug class as #21123 /
|
||||
#50005: anything that can stall for seconds must stay off the reader thread.
|
||||
"""
|
||||
assert voice_method in server._LONG_HANDLERS
|
||||
|
||||
|
||||
def test_skin_live_switch_end_to_end(server, tmp_path, monkeypatch):
|
||||
"""Real config + skin files: activating a skin (as `hermes config set` does)
|
||||
makes the per-tool reconcile broadcast skin.changed with the resolved palette.
|
||||
|
|
|
|||
|
|
@ -274,6 +274,17 @@ _LONG_HANDLERS = frozenset(
|
|||
# the WS read loop and causing false "needs setup" (#50005 family).
|
||||
"setup.runtime_check",
|
||||
"setup.status",
|
||||
# Voice RPCs can trigger check_voice_requirements() → STT provider
|
||||
# auto-detect → a SYNCHRONOUS faster-whisper lazy install (uv/pip
|
||||
# subprocess with a 300s timeout). Inline they stall the WS reader
|
||||
# loop (handle_ws awaits dispatch before reading the next frame), so
|
||||
# prompt.submit / session.list queued behind a voice.toggle sit
|
||||
# unread and the desktop "send message" appears dead for minutes
|
||||
# (reproduced: voice.toggle → session.list 40s+ timeout). Route them
|
||||
# to the pool so a slow lazy install can't block message handling.
|
||||
"voice.toggle",
|
||||
"voice.record",
|
||||
"voice.tts",
|
||||
# Desktop also polls the in-memory live-session registry every 15s.
|
||||
# The handler is normally cheap, but under heavy agent GIL pressure it
|
||||
# can still stall for tens of seconds. Keep it off the WS reader thread
|
||||
|
|
|
|||
Loading…
Reference in New Issue