fix(cli): probe autoroute proxy pids without signalling them

This commit is contained in:
yoggydev 2026-08-20 17:07:49 +09:00
parent 5290150a05
commit ab9b6880f2
2 changed files with 205 additions and 4 deletions

View file

@ -138,7 +138,68 @@ def clear_pid_record(path: Path | None = None) -> None:
resolved_path.unlink(missing_ok=True)
_PROCESS_QUERY_LIMITED_INFORMATION: Final = 0x1000
_STILL_ACTIVE: Final = 259
_ERROR_ACCESS_DENIED: Final = 5
def _windows_pid_exists(pid: int) -> bool:
"""Liveness check for Windows that does not signal the process.
Opens a query-only handle and asks for the exit code. ``OpenProcess``
failing with ``ERROR_ACCESS_DENIED`` means the pid exists but is not ours
to open, which is the case the ``PermissionError`` arm covers on POSIX.
Caveat kept deliberately: a process whose real exit code is 259 reads as
alive, because ``GetExitCodeProcess`` reports ``STILL_ACTIVE`` (259) for a
running process and cannot distinguish the two. That is the standard
trade-off for this API and it is strictly better than the previous
behaviour, which killed the process it was asked about.
"""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
kernel32.CloseHandle.restype = wintypes.BOOL
handle: Final = kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return ctypes.get_last_error() == _ERROR_ACCESS_DENIED
try:
exit_code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return True
return exit_code.value == _STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
def is_running(pid: int) -> bool:
"""Report whether ``pid`` names a live process, without signalling it.
``os.kill(pid, 0)`` is the POSIX idiom and is a genuine no-op there, but it
is not a probe on Windows. ``signal.CTRL_C_EVENT`` is 0, so ``os.kill(pid,
0)`` *is* ``os.kill(pid, CTRL_C_EVENT)`` and reaches
``GenerateConsoleCtrlEvent``. That API's second argument is a process
*group*, and ``launch_proxy`` above starts the proxy through
``subprocess.Popen`` with no ``CREATE_NEW_PROCESS_GROUP``, so the child
shares this console's group: the Ctrl-C is delivered to the proxy, to the
``lite`` process asking the question, and to anything else on the console.
Measured on Windows 11, Python 3.12.10. Probing a sleeping child returns
``True`` with no exception raised; the child then exits with 3221225786
(``0xC000013A``, ``STATUS_CONTROL_C_EXIT``) and a ``KeyboardInterrupt``
arrives in the caller a moment later -- so the traceback does not point at
the probe. ``KeyboardInterrupt`` is a ``BaseException``, so neither the
``ProcessLookupError`` nor the ``PermissionError`` arm below ever sees it.
"""
if sys.platform == "win32":
return _windows_pid_exists(pid)
try:
os.kill(pid, 0)
except ProcessLookupError:
@ -149,17 +210,31 @@ def is_running(pid: int) -> bool:
def terminate(pid: int, grace_period: float = 5.0) -> None:
"""Terminate a process by pid, escalating from SIGTERM to SIGKILL if needed."""
"""Terminate a process by pid, escalating from SIGTERM to SIGKILL if needed.
Both kills tolerate the same errors ``prisma_client`` already tolerates for
this exact case -- "already dead or inaccessible". ``ProcessLookupError``
alone is not enough: on Windows ``os.kill`` routes to ``TerminateProcess``,
and a process that has already exited answers ``ERROR_ACCESS_DENIED``, so
the call raises ``PermissionError`` and escapes the suppression.
"""
if not is_running(pid):
return
with contextlib.suppress(ProcessLookupError):
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
os.kill(pid, signal.SIGTERM)
deadline: Final = time.monotonic() + grace_period
while time.monotonic() < deadline and is_running(pid):
time.sleep(0.2)
if is_running(pid):
with contextlib.suppress(ProcessLookupError):
os.kill(pid, signal.SIGKILL)
# signal.SIGKILL does not exist on Windows. Referencing it raises
# AttributeError, which contextlib.suppress(ProcessLookupError) does
# not catch, so this escalation path crashed instead of hard-killing
# a proxy that ignored SIGTERM. os.kill with any signal other than 0
# or 1 routes to TerminateProcess on Windows, so SIGTERM is a real
# kill there. litellm/proxy/db/prisma_client.py already resolves the
# signal this way.
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
def stream_log(log_path: Path, stop_event: threading.Event) -> None:

View file

@ -1,5 +1,9 @@
import os
import signal
import socket
import subprocess
import sys
import time
from typing import Optional
from unittest.mock import patch
@ -17,6 +21,7 @@ from litellm.proxy.client.cli.commands.autoroute.process import (
missing_proxy_runtime_modules,
poll_liveliness,
read_pid_record,
terminate,
write_pid_record,
)
@ -111,6 +116,10 @@ class TestIsRunning:
def test_huge_unlikely_pid_is_not_running(self):
assert is_running(2**30) is False
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX branch only; win32 answers with a query-only process handle, not os.kill",
)
def test_permission_error_from_kill_is_treated_as_running(self, monkeypatch):
def fake_kill(pid: int, sig: int) -> None:
raise PermissionError("not permitted to signal this pid")
@ -120,6 +129,123 @@ class TestIsRunning:
assert is_running(999) is True
class TestIsRunningDoesNotSignal:
"""``os.kill(pid, 0)`` is not a liveness probe on Windows.
``signal.CTRL_C_EVENT`` is 0, so the call reaches
``GenerateConsoleCtrlEvent``, whose second argument is a process *group*.
``launch_proxy`` spawns the proxy without ``CREATE_NEW_PROCESS_GROUP``, so
the child shares this console's group and the Ctrl-C hits the child, the
caller, and anything else attached to the console.
Measured on Windows 11, Python 3.12.10: probing a sleeping child returns
``True`` with no exception, the child then exits 3221225786
(``0xC000013A`` / ``STATUS_CONTROL_C_EXIT``), and a ``KeyboardInterrupt``
lands in the caller afterwards -- a ``BaseException``, so the handlers in
``is_running`` never see it.
"""
def test_probing_a_child_does_not_terminate_it(self):
"""The behavioural check, with nothing mocked."""
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
try:
assert is_running(child.pid) is True
time.sleep(1.0)
assert child.poll() is None, (
f"the liveness probe terminated the process it was asked about "
f"(exit code {child.returncode})"
)
assert is_running(child.pid) is True
finally:
child.kill()
child.wait(timeout=10)
def test_probing_our_own_pid_leaves_this_process_alone(self):
"""What ``TestIsRunning.test_current_process_is_running`` above relies on.
The assertion there passes either way; it is the interpreter surviving
the next statement that separates a probe from a signal.
"""
assert is_running(os.getpid()) is True
time.sleep(0.5)
assert is_running(os.getpid()) is True
def test_win32_answers_without_calling_os_kill(self, monkeypatch):
calls: list = []
monkeypatch.setattr(process_module.sys, "platform", "win32")
monkeypatch.setattr(process_module.os, "kill", lambda pid, sig: calls.append(sig))
monkeypatch.setattr(process_module, "_windows_pid_exists", lambda pid: True)
assert is_running(1234) is True
assert calls == [], "os.kill reached on win32; signal 0 is CTRL_C_EVENT there"
class TestTerminateHardKillSignal:
def test_escalation_resolves_a_signal_when_sigkill_is_absent(self, monkeypatch):
"""``signal.SIGKILL`` does not exist on Windows.
Referencing it raises ``AttributeError``, and ``terminate`` wraps the
call in ``contextlib.suppress(ProcessLookupError)``, which does not
catch that -- so the branch that exists for a proxy ignoring SIGTERM
crashed instead of hard-killing it. ``os.kill`` with any signal other
than 0 or 1 routes to ``TerminateProcess`` on Windows, so SIGTERM is a
real kill there.
"""
sent: list = []
monkeypatch.setattr(process_module.os, "kill", lambda pid, sig: sent.append(sig))
monkeypatch.setattr(process_module, "is_running", lambda pid: True)
monkeypatch.setattr(process_module.time, "sleep", lambda seconds: None)
monkeypatch.delattr(process_module.signal, "SIGKILL", raising=False)
terminate(4242, grace_period=0.0)
assert sent == [signal.SIGTERM, signal.SIGTERM]
@pytest.mark.skipif(
not hasattr(signal, "SIGKILL"),
reason="POSIX escalation path; SIGKILL is absent on this platform",
)
def test_escalation_still_uses_sigkill_where_it_exists(self, monkeypatch):
sent: list = []
monkeypatch.setattr(process_module.os, "kill", lambda pid, sig: sent.append(sig))
monkeypatch.setattr(process_module, "is_running", lambda pid: True)
monkeypatch.setattr(process_module.time, "sleep", lambda seconds: None)
terminate(4242, grace_period=0.0)
assert sent == [signal.SIGTERM, signal.SIGKILL]
@pytest.mark.parametrize(
"raised",
[
PermissionError(5, "Access is denied"),
OSError(22, "Invalid argument"),
ProcessLookupError(3, "No such process"),
],
ids=["permission-denied", "oserror", "already-gone"],
)
def test_a_kill_that_cannot_land_does_not_escape(self, monkeypatch, raised):
"""A process that exited between the probe and the kill must not crash ``down``.
On Windows ``os.kill`` routes to ``TerminateProcess``, and a process
that has already exited answers ``ERROR_ACCESS_DENIED`` -- so the call
raises ``PermissionError``, not ``ProcessLookupError``. Measured with
the real CLI on Windows 11: ``lite autoroute down`` ended in
``PermissionError: [WinError 5]`` out of ``terminate``.
``litellm/proxy/db/prisma_client.py`` already tolerates all three for
this same "already dead or inaccessible" case.
"""
def fake_kill(pid: int, sig: int) -> None:
raise raised
monkeypatch.setattr(process_module.os, "kill", fake_kill)
monkeypatch.setattr(process_module, "is_running", lambda pid: True)
monkeypatch.setattr(process_module.time, "sleep", lambda seconds: None)
terminate(4242, grace_period=0.0)
class TestPollLiveliness:
def test_succeeds_when_health_check_returns_200_quickly(self, monkeypatch, tmp_path):
monkeypatch.setattr(process_module.requests, "get", lambda url, timeout: FakeResponse(200))