diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index c095586b6c9..59ebd648a09 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -5386,6 +5386,14 @@ class PrismaClient: def _is_engine_alive(self) -> bool: if self._engine_pid <= 0: return True + # Windows 上 os.kill(pid, 0) 不是 Unix 的「signal-0 存活探測」,而是直接 + # 呼叫 TerminateProcess(handle, 0) 把 engine 殺掉:CPython 在 Windows 只 + # 把 CTRL_C_EVENT / CTRL_BREAK_EVENT 當特例,其餘 sig 值(含 0)一律 + # TerminateProcess。Windows 沒有安全的 signal-0 探測方式,故一律回報存活, + # 真正的 engine 故障交由查詢層 call_with_db_reconnect_retry 在 + # ReadError / ConnectError 時惰性處理。 + if sys.platform == "win32": + return True try: os.kill(self._engine_pid, 0) return True @@ -5610,6 +5618,11 @@ class PrismaClient: Only used when BOTH waitpid thread and pidfd are unavailable (e.g., PID is not our child process and pidfd_open fails) """ + # 保險絲:Windows 上 os.kill(pid, 0) 會 TerminateProcess 殺掉 engine, + # 這個 poller 絕不能在 Windows 執行。_start_engine_watcher 已在 win32 + # 不會排這個 task,此處再擋一道以防未來有其他呼叫點。 + if sys.platform == "win32": + return while self._watching_engine and self._engine_pid > 0: try: os.kill(self._engine_pid, 0) @@ -5692,6 +5705,18 @@ class PrismaClient: "Watching engine PID %s via pidfd.", pid, ) + elif sys.platform == "win32": + # Windows 沒有 waitpid/pidfd,唯一的 fallback 是 os.kill 輪詢,但 + # os.kill(pid, 0) 在 Windows 會 TerminateProcess 把 engine 殺掉 + # (連上後約 1 秒就被自己的存活檢查殺死,之後所有查詢 ConnectError)。 + # 因此 Windows 不做主動 engine-death 偵測——回到 1.82 之前 Windows 的 + # 既有行為,engine 故障由查詢層的 reconnect retry 惰性處理。 + verbose_proxy_logger.info( + "Engine PID %s: proactive engine-death detection is unavailable on " + "Windows (waitpid/pidfd unsupported; os.kill polling would terminate " + "the engine). Relying on query-level reconnect retry instead.", + pid, + ) else: verbose_proxy_logger.info( "Watching engine PID %s via os.kill polling.", diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_engine_watcher.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_engine_watcher.py index 25c04caabba..7446bd4c4be 100644 --- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_engine_watcher.py +++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_engine_watcher.py @@ -712,3 +712,73 @@ async def test_poll_engine_proc_planned_death_skips_reconnect( "cleanup_called": 1, "confirmed_dead": False, } + + +# --------------------------------------------------------------------------- +# Windows: os.kill(pid, 0) is NOT a liveness probe -- CPython maps every sig +# other than CTRL_C_EVENT/CTRL_BREAK_EVENT (including 0) to TerminateProcess, +# so the Unix-style liveness checks would terminate the query engine (~1s after +# connect, then every subsequent query fails with ConnectError). The engine +# watcher therefore must never signal the engine on Windows. These tests run on +# the Linux CI runner and simulate Windows by patching ``sys.platform`` -- the +# module-level ``skipif(win32)`` above keeps them from running on real Windows +# where ``os.kill`` would actually kill the test's own processes. +# --------------------------------------------------------------------------- +def test_is_engine_alive_never_calls_os_kill_on_windows( + prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch +) -> None: + prisma_client._engine_pid = 12345 + fake_kill = MagicMock() + monkeypatch.setattr("os.kill", fake_kill) + monkeypatch.setattr(sys, "platform", "win32") + pinned = { + "result": prisma_client._is_engine_alive(), + "os_kill_calls": fake_kill.call_count, + } + assert pinned == {"result": True, "os_kill_calls": 0} + + +@pytest.mark.asyncio +async def test_poll_engine_proc_is_noop_on_windows( + prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch +) -> None: + prisma_client._engine_pid = 555 + prisma_client._watching_engine = True + prisma_client.attempt_db_reconnect = AsyncMock() + fake_kill = MagicMock() + monkeypatch.setattr("os.kill", fake_kill) + monkeypatch.setattr(sys, "platform", "win32") + + # Must return immediately without ever signalling the engine. + await asyncio.wait_for(prisma_client._poll_engine_proc(), timeout=1) + pinned = { + "os_kill_calls": fake_kill.call_count, + "reconnect_calls": prisma_client.attempt_db_reconnect.await_count, + } + assert pinned == {"os_kill_calls": 0, "reconnect_calls": 0} + + +@pytest.mark.asyncio +async def test_start_engine_watcher_does_not_poll_on_windows( + prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """On Windows both waitpid and pidfd are unavailable; the watcher must NOT + fall back to os.kill polling (which would terminate the engine). It records + the PID but starts no poller, deferring to query-level reconnect retry.""" + monkeypatch.setattr(prisma_client, "_get_engine_pid", MagicMock(return_value=4242)) + monkeypatch.setattr(prisma_client, "_try_waitpid_watch", MagicMock(return_value=False)) + monkeypatch.setattr(prisma_client, "_try_pidfd_watch", MagicMock(return_value=False)) + poll = AsyncMock() + monkeypatch.setattr(prisma_client, "_poll_engine_proc", poll) + fake_kill = MagicMock() + monkeypatch.setattr("os.kill", fake_kill) + monkeypatch.setattr(sys, "platform", "win32") + + await prisma_client._start_engine_watcher() + await asyncio.sleep(0) + pinned = { + "watching": prisma_client._watching_engine, + "poll_started": poll.await_count, + "os_kill_calls": fake_kill.call_count, + } + assert pinned == {"watching": False, "poll_started": 0, "os_kill_calls": 0}