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

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"}}
)

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.

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