"""Background process control for ``nanobot gateway``. This module intentionally stays small: the CLI owns command wording, while this runtime owns process state, log files, and platform-specific detach/stop details. """ from __future__ import annotations import ctypes import json import os import signal import subprocess import sys import tempfile import time from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any from nanobot.config.paths import get_data_dir @dataclass(frozen=True) class GatewayStartOptions: """Options needed to start a background gateway instance.""" port: int verbose: bool = False workspace: str | None = None config_path: str | None = None @dataclass(frozen=True) class GatewayStatus: """Current background gateway status.""" running: bool pid: int | None state_path: Path log_path: Path started_at: str | None = None port: int | None = None command: tuple[str, ...] = () reason: str = "not_started" @dataclass(frozen=True) class RuntimeResult: """Result from a gateway runtime control operation.""" ok: bool message: str status: GatewayStatus def build_gateway_command(python_executable: str, options: GatewayStartOptions) -> list[str]: """Build a foreground gateway command for process supervisors.""" command = [ python_executable, "-m", "nanobot", "gateway", "--foreground", "--port", str(options.port), ] if options.verbose: command.append("--verbose") if options.workspace: command.extend(["--workspace", options.workspace]) if options.config_path: command.extend(["--config", options.config_path]) return command @dataclass(frozen=True) class GatewayRuntimePaths: """Filesystem layout for one gateway runtime instance.""" run_dir: Path logs_dir: Path state_path: Path log_path: Path @classmethod def for_instance( cls, *, data_dir: Path | None = None, workspace: str | None = None, config_path: str | None = None, ) -> "GatewayRuntimePaths": base = data_dir or get_data_dir() suffix = _instance_suffix(workspace=workspace, config_path=config_path) run_dir = base / "run" logs_dir = base / "logs" stem = "gateway" if suffix is None else f"gateway.{suffix}" return cls( run_dir=run_dir, logs_dir=logs_dir, state_path=run_dir / f"{stem}.json", log_path=logs_dir / f"{stem}.log", ) class GatewayRuntime: """Manage a background ``nanobot gateway`` process.""" def __init__( self, *, paths: GatewayRuntimePaths | None = None, platform_name: str | None = None, python_executable: str | None = None, popen: Callable[..., Any] = subprocess.Popen, subprocess_run: Callable[..., Any] = subprocess.run, sleep: Callable[[float], None] = time.sleep, ) -> None: self.paths = paths or GatewayRuntimePaths.for_instance() self.platform_name = platform_name or _platform_name() self.python_executable = python_executable or sys.executable self._popen = popen self._subprocess_run = subprocess_run self._sleep = sleep @classmethod def refresh_state_pid(cls, *, paths: GatewayRuntimePaths) -> None: """Update the PID in an existing state file to ``os.getpid()``. Called early in gateway server startup so the state file self-heals after any restart, regardless of platform or restart mechanism. """ if not paths.state_path.exists(): return try: state = json.loads(paths.state_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return state["pid"] = os.getpid() rt = cls(paths=paths) state["identity"] = rt._process_identity(os.getpid()) state["started_at"] = _utc_now() rt._write_state(state) def start_background(self, options: GatewayStartOptions) -> RuntimeResult: """Start gateway as a detached background process.""" current = self.status() if current.running: return RuntimeResult(False, "gateway_already_running", current) command = self._build_child_command(options) self.paths.run_dir.mkdir(parents=True, exist_ok=True) self.paths.logs_dir.mkdir(parents=True, exist_ok=True) with self.paths.log_path.open("a", encoding="utf-8") as log_handle: process = self._popen( command, stdin=subprocess.DEVNULL, stdout=log_handle, stderr=subprocess.STDOUT, **self._popen_platform_kwargs(), ) pid = int(process.pid) self._sleep(0.2) if not self._is_pid_running(pid): return RuntimeResult(False, "gateway_exited_during_startup", self.status()) identity = self._process_identity(pid) self._write_state( { "pid": pid, "identity": identity, "started_at": _utc_now(), "platform": self.platform_name, "port": options.port, "workspace": options.workspace, "config_path": options.config_path, "command": command, "log_path": str(self.paths.log_path), } ) return RuntimeResult(True, "gateway_started_background", self.status()) def stop(self, *, timeout_s: int = 20) -> RuntimeResult: """Stop the recorded background gateway process.""" status = self.status() if not status.pid: return RuntimeResult(False, "gateway_not_running", status) state = self._read_state() if not self._record_matches_process(state, status.pid): self._clear_state() return RuntimeResult(False, "gateway_state_stale", self.status(reason="stale_state")) if not self._terminate(status.pid, timeout_s=timeout_s): return RuntimeResult(False, "gateway_stop_timeout", self.status(reason="stop_timeout")) self._clear_state() return RuntimeResult(True, "gateway_stopped", self.status(reason="stopped")) def restart(self, options: GatewayStartOptions, *, timeout_s: int = 20) -> RuntimeResult: """Restart the background gateway.""" stop_result = self.stop(timeout_s=timeout_s) if not stop_result.ok and stop_result.message not in {"gateway_not_running", "gateway_state_stale"}: return stop_result return self.start_background(options) def status(self, *, reason: str | None = None) -> GatewayStatus: """Return live status, clearing stale state when needed.""" state = self._read_state() pid = _as_int(state.get("pid")) if state else None if pid is None: return GatewayStatus( running=False, pid=None, state_path=self.paths.state_path, log_path=self.paths.log_path, reason=reason or "not_started", ) if not self._is_pid_running(pid) or not self._record_matches_process(state, pid): self._clear_state() return GatewayStatus( running=False, pid=None, state_path=self.paths.state_path, log_path=self.paths.log_path, reason=reason or "stale_state", ) command = state.get("command") return GatewayStatus( running=True, pid=pid, state_path=self.paths.state_path, log_path=self.paths.log_path, started_at=_as_str(state.get("started_at")), port=_as_int(state.get("port")), command=tuple(command) if isinstance(command, list) else (), reason=reason or "running", ) def read_log_tail(self, *, tail: int = 200) -> list[str]: """Return the last ``tail`` log lines.""" if tail <= 0 or not self.paths.log_path.exists(): return [] try: lines = self.paths.log_path.read_text(encoding="utf-8", errors="replace").splitlines() except OSError: return [] return lines[-tail:] def follow_logs(self, *, tail: int = 200) -> int: """Print existing log tail and follow new log lines.""" for line in self.read_log_tail(tail=tail): print(line) self.paths.logs_dir.mkdir(parents=True, exist_ok=True) self.paths.log_path.touch(exist_ok=True) try: with self.paths.log_path.open("r", encoding="utf-8", errors="replace") as handle: handle.seek(0, os.SEEK_END) while True: line = handle.readline() if line: print(line.rstrip("\n")) else: self._sleep(0.5) except KeyboardInterrupt: return 130 def _build_child_command(self, options: GatewayStartOptions) -> list[str]: return build_gateway_command(self.python_executable, options) def _popen_platform_kwargs(self) -> dict[str, Any]: if self.platform_name == "Windows": flags = 0 flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0) return {"creationflags": flags} return {"start_new_session": True} def _terminate(self, pid: int, *, timeout_s: int) -> bool: if self.platform_name == "Windows": return self._terminate_windows(pid, timeout_s=timeout_s) return self._terminate_posix(pid, timeout_s=timeout_s) def _terminate_posix(self, pid: int, *, timeout_s: int) -> bool: try: pgid = os.getpgid(pid) except OSError: pgid = None try: if pgid is not None: os.killpg(pgid, signal.SIGTERM) else: os.kill(pid, signal.SIGTERM) except ProcessLookupError: return True if self._wait_for_exit(pid, timeout_s): return True with suppress(ProcessLookupError): if pgid is not None: os.killpg(pgid, signal.SIGKILL) else: os.kill(pid, signal.SIGKILL) return self._wait_for_exit(pid, 2) def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool: ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None) if ctrl_break is not None: # Detached Windows children can reject CTRL_BREAK_EVENT with WinError 87; # keep the existing taskkill fallback for that process shape. ctrl_break_sent = False try: os.kill(pid, ctrl_break) except ProcessLookupError: return True except OSError: pass else: ctrl_break_sent = True if ctrl_break_sent and self._wait_for_exit(pid, timeout_s): return True self._subprocess_run( ["taskkill", "/PID", str(pid), "/T"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if self._wait_for_exit(pid, 2): return True self._subprocess_run( ["taskkill", "/PID", str(pid), "/T", "/F"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return self._wait_for_exit(pid, 2) def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool: deadline = time.monotonic() + max(float(timeout_s), 0.0) while time.monotonic() < deadline: if not self._is_pid_running(pid): return True self._sleep(0.1) return not self._is_pid_running(pid) def _is_pid_running(self, pid: int) -> bool: if pid <= 0: return False if self.platform_name == "Windows": return _windows_process_identity(pid) is not None try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True except OSError: return False return True def _process_identity(self, pid: int) -> str | int | None: if self.platform_name == "Windows": return _windows_process_identity(pid) try: return os.getpgid(pid) except OSError: return None def _record_matches_process(self, state: dict[str, Any] | None, pid: int) -> bool: if not state: return False recorded = state.get("identity") if recorded is None: return True return recorded == self._process_identity(pid) def _read_state(self) -> dict[str, Any] | None: try: with self.paths.state_path.open(encoding="utf-8") as handle: payload = json.load(handle) except (OSError, json.JSONDecodeError, ValueError): return None return payload if isinstance(payload, dict) else None def _write_state(self, payload: dict[str, Any]) -> None: self.paths.run_dir.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp( prefix=f"{self.paths.state_path.name}.", suffix=".tmp", dir=self.paths.run_dir, ) tmp_path = Path(tmp_name) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2, ensure_ascii=False) handle.write("\n") handle.flush() os.fsync(handle.fileno()) tmp_path.replace(self.paths.state_path) finally: tmp_path.unlink(missing_ok=True) def _clear_state(self) -> None: self.paths.state_path.unlink(missing_ok=True) def _instance_suffix(*, workspace: str | None, config_path: str | None) -> str | None: raw = "|".join(value for value in (workspace, config_path) if value) if not raw: return None import hashlib return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] def _platform_name() -> str: if sys.platform.startswith("win"): return "Windows" if sys.platform == "darwin": return "Darwin" return "Linux" def _utc_now() -> str: return datetime.now(UTC).isoformat().replace("+00:00", "Z") def _as_int(value: object) -> int | None: if isinstance(value, int): return value if isinstance(value, str): try: return int(value) except ValueError: return None return None def _as_str(value: object) -> str | None: return value if isinstance(value, str) else None def _windows_process_identity(pid: int) -> str | None: if os.name != "nt": return None class FileTime(ctypes.Structure): _fields_ = [("low", ctypes.c_uint32), ("high", ctypes.c_uint32)] @property def value(self) -> int: return (int(self.high) << 32) | int(self.low) process_query_limited_information = 0x1000 kernel32 = ctypes.windll.kernel32 handle = kernel32.OpenProcess(process_query_limited_information, False, pid) if not handle: return None try: creation_time = FileTime() exit_time = FileTime() kernel_time = FileTime() user_time = FileTime() ok = kernel32.GetProcessTimes( handle, ctypes.byref(creation_time), ctypes.byref(exit_time), ctypes.byref(kernel_time), ctypes.byref(user_time), ) if not ok: return None exit_code = ctypes.c_uint32() if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): return None if exit_code.value != 259: return None return str(creation_time.value) finally: kernel32.CloseHandle(handle)