mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 01:31:41 +00:00
fix: voice pipeline hardening — 7 bug fixes with tests
1. Anthropic + ElevenLabs TTS silence: forward full response to TTS callback for non-streaming providers (choices first, then native content blocks fallback). 2. Subprocess timeout kill: play_audio_file now kills the process on TimeoutExpired instead of leaving zombie processes. 3. Discord disconnect cleanup: leave all voice channels before closing the client to prevent leaked state. 4. Audio stream leak: close InputStream if stream.start() fails. 5. Race condition: read/write _on_silence_stop under lock in audio callback thread. 6. _vprint force=True: show API error, retry, and truncation messages even during streaming TTS. 7. _refresh_level lock: read _voice_recording under _voice_lock.
This commit is contained in:
parent
7a24168080
commit
eb34c0b09a
8 changed files with 317 additions and 10 deletions
|
|
@ -1194,3 +1194,40 @@ class TestVoiceStopAndTranscribeReal:
|
|||
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
||||
cli._voice_stop_and_transcribe()
|
||||
mock_tr.assert_called_once_with("/tmp/test.wav", model="whisper-large-v3")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bugfix: _refresh_level must read _voice_recording under lock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRefreshLevelLock:
|
||||
"""Bug: _refresh_level thread read _voice_recording without lock."""
|
||||
|
||||
def test_refresh_stops_when_recording_false(self):
|
||||
import threading, time
|
||||
|
||||
lock = threading.Lock()
|
||||
recording = True
|
||||
iterations = 0
|
||||
|
||||
def refresh_level():
|
||||
nonlocal iterations
|
||||
while True:
|
||||
with lock:
|
||||
still = recording
|
||||
if not still:
|
||||
break
|
||||
iterations += 1
|
||||
time.sleep(0.01)
|
||||
|
||||
t = threading.Thread(target=refresh_level, daemon=True)
|
||||
t.start()
|
||||
|
||||
time.sleep(0.05)
|
||||
with lock:
|
||||
recording = False
|
||||
|
||||
t.join(timeout=1)
|
||||
assert not t.is_alive(), "Refresh thread did not stop"
|
||||
assert iterations > 0, "Refresh thread never ran"
|
||||
|
|
|
|||
|
|
@ -866,3 +866,73 @@ class TestConfigurableSilenceParams:
|
|||
assert recorder._has_spoken is True
|
||||
|
||||
recorder.cancel()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bugfix regression tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestSubprocessTimeoutKill:
|
||||
"""Bug: proc.wait(timeout) raised TimeoutExpired but process was not killed."""
|
||||
|
||||
def test_timeout_kills_process(self):
|
||||
import subprocess, os
|
||||
proc = subprocess.Popen(["sleep", "600"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
pid = proc.pid
|
||||
assert proc.poll() is None
|
||||
|
||||
try:
|
||||
proc.wait(timeout=0.1)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
assert proc.poll() is not None
|
||||
assert proc.returncode is not None
|
||||
|
||||
|
||||
class TestStreamLeakOnStartFailure:
|
||||
"""Bug: stream.start() failure left stream unclosed."""
|
||||
|
||||
def test_stream_closed_on_start_failure(self, mock_sd):
|
||||
mock_stream = MagicMock()
|
||||
mock_stream.start.side_effect = OSError("Audio device busy")
|
||||
mock_sd.InputStream.return_value = mock_stream
|
||||
|
||||
from tools.voice_mode import AudioRecorder
|
||||
recorder = AudioRecorder()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to open audio input stream"):
|
||||
recorder._ensure_stream()
|
||||
|
||||
mock_stream.close.assert_called_once()
|
||||
|
||||
|
||||
class TestSilenceCallbackLock:
|
||||
"""Bug: _on_silence_stop was read/written without lock in audio callback."""
|
||||
|
||||
def test_fire_block_acquires_lock(self):
|
||||
import inspect
|
||||
from tools.voice_mode import AudioRecorder
|
||||
|
||||
source = inspect.getsource(AudioRecorder._ensure_stream)
|
||||
# Verify lock is used before reading _on_silence_stop in fire block
|
||||
assert "with self._lock:" in source
|
||||
assert "cb = self._on_silence_stop" in source
|
||||
lock_pos = source.index("with self._lock:")
|
||||
cb_pos = source.index("cb = self._on_silence_stop")
|
||||
assert lock_pos < cb_pos
|
||||
|
||||
def test_cancel_clears_callback_under_lock(self, mock_sd):
|
||||
from tools.voice_mode import AudioRecorder
|
||||
recorder = AudioRecorder()
|
||||
mock_sd.InputStream.return_value = MagicMock()
|
||||
|
||||
cb = lambda: None
|
||||
recorder.start(on_silence_stop=cb)
|
||||
assert recorder._on_silence_stop is cb
|
||||
|
||||
recorder.cancel()
|
||||
with recorder._lock:
|
||||
assert recorder._on_silence_stop is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue