#684 os.kill(pid,0) fleet migration: Windows-guard 8 liveness probes — @ai_mail (daemon.py x2, wake.py x2), @drone (lock_handler.py), @flow (lock_ops.py), @hooks (cc_sessions.py, presence.py). Each early-returns to OpenProcess+GetExitCodeProcess on win32 (os.kill(pid,0)=TerminateProcess, KILLS target). Fleet sweep now clears all but git_lock_tool.py (deferred #687). ai_mail 168 / flow 17 tests green, drone/hooks import-clean.
This commit is contained in:
@@ -86,6 +86,56 @@ def _write_json(filepath: Path, data: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
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 = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return True if the process is alive."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[daemon] PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError as exc:
|
||||
logger.info("[daemon] PID %s not found: %s", pid, exc)
|
||||
return False
|
||||
except PermissionError as exc:
|
||||
logger.info("[daemon] PID %s permission denied (alive): %s", pid, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.info("[daemon] PID %s os.kill error (assuming dead): %s", pid, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
@@ -96,14 +146,9 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
data = json.load(f)
|
||||
pid = data.get("pid")
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return data # Process alive, lock valid
|
||||
except ProcessLookupError:
|
||||
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
|
||||
except PermissionError as e:
|
||||
logger.warning("[daemon] Lock PID %s permission error: %s", pid, e)
|
||||
return data # Process exists, can't signal
|
||||
if _pid_alive(pid):
|
||||
return data
|
||||
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
|
||||
# Stale lock — check age (10 min timeout)
|
||||
ts = data.get("timestamp", "")
|
||||
if ts:
|
||||
@@ -214,15 +259,10 @@ def _write_pid_file() -> bool:
|
||||
# PID file exists — check if the owning process is alive
|
||||
try:
|
||||
old_pid = int(DAEMON_PID_FILE.read_text().strip())
|
||||
try:
|
||||
os.kill(old_pid, 0)
|
||||
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
|
||||
except PermissionError:
|
||||
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
|
||||
if _pid_alive(old_pid):
|
||||
logger.info("Another daemon already running (PID %s). Exiting.", old_pid)
|
||||
return False
|
||||
logger.info("Removing stale PID file (PID %s is dead)", old_pid)
|
||||
except (ValueError, OSError):
|
||||
logger.info("Corrupt PID file — removing")
|
||||
|
||||
|
||||
@@ -141,6 +141,34 @@ def _read_json(filepath: Path) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
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 = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
@@ -151,14 +179,9 @@ def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
data = json.load(f)
|
||||
pid = data.get("pid")
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return data # Process alive, lock valid
|
||||
except ProcessLookupError:
|
||||
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
|
||||
except PermissionError as e:
|
||||
logger.warning("[wake] Lock PID %s permission error: %s", pid, e)
|
||||
return data # Process exists but can't signal — treat as active
|
||||
if _check_pid_alive(pid):
|
||||
return data
|
||||
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
|
||||
# Stale lock — check age (10 min timeout)
|
||||
ts = data.get("timestamp", "")
|
||||
if ts:
|
||||
@@ -273,21 +296,33 @@ def _clean_zombies() -> int:
|
||||
|
||||
def _check_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process is alive (not zombie)."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[wake] PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
# Also verify not zombie via /proc (Linux only)
|
||||
if sys.platform == "linux":
|
||||
except ProcessLookupError as exc:
|
||||
logger.warning("[wake] PID %s not found: %s", pid, exc)
|
||||
return False
|
||||
except PermissionError as exc:
|
||||
logger.warning("[wake] PID %s permission denied: %s", pid, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("[wake] PID %s os.kill error (assuming dead): %s", pid, exc)
|
||||
return False
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
with open(f"/proc/{pid}/status", "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("State:"):
|
||||
return "Z" not in line
|
||||
return True
|
||||
except (ProcessLookupError, FileNotFoundError) as e:
|
||||
logger.warning("[wake] PID %s not found: %s", pid, e)
|
||||
return False
|
||||
except PermissionError as e:
|
||||
logger.warning("[wake] PID %s permission denied: %s", pid, e)
|
||||
return True # Exists but can't check — assume alive
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[wake] PID %s /proc not found: %s", pid, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _spawn_in_systemd_scope(monitor_cmd, branch_path, spawn_env, branch_email, lock_file_path, custom_message, status):
|
||||
|
||||
@@ -814,7 +814,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -823,17 +823,14 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_check_lock_dead_pid(tmp_path, monkeypatch):
|
||||
"""Lock with dead PID (ProcessLookupError) is cleaned up."""
|
||||
"""Lock with dead PID is cleaned up."""
|
||||
lock_dir = tmp_path / ".ai_mail.local"
|
||||
lock_dir.mkdir(parents=True)
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -849,10 +846,7 @@ def test_check_lock_permission_error(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_permission(pid, sig):
|
||||
raise PermissionError("Operation not permitted")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_permission)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -869,10 +863,7 @@ def test_check_lock_stale_over_10min_removed(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": old_time}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -889,10 +880,7 @@ def test_check_lock_stale_under_10min_dead_pid_removed(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": recent_time}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 1234, "timestamp": "2026-03-29T10:00:00"}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: True)
|
||||
result = _check_lock(tmp_path)
|
||||
assert result is not None
|
||||
assert result["pid"] == 1234
|
||||
@@ -235,7 +235,7 @@ def test_check_lock_dead_pid_removes_lock(tmp_path, monkeypatch):
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 99999, "timestamp": "2026-03-29T10:00:00"}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: False)
|
||||
result = _check_lock(tmp_path)
|
||||
assert result is None
|
||||
assert not lock_file.exists()
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,6 +30,57 @@ _LOCK_FILENAME = ".git_pr.lock"
|
||||
_STALE_THRESHOLD_SECONDS = 600
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
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 = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return True if the process is alive. Platform-guarded: Windows uses
|
||||
OpenProcess (os.kill on win32 calls TerminateProcess — kills the target)."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("_pid_alive: PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
logger.info("_pid_alive: PID %s not found", pid)
|
||||
return False
|
||||
except PermissionError:
|
||||
logger.info("_pid_alive: PID %s permission denied (alive)", pid)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.info("_pid_alive: PID %s OSError (assuming dead): %s", pid, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def find_repo_root() -> Path:
|
||||
"""Walk up from CWD looking for AIPASS_REGISTRY.json, fallback to git rev-parse."""
|
||||
cwd = Path.cwd()
|
||||
@@ -183,19 +235,9 @@ def check_lock_status() -> dict:
|
||||
|
||||
# Check if PID is still alive (orphan detection)
|
||||
orphaned = False
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
logger.info("check_lock_status: PID %d not found — lock is orphaned", pid)
|
||||
orphaned = True
|
||||
except PermissionError as exc:
|
||||
# Process exists but we can't signal it — not orphaned
|
||||
logger.warning("check_lock_status: PID %d exists but permission denied for signal check: %s", pid, exc)
|
||||
except OSError:
|
||||
# On Windows, os.kill(pid, 0) raises OSError for non-existent PIDs
|
||||
logger.info("check_lock_status: PID %d not found (OSError) — lock is orphaned", pid)
|
||||
orphaned = True
|
||||
if pid and not _pid_alive(pid):
|
||||
logger.info("check_lock_status: PID %d not alive — lock is orphaned", pid)
|
||||
orphaned = True
|
||||
|
||||
status = "active"
|
||||
if orphaned:
|
||||
|
||||
@@ -19,6 +19,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
@@ -26,6 +27,57 @@ from aipass.prax import logger
|
||||
from aipass.flow.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only
|
||||
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 = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return True if the process is alive. Platform-guarded: win32 uses
|
||||
OpenProcess instead of os.kill (which terminates on Windows)."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError as exc:
|
||||
logger.info("PID %s not found: %s", pid, exc)
|
||||
return False
|
||||
except PermissionError as exc:
|
||||
logger.info("PID %s permission denied (alive): %s", pid, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.info("PID %s os.kill error (assuming dead): %s", pid, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def try_create_lock(lock_file: Path) -> bool:
|
||||
"""Atomically create lock file with current PID. Returns True on success."""
|
||||
try:
|
||||
@@ -42,12 +94,14 @@ def is_lock_stale(lock_file: Path) -> bool:
|
||||
"""Check if existing lock file belongs to a dead process."""
|
||||
try:
|
||||
pid = int(lock_file.read_text(encoding="utf-8").strip())
|
||||
os.kill(pid, 0)
|
||||
except (ValueError, OSError):
|
||||
logger.info("Stale lock found (unreadable), taking over: %s", lock_file)
|
||||
return True
|
||||
if _pid_alive(pid):
|
||||
logger.info("Another instance running (PID %d), lock valid: %s", pid, lock_file)
|
||||
return False
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
logger.info("Stale lock found, taking over: %s", lock_file)
|
||||
return True
|
||||
logger.info("Stale lock found (PID %d dead), taking over: %s", pid, lock_file)
|
||||
return True
|
||||
|
||||
|
||||
def acquire_lock(lock_file: Path) -> bool:
|
||||
|
||||
@@ -99,14 +99,14 @@ class TestIsLockStale:
|
||||
result = mod.is_lock_stale(lock)
|
||||
assert result is True
|
||||
|
||||
def test_permission_error_treated_as_stale(self, tmp_path):
|
||||
"""PermissionError from os.kill should treat lock as stale."""
|
||||
def test_permission_error_treated_as_alive(self, tmp_path):
|
||||
"""PermissionError from os.kill means process exists — lock valid."""
|
||||
mod = _import_lock_ops()
|
||||
lock = tmp_path / ".test.lock"
|
||||
lock.write_text("1", encoding="utf-8")
|
||||
with patch(f"{_MOD}.os.kill", side_effect=PermissionError):
|
||||
result = mod.is_lock_stale(lock)
|
||||
assert result is True
|
||||
assert result is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -20,6 +20,7 @@ Used by presence_gate to source truth instead of PRESENCE.central.json.
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
@@ -30,10 +31,44 @@ CONSOLE = err_console
|
||||
CC_SESSIONS_DIR = Path.home() / ".claude" / "sessions"
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only
|
||||
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 = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _is_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process with the given PID exists."""
|
||||
if pid <= 1:
|
||||
return False
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[CC_SESSIONS] PID %d Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
|
||||
@@ -187,8 +187,42 @@ def _resolve_session_pid() -> int | None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only
|
||||
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 = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _is_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process with the given PID exists."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[PRESENCE] PID %d Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user