Compare commits

...
Author SHA1 Message Date
07fcf35276 fix: use a signal-free liveness probe for the daemon on Windows (#681)
* fix: use a signal-free liveness probe for the daemon on Windows

`_read_pid()` probed the recorded pid with `os.kill(pid, 0)`. That is a
POSIX idiom: on Windows signal 0 is `CTRL_C_EVENT`, so the call routes to
`GenerateConsoleCtrlEvent` rather than testing for existence, and raises
`OSError` (WinError 87, "The parameter is incorrect") for any pid that is
not a live console process-group leader — which includes both dead pids
and the detached server `jarvis start` creates.

That single call produced three symptoms. `jarvis status` propagated the
error and crashed. `_read_pid`'s `except OSError` swallowed it for a
running server, so `status` and `stop` reported "not running" and deleted
a live pid file. And because the probe *sends* a console control event
rather than merely asking, running `status` against the daemon could
terminate it.

Add `_pid_alive()`, which opens a process handle and checks it on Windows
and keeps the signal-0 probe on POSIX, and use it for both liveness
checks. `SIGKILL` in the stop path is now reached on Windows for the
first time, so guard it — it is POSIX-only, and `SIGTERM` already maps to
`TerminateProcess` there.

The existing round-trip test mocked `os.kill` to succeed, which is why
this passed CI on Linux while failing on every Windows run. Point it at
the new seam and add `TestPidLiveness`, which exercises real pids so the
platform behaviour is actually covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: format daemon tests with CI Ruff

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-10 16:56:53 -07:00
2 changed files with 107 additions and 12 deletions
+54 -9
View File
@@ -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:
+53 -3
View File
@@ -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.