From 4e8a66daceab56e9d2d76ad9abc8b65ea8fdd907 Mon Sep 17 00:00:00 2001 From: CleanDev-Fix <219162456+CleanDev-Fix@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:07:08 -0400 Subject: [PATCH] fix(tools): keep command TTS deadline through exit --- tests/tools/test_tts_command_providers.py | 26 +++++++++++++++++++++++ tools/tts_tool.py | 17 ++++++++------- website/docs/user-guide/features/tts.md | 2 +- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py index 34972f2c2d28..6f662bc4dc61 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/tests/tools/test_tts_command_providers.py @@ -396,6 +396,32 @@ class TestRunCommandTts: assert read_sizes["stdout"][0] == 65536 assert read_sizes["stderr"][0] == 65536 + def test_closed_pipes_still_running_honors_idle_timeout(self): + class ClosedStream: + def read(self, size: int) -> str: + return "" + + class FakeProcess: + def __init__(self): + self.pid = 12345 + self.returncode = None + self.stdout = ClosedStream() + self.stderr = ClosedStream() + + def wait(self, timeout=None): + if timeout is None: + self.returncode = 0 + return self.returncode + raise subprocess.TimeoutExpired("fake tts", timeout) + + process = FakeProcess() + with ( + patch("tools.tts_tool.subprocess.Popen", return_value=process), + patch("tools.tts_tool._terminate_command_tts_process_tree"), + ): + with pytest.raises(subprocess.TimeoutExpired): + _run_command_tts("fake tts", timeout=0.25) + def test_stderr_progress_extends_beyond_timeout(self, tmp_path): script = tmp_path / "progress_then_exit.py" script.write_text( diff --git a/tools/tts_tool.py b/tools/tts_tool.py index cb37b6b13d2a..cc2ec42c97a3 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -1026,17 +1026,13 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces def read_stream(name: str, stream: Any) -> None: encoding = getattr(stream, "encoding", None) or "utf-8" + read1 = getattr(getattr(stream, "buffer", None), "read1", None) try: while True: - try: - fd = stream.fileno() - except (AttributeError, OSError): - fd = None - - if fd is None: + if read1 is None: chunk = stream.read(65536) else: - data = os.read(fd, 65536) + data = read1(65536) chunk = data.decode(encoding, errors="replace") if not chunk: break @@ -1076,6 +1072,12 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces chunks[name].append(chunk) deadline = time.monotonic() + timeout + if not timed_out: + try: + proc.wait(timeout=max(0.0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + timed_out = True + if timed_out: _terminate_command_tts_process_tree(proc) for reader in readers: @@ -1101,7 +1103,6 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces stdout = "".join(chunks["stdout"]) stderr = "".join(chunks["stderr"]) - proc.wait() if proc.returncode: raise subprocess.CalledProcessError( diff --git a/website/docs/user-guide/features/tts.md b/website/docs/user-guide/features/tts.md index 89275dd94070..8c15602e6d5f 100644 --- a/website/docs/user-guide/features/tts.md +++ b/website/docs/user-guide/features/tts.md @@ -328,7 +328,7 @@ Use `{{` and `}}` for literal braces. | Key | Default | Meaning | |--------------------|---------|------------------------------------------------------------------------------------------------------------| -| `timeout` | `120` | Seconds; the process tree is killed on expiry (Unix `killpg`, Windows `taskkill /T`). | +| `timeout` | `120` | Idle seconds; stdout or stderr output resets the deadline. The process tree is killed after inactivity (Unix `killpg`, Windows `taskkill /T`). | | `output_format` | `mp3` | One of `mp3` / `wav` / `ogg` / `flac`. Auto-inferred from the output extension if Hermes picks a path. | | `voice_compatible` | `false` | When `true`, Hermes converts MP3/WAV output to Opus/OGG via ffmpeg so Telegram renders a voice bubble. | | `max_text_length` | `5000` | Input is truncated to this length before rendering the command. |