diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py index 8ba079a7..2874caef 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py @@ -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") diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index 8bcf539f..b97b5115 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -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): diff --git a/src/aipass/ai_mail/tests/test_daemon.py b/src/aipass/ai_mail/tests/test_daemon.py index d0e521ab..fe153bcd 100644 --- a/src/aipass/ai_mail/tests/test_daemon.py +++ b/src/aipass/ai_mail/tests/test_daemon.py @@ -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) diff --git a/src/aipass/ai_mail/tests/test_wake.py b/src/aipass/ai_mail/tests/test_wake.py index d715891f..04323f6b 100644 --- a/src/aipass/ai_mail/tests/test_wake.py +++ b/src/aipass/ai_mail/tests/test_wake.py @@ -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() diff --git a/src/aipass/drone/apps/handlers/git/lock_handler.py b/src/aipass/drone/apps/handlers/git/lock_handler.py index c3da99f8..cd94e03b 100644 --- a/src/aipass/drone/apps/handlers/git/lock_handler.py +++ b/src/aipass/drone/apps/handlers/git/lock_handler.py @@ -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: diff --git a/src/aipass/flow/apps/handlers/runner/lock_ops.py b/src/aipass/flow/apps/handlers/runner/lock_ops.py index 6d21388c..86be0cdd 100644 --- a/src/aipass/flow/apps/handlers/runner/lock_ops.py +++ b/src/aipass/flow/apps/handlers/runner/lock_ops.py @@ -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: diff --git a/src/aipass/flow/tests/test_lock_ops.py b/src/aipass/flow/tests/test_lock_ops.py index ba589c53..36f6fa40 100644 --- a/src/aipass/flow/tests/test_lock_ops.py +++ b/src/aipass/flow/tests/test_lock_ops.py @@ -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 # ═══════════════════════════════════════════════════════════ diff --git a/src/aipass/hooks/apps/modules/cc_sessions.py b/src/aipass/hooks/apps/modules/cc_sessions.py index f8bd872b..f469537c 100644 --- a/src/aipass/hooks/apps/modules/cc_sessions.py +++ b/src/aipass/hooks/apps/modules/cc_sessions.py @@ -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 diff --git a/src/aipass/hooks/apps/modules/presence.py b/src/aipass/hooks/apps/modules/presence.py index 569af348..cefee82b 100644 --- a/src/aipass/hooks/apps/modules/presence.py +++ b/src/aipass/hooks/apps/modules/presence.py @@ -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