ai-lsc/src/ai_lsc/runtime/process.py

127 lines
4.1 KiB
Python
Executable File

"""Generic process manager.
Handles desktop-app launching, process killing via ``pkill``, and
bare ``subprocess.Popen`` calls for non-tmux/non-systemd tools.
"""
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import threading
from collections.abc import Iterable
# Terminal emulator candidates, ordered by preference.
_TERMINALS = [
"xterm", "konsole", "gnome-terminal", "xfce4-terminal",
"lxterminal", "alacritty", "kitty", "wezterm",
]
def detect_terminal() -> str:
"""Return the first available terminal emulator on the system."""
return next((t for t in _TERMINALS if shutil.which(t)), "xterm")
def _to_arg_list(command: str | list[str]) -> list[str]:
"""Coerce a registry command into a safe argv list.
Accepts either a pre-split argv list (preferred) or a single shell-style
string (which is split with :func:`shlex.split`, never passed to a shell).
"""
if isinstance(command, (list, tuple)):
return [str(c) for c in command]
return shlex.split(command)
class ProcessManager:
"""Launch and terminate generic (desktop / CLI) processes.
All launched processes are tracked in :attr:`_launched` so they can be
polled / reaped periodically, preventing zombie accumulation in a
long-lived GUI process.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._launched: list[subprocess.Popen] = []
def launch_desktop(self, command: str | list[str]) -> None:
"""Fire-and-forget launch of a desktop command."""
argv = _to_arg_list(command)
if not argv:
raise ValueError("launch_desktop received an empty command")
with self._lock:
self._launched.append(subprocess.Popen(argv))
def launch_terminal(self, command: str | list[str], env: dict[str, str] | None = None) -> None:
"""Open *command* inside a new terminal emulator window."""
term = detect_terminal()
# xterm, alacritty, kitty, wezterm use -e; others use --
sep = "-e" if term in ("xterm", "alacritty", "kitty", "wezterm") else "--"
cmd_str = shlex.join(_to_arg_list(command)) if isinstance(command, (list, tuple)) else command
argv = [term, sep, "bash", "-c", cmd_str]
with self._lock:
self._launched.append(subprocess.Popen(argv, env=env))
def kill_by_name(self, search_term: str) -> None:
"""Send SIGTERM to all processes matching *search_term*."""
subprocess.run(
["pkill", "-f", search_term],
timeout=5,
stderr=subprocess.DEVNULL,
check=False,
)
def reap(self) -> None:
"""Poll and drop finished children; call periodically from the GUI."""
with self._lock:
still_alive: list[subprocess.Popen] = []
for proc in self._launched:
if proc.poll() is None:
still_alive.append(proc)
self._launched = still_alive
def shutdown(self, timeout: float = 2.0) -> None:
"""Terminate every still-running child on application quit."""
with self._lock:
for proc in self._launched:
if proc.poll() is None:
proc.terminate()
for proc in self._launched:
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
self._launched.clear()
def __del__(self) -> None: # noqa: D401 - best-effort cleanup
try:
self.shutdown()
except Exception:
pass
def safe_env(base: dict[str, str] | None = None, **overrides: str) -> dict[str, str]:
"""Build a clean environment for child processes.
Merges the current ``os.environ`` with any explicit *base* dict and
keyword overrides. Values are coerced to ``str``.
"""
env = dict(os.environ)
if base:
env.update(base)
env.update({k: str(v) for k, v in overrides.items()})
return env
__all__ = [
"ProcessManager",
"detect_terminal",
"safe_env",
"_to_arg_list",
"_TERMINALS",
]