mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-17 10:22:01 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07fcf35276 |
@@ -17,18 +17,64 @@ _PID_FILE = DEFAULT_CONFIG_DIR / "server.pid"
|
||||
_LOG_FILE = DEFAULT_CONFIG_DIR / "server.log"
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return whether *pid* identifies a running process without signaling it."""
|
||||
if pid <= 0:
|
||||
return False
|
||||
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
error_invalid_parameter = 87
|
||||
synchronize = 0x00100000
|
||||
wait_object_0 = 0x00000000
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.OpenProcess(synchronize, False, pid)
|
||||
if not handle:
|
||||
# OpenProcess reports ERROR_INVALID_PARAMETER when the PID does not
|
||||
# exist. For access-denied and other inconclusive failures, retain
|
||||
# the PID file rather than declaring a potentially live daemon dead.
|
||||
return ctypes.get_last_error() != error_invalid_parameter
|
||||
|
||||
try:
|
||||
wait_result = kernel32.WaitForSingleObject(handle, 0)
|
||||
# WAIT_OBJECT_0 proves the process exited. WAIT_TIMEOUT proves it
|
||||
# is live; unexpected failures are inconclusive, so retain the PID.
|
||||
return wait_result != wait_object_0
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _read_pid() -> int | None:
|
||||
"""Read PID from pid file, return None if not found or stale."""
|
||||
if not _PID_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
pid = int(_PID_FILE.read_text().strip())
|
||||
# Check if process is still running
|
||||
os.kill(pid, 0)
|
||||
return pid
|
||||
except (ValueError, OSError):
|
||||
except (OSError, ValueError):
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
return None
|
||||
if not _pid_alive(pid):
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
return None
|
||||
return pid
|
||||
|
||||
|
||||
def _write_pid(pid: int) -> None:
|
||||
@@ -127,14 +173,13 @@ def stop() -> None:
|
||||
# Wait up to 10 seconds for graceful shutdown
|
||||
for _ in range(20):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
else:
|
||||
# Force kill if still running
|
||||
# SIGKILL is POSIX-only. On Windows SIGTERM already maps to
|
||||
# TerminateProcess, so repeating it is the available escalation.
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli import cli
|
||||
from openjarvis.cli.daemon_cmd import _read_pid, _write_pid
|
||||
from openjarvis.cli.daemon_cmd import _pid_alive, _read_pid, _write_pid
|
||||
|
||||
|
||||
class TestDaemonCommands:
|
||||
@@ -45,12 +48,12 @@ class TestDaemonCommands:
|
||||
assert _read_pid() is None
|
||||
|
||||
def test_write_and_read_pid(self, tmp_path: Path) -> None:
|
||||
"""Write a PID, then read it back (mock os.kill to succeed)."""
|
||||
"""Write a PID, then read it back with a successful liveness probe."""
|
||||
pid_file = tmp_path / "server.pid"
|
||||
with (
|
||||
patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file),
|
||||
patch("openjarvis.cli.daemon_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch("os.kill", return_value=None),
|
||||
patch("openjarvis.cli.daemon_cmd._pid_alive", return_value=True),
|
||||
):
|
||||
_write_pid(12345)
|
||||
assert pid_file.exists()
|
||||
@@ -82,6 +85,53 @@ class TestDaemonCommands:
|
||||
assert "already running" in result.output
|
||||
|
||||
|
||||
class TestPidLiveness:
|
||||
"""Regression coverage for Windows-safe PID liveness checks."""
|
||||
|
||||
def test_pid_alive_current_process(self) -> None:
|
||||
assert _pid_alive(os.getpid()) is True
|
||||
|
||||
def test_pid_alive_nonpositive(self) -> None:
|
||||
assert _pid_alive(0) is False
|
||||
assert _pid_alive(-1) is False
|
||||
|
||||
def test_pid_alive_dead_pid(self) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
proc.wait()
|
||||
|
||||
for _ in range(20):
|
||||
if not _pid_alive(proc.pid):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
assert _pid_alive(proc.pid) is False
|
||||
|
||||
def test_read_pid_stale_pid_returns_none(self, tmp_path: Path) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
proc.wait()
|
||||
pid_file = tmp_path / "server.pid"
|
||||
pid_file.write_text(str(proc.pid))
|
||||
|
||||
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
|
||||
assert _read_pid() is None
|
||||
|
||||
assert not pid_file.exists()
|
||||
|
||||
def test_read_pid_live_pid_returns_it(self, tmp_path: Path) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(10)"])
|
||||
try:
|
||||
pid_file = tmp_path / "server.pid"
|
||||
pid_file.write_text(str(proc.pid))
|
||||
|
||||
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
|
||||
assert _read_pid() == proc.pid
|
||||
|
||||
assert pid_file.exists()
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
|
||||
|
||||
class TestDaemonDetachment:
|
||||
"""The spawned server must outlive the console that started it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user