# -*- coding: utf-8 -*- # pylint: disable=protected-access from __future__ import annotations import asyncio import os import subprocess from pathlib import Path import pytest from pineagents.utils import command_runner from pineagents.utils.command_runner import ( CommandExecutionError, ManagedProcess, ProcessLaunchError, run_command, run_command_async, shutdown_process, shutdown_process_sync, start_command_async, start_multiprocessing_process, ) def test_run_command_returns_combined_output( monkeypatch: pytest.MonkeyPatch, ) -> None: recorded: dict[str, object] = {} def fake_run(*args, **kwargs) -> subprocess.CompletedProcess[str]: recorded["command"] = args[0] recorded["cwd"] = kwargs["cwd"] return subprocess.CompletedProcess( args=args[0], returncode=0, stdout="stdout line\n", stderr="stderr line\n", ) monkeypatch.setattr( command_runner, "windows_hidden_subprocess_kwargs", lambda: {}, ) monkeypatch.setattr(command_runner.subprocess, "run", fake_run) result = run_command(["demo", "--flag"], cwd=Path("/tmp/demo")) assert result.command == ["demo", "--flag"] assert result.combined_output == "stdout line\nstderr line" assert recorded == { "command": ["demo", "--flag"], "cwd": os.fspath(Path("/tmp/demo")), } def test_run_command_hides_windows_console( monkeypatch: pytest.MonkeyPatch, ) -> None: recorded: dict[str, object] = {} def fake_run(*args, **kwargs) -> subprocess.CompletedProcess[str]: del args recorded.update(kwargs) return subprocess.CompletedProcess( args=["demo"], returncode=0, stdout="", stderr="", ) monkeypatch.setattr( command_runner, "windows_hidden_subprocess_kwargs", lambda: {"creationflags": 0x08000000}, ) monkeypatch.setattr(command_runner.subprocess, "run", fake_run) run_command(["demo"]) assert recorded["creationflags"] == 0x08000000 def test_run_command_raises_for_non_zero_exit( monkeypatch: pytest.MonkeyPatch, ) -> None: def fake_run( command: list[str], **_kwargs, ) -> subprocess.CompletedProcess[str]: del _kwargs return subprocess.CompletedProcess( args=command, returncode=2, stdout="", stderr="failure", ) monkeypatch.setattr(command_runner.subprocess, "run", fake_run) with pytest.raises(CommandExecutionError, match="failure") as exc_info: run_command(["demo"], check=True) assert exc_info.value.returncode == 2 assert exc_info.value.command == ["demo"] def test_run_command_raises_for_missing_executable( monkeypatch: pytest.MonkeyPatch, ) -> None: def fake_run(*args, **kwargs) -> subprocess.CompletedProcess[str]: del args, kwargs raise FileNotFoundError monkeypatch.setattr(command_runner.subprocess, "run", fake_run) with pytest.raises( CommandExecutionError, match="Command executable not found", ): run_command(["missing-binary"]) @pytest.mark.asyncio async def test_run_command_async_uses_sync_runner( monkeypatch: pytest.MonkeyPatch, ) -> None: def fake_run_command(command: list[str], **_kwargs): del command, _kwargs return command_runner.CommandResult( command=["demo"], returncode=0, stdout="ok", stderr="", ) monkeypatch.setattr(command_runner, "run_command", fake_run_command) result = await run_command_async(["demo"]) assert result.combined_output == "ok" @pytest.mark.asyncio async def test_start_command_async_uses_asyncio_subprocess( monkeypatch: pytest.MonkeyPatch, ) -> None: recorded: dict[str, object] = {} class _FakeAsyncProcess: def __init__(self) -> None: self.pid = 4321 self.returncode: int | None = None self.stdout = None async def wait(self) -> int: self.returncode = 0 return 0 def terminate(self) -> None: self.returncode = -15 def kill(self) -> None: self.returncode = -9 fake_process = _FakeAsyncProcess() async def fake_create_subprocess_exec(*args, **kwargs): recorded["command"] = list(args) recorded["cwd"] = kwargs["cwd"] recorded["env"] = kwargs["env"] recorded["stdout"] = kwargs["stdout"] recorded["stderr"] = kwargs["stderr"] return fake_process monkeypatch.setattr( command_runner, "windows_hidden_subprocess_kwargs", lambda _creationflags=0: {}, ) monkeypatch.setattr( command_runner.asyncio, "create_subprocess_exec", fake_create_subprocess_exec, ) result = await start_command_async( ["demo", "--serve"], cwd=Path("/tmp/demo"), env={"A": "1"}, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) assert isinstance(result, ManagedProcess) assert result.pid == 4321 assert result.command == ["demo", "--serve"] assert result.creation_mode == "asyncio" assert result.owns_process_group is False assert recorded == { "command": ["demo", "--serve"], "cwd": os.fspath(Path("/tmp/demo")), "env": {"A": "1"}, "stdout": asyncio.subprocess.PIPE, "stderr": asyncio.subprocess.STDOUT, } @pytest.mark.asyncio async def test_start_command_async_hides_windows_console( monkeypatch: pytest.MonkeyPatch, ) -> None: recorded: dict[str, object] = {} class _FakeAsyncProcess: pid = 4321 returncode: int | None = None stdout = None async def wait(self) -> int: return 0 def terminate(self) -> None: return None def kill(self) -> None: return None async def fake_create_subprocess_exec(*args, **kwargs): del args recorded.update(kwargs) return _FakeAsyncProcess() monkeypatch.setattr( command_runner, "windows_hidden_subprocess_kwargs", lambda creationflags=0: { "creationflags": creationflags | 0x08000000, }, ) monkeypatch.setattr( command_runner.asyncio, "create_subprocess_exec", fake_create_subprocess_exec, ) await start_command_async(["demo"], creationflags=0x00000200) assert recorded["creationflags"] == 0x08000200 def test_coerce_subprocess_path_supports_generic_pathlike() -> None: class _CustomPathLike: def __fspath__(self) -> str: return "custom/path" assert ( command_runner._coerce_subprocess_path(_CustomPathLike()) == "custom/path" ) def test_windows_hidden_subprocess_kwargs_returns_empty_on_posix( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(command_runner.os, "name", "posix", raising=False) assert not command_runner.windows_hidden_subprocess_kwargs() def test_windows_hidden_subprocess_kwargs_returns_flags_on_windows( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(command_runner.os, "name", "nt", raising=False) monkeypatch.setattr( command_runner.subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False, ) assert command_runner.windows_hidden_subprocess_kwargs() == { "creationflags": 0x08000000, } def test_windows_hidden_subprocess_kwargs_preserves_existing_flags( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(command_runner.os, "name", "nt", raising=False) monkeypatch.setattr( command_runner.subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False, ) assert command_runner.windows_hidden_subprocess_kwargs(0x00000200) == { "creationflags": 0x08000200, } @pytest.mark.asyncio async def test_start_command_async_falls_back_to_threaded_popen_on_windows( monkeypatch: pytest.MonkeyPatch, ) -> None: popen_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] class _FakeBlockingStdout: def readline(self) -> bytes: return b"" class _FakePopen: def __init__(self) -> None: self.pid = 1234 self.stdout = _FakeBlockingStdout() self._returncode: int | None = None def poll(self) -> int | None: return self._returncode def wait(self) -> int: self._returncode = 0 return 0 def terminate(self) -> None: self._returncode = -15 def kill(self) -> None: self._returncode = -9 async def fail_create_subprocess_exec(*args, **kwargs): del args, kwargs raise NotImplementedError def fake_popen_factory(*args, **kwargs): popen_calls.append((args, kwargs)) return _FakePopen() monkeypatch.setattr(command_runner.os, "name", "nt", raising=False) monkeypatch.setattr( command_runner.subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False, ) monkeypatch.setattr( command_runner.asyncio, "create_subprocess_exec", fail_create_subprocess_exec, ) monkeypatch.setattr( command_runner.subprocess, "Popen", fake_popen_factory, ) result = await start_command_async( ["demo", "--serve"], stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) assert isinstance(result, ManagedProcess) assert result.pid == 1234 assert result.creation_mode == "threaded" assert await result.wait() == 0 assert popen_calls == [ ( (["demo", "--serve"],), { "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT, "creationflags": 0x08000000, }, ), ] @pytest.mark.asyncio async def test_start_command_async_raises_for_missing_executable( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fail_create_subprocess_exec(*args, **kwargs): del args, kwargs raise FileNotFoundError monkeypatch.setattr( command_runner.asyncio, "create_subprocess_exec", fail_create_subprocess_exec, ) with pytest.raises( ProcessLaunchError, match="Command executable not found", ): await start_command_async(["missing-binary"]) def test_start_multiprocessing_process_wraps_process() -> None: class _FakeMultiprocessingProcess: def __init__(self) -> None: self.pid = 6789 self.exitcode: int | None = None self.started = False self.closed = False def start(self) -> None: self.started = True def is_alive(self) -> bool: return self.exitcode is None def join(self, timeout=None) -> None: del timeout self.exitcode = 0 def terminate(self) -> None: self.exitcode = -15 def kill(self) -> None: self.exitcode = -9 def close(self) -> None: self.closed = True raw_process = _FakeMultiprocessingProcess() managed = start_multiprocessing_process( raw_process, command=["qwenpaw-model-download", "demo/repo", "modelscope"], ) assert isinstance(managed, ManagedProcess) assert managed.creation_mode == "multiprocessing" assert managed.command == [ "qwenpaw-model-download", "demo/repo", "modelscope", ] assert managed.is_alive() is True assert raw_process.started is True def test_wait_for_process_exit_prefers_process_liveness( monkeypatch: pytest.MonkeyPatch, ) -> None: pid_checks: list[tuple[int, str]] = [] class _FakeProcess: def __init__(self) -> None: self.pid = 2468 self.returncode = 0 self.stdout = None async def wait(self) -> int: return 0 def terminate(self) -> None: return None def kill(self) -> None: return None def is_alive(self) -> bool: return False def join(self, timeout=None) -> int: del timeout return 0 def _record_pid_check(pid: int, platform_name: str) -> bool: pid_checks.append((pid, platform_name)) return True monkeypatch.setattr( command_runner, "_is_pid_running", _record_pid_check, ) managed = ManagedProcess( _FakeProcess(), command=["demo"], owns_process_group=False, creation_mode="multiprocessing", ) assert command_runner._wait_for_process_exit(managed, timeout=1.0) is True assert not pid_checks @pytest.mark.asyncio async def test_shutdown_process_terminates_gracefully( monkeypatch: pytest.MonkeyPatch, ) -> None: del monkeypatch class _FakeAsyncProcess: def __init__(self) -> None: self.pid = 99 self.stdout = None self.returncode: int | None = None self.terminate_calls = 0 self.kill_calls = 0 async def wait(self) -> int: self.returncode = 0 return 0 def terminate(self) -> None: self.terminate_calls += 1 def kill(self) -> None: self.kill_calls += 1 inner = _FakeAsyncProcess() managed = ManagedProcess( inner, command=["demo"], owns_process_group=False, creation_mode="asyncio", ) result = await shutdown_process(managed, graceful_timeout=0.1) assert result.exited is True assert result.terminated_gracefully is True assert result.killed is False assert result.returncode == 0 assert inner.terminate_calls == 1 assert inner.kill_calls == 0 @pytest.mark.asyncio async def test_shutdown_process_escalates_to_kill_after_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: del monkeypatch class _FakeAsyncProcess: def __init__(self) -> None: self.pid = 100 self.stdout = None self.returncode: int | None = None self.terminate_calls = 0 self.kill_calls = 0 self._killed = False async def wait(self) -> int: if not self._killed: await asyncio.sleep(3600) self.returncode = -9 return -9 def terminate(self) -> None: self.terminate_calls += 1 def kill(self) -> None: self.kill_calls += 1 self._killed = True inner = _FakeAsyncProcess() managed = ManagedProcess( inner, command=["demo"], owns_process_group=False, creation_mode="asyncio", ) result = await shutdown_process( managed, graceful_timeout=0.01, kill_timeout=0.1, ) assert result.exited is True assert result.terminated_gracefully is False assert result.killed is True assert result.returncode == -9 assert inner.terminate_calls == 1 assert inner.kill_calls == 1 def test_shutdown_process_sync_uses_process_group_on_posix( monkeypatch: pytest.MonkeyPatch, ) -> None: signals: list[tuple[int, int]] = [] class _FakeProcess: def __init__(self) -> None: self.pid = 123 self.stdout = None self.returncode: int | None = None async def wait(self) -> int: self.returncode = 0 return 0 def terminate(self) -> None: raise AssertionError( "terminate should not be used for process groups", ) def kill(self) -> None: raise AssertionError( "kill should not be used for process groups", ) monkeypatch.setattr(command_runner.os, "name", "posix", raising=False) monkeypatch.setattr( command_runner.os, "getpgid", lambda pid: pid, raising=False, ) monkeypatch.setattr( command_runner.os, "killpg", lambda pgid, sig: signals.append((pgid, int(sig))), raising=False, ) monkeypatch.setattr( command_runner, "_wait_for_process_exit", lambda process, timeout: True, ) managed = ManagedProcess( _FakeProcess(), command=["demo"], owns_process_group=True, creation_mode="asyncio", ) result = shutdown_process_sync(managed, graceful_timeout=5.0) assert result.exited is True assert result.terminated_gracefully is True assert result.killed is False assert signals == [(123, int(command_runner.signal.SIGTERM))] def test_shutdown_process_sync_escalates_to_kill( monkeypatch: pytest.MonkeyPatch, ) -> None: signals: list[int] = [] wait_outcomes = iter([False, True]) class _FakeProcess: def __init__(self) -> None: self.pid = 456 self.stdout = None self.returncode: int | None = None async def wait(self) -> int: self.returncode = 0 return 0 def terminate(self) -> None: signals.append(15) def kill(self) -> None: signals.append(9) monkeypatch.setattr( command_runner, "_wait_for_process_exit", lambda process, timeout: next(wait_outcomes), ) managed = ManagedProcess( _FakeProcess(), command=["demo"], owns_process_group=False, creation_mode="asyncio", ) result = shutdown_process_sync( managed, graceful_timeout=5.0, kill_timeout=1.0, ) assert result.exited is True assert result.terminated_gracefully is False assert result.killed is True assert signals == [15, 9] def test_is_pid_running_uses_tasklist_on_windows( monkeypatch: pytest.MonkeyPatch, ) -> None: def fail_if_called(pid: int, sig: int) -> None: raise AssertionError("os.kill should not be used on Windows") monkeypatch.setattr(command_runner.os, "kill", fail_if_called) monkeypatch.setattr( command_runner.subprocess, "check_output", lambda *args, **kwargs: ( "Image Name PID Session Name " "Session# Mem Usage\n" "========================= ======== ================ " "========== ============\n" "llama-server.exe 4321 Console " " 1 12,000 K\n" ), ) assert command_runner._is_pid_running(4321, "nt") is True def test_is_pid_running_uses_os_kill_on_posix( monkeypatch: pytest.MonkeyPatch, ) -> None: calls: list[tuple[int, int]] = [] def fake_kill(pid: int, sig: int) -> None: calls.append((pid, sig)) raise PermissionError() monkeypatch.setattr(command_runner.os, "kill", fake_kill) assert command_runner._is_pid_running(1234, "posix") is True assert calls == [(1234, 0)]