Skip to content

Commit 20ac43c

Browse files
committed
fix: use a non-destructive Windows daemon PID probe
1 parent d8f1ba2 commit 20ac43c

2 files changed

Lines changed: 134 additions & 0 deletions

File tree

src/perplexity_web_mcp/mcp/server.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,10 +783,51 @@ def get_daemon_pid_path(port: int) -> Path:
783783
return CONFIG_DIR / f"daemon-{port}.pid"
784784

785785

786+
def _is_windows_pid_running(pid: int) -> bool:
787+
"""Check process state without sending a signal on Windows.
788+
789+
Indeterminate results are treated as running so a valid daemon lock is not
790+
discarded merely because the process cannot be inspected.
791+
"""
792+
import ctypes
793+
from ctypes import wintypes
794+
795+
synchronize = 0x00100000
796+
error_invalid_parameter = 87
797+
wait_object_0 = 0x00000000
798+
wait_timeout = 0x00000102
799+
800+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
801+
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
802+
kernel32.OpenProcess.restype = wintypes.HANDLE
803+
kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
804+
kernel32.WaitForSingleObject.restype = wintypes.DWORD
805+
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
806+
kernel32.CloseHandle.restype = wintypes.BOOL
807+
808+
handle = kernel32.OpenProcess(synchronize, False, pid)
809+
if not handle:
810+
return ctypes.get_last_error() != error_invalid_parameter
811+
812+
try:
813+
wait_result = kernel32.WaitForSingleObject(handle, 0)
814+
if wait_result == wait_object_0:
815+
return False
816+
if wait_result == wait_timeout:
817+
return True
818+
return True
819+
finally:
820+
kernel32.CloseHandle(handle)
821+
822+
786823
def is_pid_running(pid: int) -> bool:
787824
"""Check if a process with the given PID is currently running."""
788825
if pid <= 0:
789826
return False
827+
if sys.platform == "win32":
828+
if pid > 0xFFFFFFFF:
829+
return False
830+
return _is_windows_pid_running(pid)
790831
try:
791832
os.kill(pid, 0)
792833
return True

tests/test_mcp_server.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
from __future__ import annotations
44

5+
import ctypes
56
from pathlib import Path
7+
import subprocess
8+
import sys
69
from unittest.mock import MagicMock, patch
710

811
import pytest
@@ -98,6 +101,96 @@ def test_daemon_pid_lifecycle(tmp_path: Path, monkeypatch) -> None:
98101
assert server.get_running_daemon_pid(port) is None
99102

100103

104+
@pytest.mark.parametrize(
105+
("open_result", "last_error", "wait_result", "expected"),
106+
(
107+
(0, 87, None, False),
108+
(0, 5, None, True),
109+
(123, 0, 0x00000102, True),
110+
(123, 0, 0x00000000, False),
111+
(123, 0, 0xFFFFFFFF, True),
112+
),
113+
)
114+
def test_windows_pid_probe_is_non_destructive_and_conservative(
115+
open_result: int,
116+
last_error: int,
117+
wait_result: int | None,
118+
expected: bool,
119+
) -> None:
120+
kernel32 = MagicMock()
121+
kernel32.OpenProcess.return_value = open_result
122+
kernel32.WaitForSingleObject.return_value = wait_result
123+
124+
with (
125+
patch.object(ctypes, "WinDLL", return_value=kernel32, create=True) as win_dll,
126+
patch.object(ctypes, "get_last_error", return_value=last_error, create=True),
127+
):
128+
assert server._is_windows_pid_running(4242) is expected
129+
130+
win_dll.assert_called_once_with("kernel32", use_last_error=True)
131+
kernel32.OpenProcess.assert_called_once_with(0x00100000, False, 4242)
132+
if open_result:
133+
kernel32.WaitForSingleObject.assert_called_once_with(open_result, 0)
134+
kernel32.CloseHandle.assert_called_once_with(open_result)
135+
else:
136+
kernel32.WaitForSingleObject.assert_not_called()
137+
kernel32.CloseHandle.assert_not_called()
138+
139+
140+
def test_is_pid_running_uses_windows_probe() -> None:
141+
with (
142+
patch.object(server.sys, "platform", "win32"),
143+
patch.object(server, "_is_windows_pid_running", return_value=True) as probe,
144+
patch.object(server.os, "kill") as kill,
145+
):
146+
assert server.is_pid_running(4242) is True
147+
148+
probe.assert_called_once_with(4242)
149+
kill.assert_not_called()
150+
151+
152+
def test_is_pid_running_rejects_out_of_range_windows_pid() -> None:
153+
with (
154+
patch.object(server.sys, "platform", "win32"),
155+
patch.object(server, "_is_windows_pid_running") as probe,
156+
patch.object(server.os, "kill") as kill,
157+
):
158+
assert server.is_pid_running(0x100000000) is False
159+
160+
probe.assert_not_called()
161+
kill.assert_not_called()
162+
163+
164+
def test_windows_live_pid_file_is_retained_and_blocks_duplicate(tmp_path: Path, monkeypatch) -> None:
165+
monkeypatch.setattr(server, "CONFIG_DIR", tmp_path)
166+
port = 8995
167+
pid_path = server.get_daemon_pid_path(port)
168+
pid_path.write_text("4242", encoding="utf-8")
169+
170+
with (
171+
patch.object(server.sys, "platform", "win32"),
172+
patch.object(server, "_is_windows_pid_running", return_value=True) as probe,
173+
):
174+
assert server.get_running_daemon_pid(port) == 4242
175+
assert pid_path.exists()
176+
assert server.acquire_daemon_lock(port) is False
177+
178+
probe.assert_called_once_with(4242)
179+
180+
181+
@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows process APIs")
182+
def test_windows_pid_probe_preserves_live_child() -> None:
183+
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
184+
try:
185+
assert server.is_pid_running(child.pid) is True
186+
assert child.poll() is None
187+
finally:
188+
child.terminate()
189+
child.wait(timeout=10)
190+
191+
assert server.is_pid_running(child.pid) is False
192+
193+
101194
def test_daemon_stale_pid_cleanup(tmp_path: Path, monkeypatch) -> None:
102195
monkeypatch.setattr(server, "CONFIG_DIR", tmp_path)
103196
port = 8998

0 commit comments

Comments
 (0)