From ab9b6880f2a0e315c8a14daef2505128073ef0c9 Mon Sep 17 00:00:00 2001 From: yoggydev <280342032+yoggydev@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:07:49 +0900 Subject: [PATCH] fix(cli): probe autoroute proxy pids without signalling them --- .../client/cli/commands/autoroute/process.py | 83 +++++++++++- .../client/cli/autoroute/test_process.py | 126 ++++++++++++++++++ 2 files changed, 205 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/client/cli/commands/autoroute/process.py b/litellm/proxy/client/cli/commands/autoroute/process.py index 425b8581fed..ada55c92673 100644 --- a/litellm/proxy/client/cli/commands/autoroute/process.py +++ b/litellm/proxy/client/cli/commands/autoroute/process.py @@ -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: diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_process.py b/tests/test_litellm/proxy/client/cli/autoroute/test_process.py index 478b64c2d78..51e020b450f 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_process.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_process.py @@ -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))