- Set stdin=subprocess.DEVNULL across runtime, client, and utility subprocess calls to avoid hijacking the parent process's stdin. - Run awake service initialization asynchronously on a daemon thread in MCP server startup. - Redirect logger header printing to stderr to ensure stdio JSON-RPC purity. - Add unit and lifecycle tests for MCP stdio protocol handshake and stdin isolation.
322 lines
11 KiB
Python
322 lines
11 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Artemis Server Lifecycle and Process Management.
|
|
|
|
Provides cross-platform facilities to discover, inspect, terminate,
|
|
and restart running Artemis UI / backend server instances regardless
|
|
of which terminal or process spawned them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import Any
|
|
|
|
from artemis.config.paths import ROOT_DIR, get_server_info_file
|
|
from artemis.runtime.device_lock import DeviceExecutionLock
|
|
from artemis.runtime.supervisor import ProcessSupervisor
|
|
from artemis.utils.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def is_port_in_use(port: int, host: str = "127.0.0.1", timeout: float = 0.5) -> bool:
|
|
"""Check whether a TCP port is currently listening / occupied."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.settimeout(timeout)
|
|
try:
|
|
return s.connect_ex((host, port)) == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def write_server_info(
|
|
port: int,
|
|
host: str = "0.0.0.0",
|
|
pid: int | None = None,
|
|
) -> Path:
|
|
"""Persist current Artemis server runtime metadata to well-known path."""
|
|
info_file = get_server_info_file()
|
|
try:
|
|
info_file.parent.mkdir(parents=True, exist_ok=True)
|
|
data = {
|
|
"pid": pid or os.getpid(),
|
|
"port": port,
|
|
"host": host,
|
|
"started_at": time.time(),
|
|
"cwd": str(ROOT_DIR),
|
|
"sys_executable": sys.executable,
|
|
"cmdline": sys.argv,
|
|
}
|
|
info_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
logger.debug(f"Saved Artemis server info (PID: {data['pid']}, port: {port}) to {info_file}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not persist server metadata to {info_file}: {e}")
|
|
return info_file
|
|
|
|
|
|
def read_server_info() -> dict[str, Any] | None:
|
|
"""Read and validate the persisted server runtime metadata if present."""
|
|
info_file = get_server_info_file()
|
|
if not info_file.exists():
|
|
return None
|
|
try:
|
|
content = info_file.read_text(encoding="utf-8").strip()
|
|
if not content:
|
|
return None
|
|
data = json.loads(content)
|
|
if isinstance(data, dict) and "pid" in data:
|
|
return data
|
|
except Exception as e:
|
|
logger.debug(f"Failed to read server info from {info_file}: {e}")
|
|
return None
|
|
|
|
|
|
def clear_server_info() -> None:
|
|
"""Remove the persisted server metadata file if it exists."""
|
|
info_file = get_server_info_file()
|
|
try:
|
|
if info_file.exists():
|
|
info_file.unlink()
|
|
logger.debug(f"Cleared server info file at {info_file}")
|
|
except Exception as e:
|
|
logger.debug(f"Error clearing server info file {info_file}: {e}")
|
|
|
|
|
|
def find_server_pids(port: int = 8000) -> list[int]:
|
|
"""Find all process IDs associated with the Artemis server listening on `port`.
|
|
|
|
Uses a multi-tiered discovery strategy:
|
|
1. Check persisted server metadata file.
|
|
2. Check port listeners via lsof (macOS/Linux), fuser (Linux), or netstat (Windows).
|
|
3. Fallback to psutil net_connections and process table scan.
|
|
"""
|
|
discovered: set[int] = set()
|
|
|
|
# 1. Check metadata file
|
|
info = read_server_info()
|
|
if info and info.get("port") == port:
|
|
saved_pid = info.get("pid")
|
|
if saved_pid and isinstance(saved_pid, int):
|
|
try:
|
|
import psutil
|
|
|
|
if psutil.pid_exists(saved_pid):
|
|
discovered.add(saved_pid)
|
|
except Exception:
|
|
try:
|
|
os.kill(saved_pid, 0)
|
|
discovered.add(saved_pid)
|
|
except OSError:
|
|
pass
|
|
|
|
# 2. Check port listeners with platform-native tools
|
|
# 2a. lsof (macOS & Linux)
|
|
if shutil.which("lsof"):
|
|
try:
|
|
res = subprocess.run(
|
|
["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
|
|
stdin=subprocess.DEVNULL,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2.0,
|
|
)
|
|
if res.returncode == 0 and res.stdout.strip():
|
|
for token in res.stdout.strip().splitlines():
|
|
token = token.strip()
|
|
if token.isdigit():
|
|
discovered.add(int(token))
|
|
except Exception:
|
|
pass
|
|
|
|
# 2b. fuser (Linux fallback)
|
|
if not discovered and shutil.which("fuser"):
|
|
try:
|
|
res = subprocess.run(
|
|
["fuser", f"{port}/tcp"],
|
|
stdin=subprocess.DEVNULL,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2.0,
|
|
)
|
|
for token in res.stdout.strip().split():
|
|
token = token.strip()
|
|
if token.isdigit():
|
|
discovered.add(int(token))
|
|
except Exception:
|
|
pass
|
|
|
|
# 2c. netstat (Windows fallback)
|
|
if not discovered and sys.platform == "win32":
|
|
try:
|
|
res = subprocess.run(
|
|
["netstat", "-ano"],
|
|
stdin=subprocess.DEVNULL,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=3.0,
|
|
)
|
|
if res.returncode == 0:
|
|
for line in res.stdout.splitlines():
|
|
if f":{port}" in line and "LISTENING" in line.upper():
|
|
parts = line.strip().split()
|
|
if parts and parts[-1].isdigit():
|
|
discovered.add(int(parts[-1]))
|
|
except Exception:
|
|
pass
|
|
|
|
# 2d. psutil net_connections fallback
|
|
if not discovered:
|
|
try:
|
|
import psutil
|
|
|
|
for conn in psutil.net_connections(kind="inet"):
|
|
if conn.laddr and conn.laddr.port == port:
|
|
if conn.pid:
|
|
discovered.add(conn.pid)
|
|
except Exception:
|
|
pass
|
|
|
|
# 3. Process table scan if still nothing found on port but port is in use
|
|
if not discovered and is_port_in_use(port):
|
|
try:
|
|
import psutil
|
|
|
|
for proc in psutil.process_iter(["pid", "cmdline"]):
|
|
try:
|
|
cmdline = " ".join(proc.info.get("cmdline") or [])
|
|
if "apps.admin_console.server" in cmdline or (
|
|
"artemis" in cmdline and "ui" in cmdline
|
|
):
|
|
if proc.info.get("pid"):
|
|
discovered.add(proc.info["pid"])
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
return sorted(discovered)
|
|
|
|
|
|
def get_server_status(port: int = 8000) -> dict[str, Any]:
|
|
"""Retrieve detailed status of the Artemis server on given port."""
|
|
in_use = is_port_in_use(port)
|
|
pids = find_server_pids(port)
|
|
info = read_server_info()
|
|
|
|
uptime_seconds: float | None = None
|
|
if info and "started_at" in info and isinstance(info["started_at"], (int, float)):
|
|
uptime_seconds = max(0.0, time.time() - info["started_at"])
|
|
|
|
is_running = in_use or bool(pids)
|
|
|
|
return {
|
|
"running": is_running,
|
|
"port": port,
|
|
"pids": pids,
|
|
"active_pid": pids[0] if pids else (info.get("pid") if info else None),
|
|
"uptime_seconds": uptime_seconds,
|
|
"url": f"http://localhost:{port}" if is_running else None,
|
|
"admin_url": f"http://localhost:{port}/admin" if is_running else None,
|
|
"metadata": info,
|
|
}
|
|
|
|
|
|
def stop_server(
|
|
port: int = 8000,
|
|
timeout: float = 4.0,
|
|
force: bool = False,
|
|
) -> tuple[bool, str, list[int]]:
|
|
"""Gracefully terminate any running Artemis server on `port`.
|
|
|
|
Args:
|
|
port: TCP port to inspect and terminate.
|
|
timeout: Maximum seconds to wait for graceful process exit.
|
|
force: If True, immediately send SIGKILL without waiting.
|
|
|
|
Returns:
|
|
tuple (success, message, affected_pids)
|
|
"""
|
|
pids = find_server_pids(port)
|
|
|
|
if not pids and not is_port_in_use(port):
|
|
clear_server_info()
|
|
DeviceExecutionLock.cleanup_stale_locks()
|
|
return True, f"No active server detected on port {port}.", []
|
|
|
|
# If no PID was discovered but port is still in use, try one more aggressive discovery
|
|
if not pids and is_port_in_use(port):
|
|
time.sleep(0.2)
|
|
pids = find_server_pids(port)
|
|
|
|
stopped_pids: list[int] = []
|
|
current_pid = os.getpid()
|
|
|
|
for pid in pids:
|
|
if pid == current_pid:
|
|
# Don't terminate self during stop_server call
|
|
continue
|
|
try:
|
|
if force:
|
|
ProcessSupervisor.terminate_tree(pid, timeout_seconds=0.5)
|
|
else:
|
|
ProcessSupervisor.terminate_tree(pid, timeout_seconds=timeout)
|
|
stopped_pids.append(pid)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to terminate process {pid}: {e}")
|
|
|
|
# Wait for the port to actually become free
|
|
deadline = time.time() + max(1.5, timeout)
|
|
while time.time() < deadline:
|
|
if not is_port_in_use(port):
|
|
break
|
|
time.sleep(0.15)
|
|
|
|
# Force kill if port is still occupied
|
|
if is_port_in_use(port) and not force:
|
|
remaining_pids = find_server_pids(port)
|
|
for pid in remaining_pids:
|
|
if pid != current_pid:
|
|
try:
|
|
ProcessSupervisor.terminate_tree(pid, timeout_seconds=0.5)
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.3)
|
|
|
|
# Cleanup stale device execution locks left by terminated server
|
|
cleaned_locks = DeviceExecutionLock.cleanup_stale_locks()
|
|
clear_server_info()
|
|
|
|
port_free = not is_port_in_use(port)
|
|
if port_free:
|
|
pid_str = f" (PID: {', '.join(map(str, stopped_pids))})" if stopped_pids else ""
|
|
msg = f"Artemis server stopped{pid_str}. Port {port} is released."
|
|
if cleaned_locks:
|
|
msg += f" Cleaned up {cleaned_locks} device lock(s)."
|
|
return True, msg, stopped_pids
|
|
else:
|
|
return (
|
|
False,
|
|
f"Port {port} remains occupied after stop attempt. Some background process may require manual termination.",
|
|
stopped_pids,
|
|
)
|