mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(cli): probe autoroute proxy pids without signalling them
This commit is contained in:
parent
5290150a05
commit
ab9b6880f2
2 changed files with 205 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue