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:
hustwkr
2026-08-13 17:27:58 +08:00
committed by kshitij
parent 2ffed55c32
commit 6a9d2dc2f3
3 changed files with 59 additions and 16 deletions
+34 -16
View File
@@ -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"}}
)
+14
View File
@@ -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.
+11
View File
@@ -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