diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py index 616a88d7b88d..5bfe64125e78 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/tests/tools/test_tts_command_providers.py @@ -14,6 +14,8 @@ differences) Windows. import json import os +import shlex +import subprocess import sys from pathlib import Path from unittest.mock import patch @@ -37,6 +39,7 @@ from tools.tts_tool import ( _render_command_tts_template, _resolve_command_provider_config, _resolve_max_text_length, + _run_command_tts, _shell_quote_context, check_tts_requirements, text_to_speech_tool, @@ -57,6 +60,13 @@ def _python_copy_command(output_placeholder: str = "{output_path}") -> str: ) +def _shell_command(*args: str) -> str: + """Return a shell command string for subprocess.Popen(shell=True).""" + if os.name == "nt": + return subprocess.list2cmdline(list(args)) + return " ".join(shlex.quote(str(arg)) for arg in args) + + # --------------------------------------------------------------------------- # _resolve_command_provider_config / built-in precedence # --------------------------------------------------------------------------- @@ -348,6 +358,53 @@ class TestRenderCommandTtsTemplate: assert '"bob\'s voice"' in rendered +# --------------------------------------------------------------------------- +# _run_command_tts idle/progress timeout behavior +# --------------------------------------------------------------------------- + +class TestRunCommandTts: + def test_stderr_progress_extends_beyond_timeout(self, tmp_path): + script = tmp_path / "progress_then_exit.py" + script.write_text( + "\n".join([ + "import sys, time", + "for idx in range(4):", + " print(f'tick {idx}', file=sys.stderr, flush=True)", + " time.sleep(0.15)", + "print('done', flush=True)", + ]), + encoding="utf-8", + ) + + result = _run_command_tts( + _shell_command(sys.executable, "-u", str(script)), + timeout=0.25, + ) + + assert result.returncode == 0 + assert "tick 3" in result.stderr + assert "done" in result.stdout + + def test_silent_after_progress_still_times_out_with_stderr(self, tmp_path): + script = tmp_path / "progress_then_hang.py" + script.write_text( + "\n".join([ + "import sys, time", + "print('starting tier 1', file=sys.stderr, flush=True)", + "time.sleep(1.0)", + ]), + encoding="utf-8", + ) + + with pytest.raises(subprocess.TimeoutExpired) as excinfo: + _run_command_tts( + _shell_command(sys.executable, "-u", str(script)), + timeout=0.2, + ) + + assert "starting tier 1" in (excinfo.value.stderr or "") + + # --------------------------------------------------------------------------- # End-to-end: _generate_command_tts # --------------------------------------------------------------------------- diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 82dc5ad4f4d8..9a0c37d1393b 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -48,6 +48,7 @@ import shutil import subprocess import tempfile import threading +import time import uuid from dataclasses import dataclass, field from pathlib import Path @@ -999,7 +1000,7 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None: def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProcess: - """Run a command-provider shell command with process-tree timeout cleanup.""" + """Run a command-provider shell command with process-tree idle cleanup.""" from agent.delegation_context import delegated_child_subprocess_env popen_kwargs: Dict[str, Any] = { @@ -1019,21 +1020,75 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces popen_kwargs["start_new_session"] = True proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL) - try: - stdout, stderr = proc.communicate(timeout=timeout) - except subprocess.TimeoutExpired as exc: - _terminate_command_tts_process_tree(proc) + output_queue: "queue.Queue[tuple[str, Optional[str]]]" = queue.Queue() + chunks: Dict[str, list[str]] = {"stdout": [], "stderr": []} + open_streams = {"stdout", "stderr"} + + def read_stream(name: str, stream: Any) -> None: try: - stdout, stderr = proc.communicate(timeout=1) - except Exception: - stdout = getattr(exc, "output", None) - stderr = getattr(exc, "stderr", None) + while True: + chunk = stream.read(1) + if not chunk: + break + output_queue.put((name, chunk)) + finally: + output_queue.put((name, None)) + + readers = [ + threading.Thread( + target=read_stream, + args=("stdout", proc.stdout), + daemon=True, + ), + threading.Thread( + target=read_stream, + args=("stderr", proc.stderr), + daemon=True, + ), + ] + for reader in readers: + reader.start() + + deadline = time.monotonic() + timeout + timed_out = False + while open_streams: + remaining = deadline - time.monotonic() + if remaining <= 0: + timed_out = True + break + try: + name, chunk = output_queue.get(timeout=min(0.05, remaining)) + except queue.Empty: + continue + if chunk is None: + open_streams.discard(name) + continue + chunks[name].append(chunk) + deadline = time.monotonic() + timeout + + if timed_out: + _terminate_command_tts_process_tree(proc) + for reader in readers: + reader.join(timeout=0.5) + while True: + try: + name, chunk = output_queue.get_nowait() + except queue.Empty: + break + if chunk: + chunks[name].append(chunk) + stdout = "".join(chunks["stdout"]) + stderr = "".join(chunks["stderr"]) raise subprocess.TimeoutExpired( command, timeout, output=stdout, stderr=stderr, - ) from exc + ) + + stdout = "".join(chunks["stdout"]) + stderr = "".join(chunks["stderr"]) + proc.wait() if proc.returncode: raise subprocess.CalledProcessError(