Files

727 lines
19 KiB
Python
Raw Permalink Normal View History

2026-08-23 22:44:18 +08:00
# -*- 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)]