Initial commit: scan_web integrated recording UI
FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/ scan_gui_triple desktop tools into one web app.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""Subprocess orchestration for lidar/camera/recording processes.
|
||||
|
||||
ManagedProcess.stop() is a near-literal port of the `_kill_proc` pattern used
|
||||
by scan_gui.py / scan_gui_dual.py / scan_gui_triple.py: SIGINT the whole
|
||||
process group (so `ros2 launch` subtrees and `ros2 bag record` get a chance
|
||||
to shut down / flush cleanly), wait, then SIGKILL stragglers. This must not
|
||||
regress — it's the one piece of logic explicitly called out as load-bearing
|
||||
in the design plan.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
|
||||
logger = logging.getLogger("scan_web.process_manager")
|
||||
|
||||
|
||||
class ManagedProcess:
|
||||
def __init__(self, name: str, cmd: str, log_path: Optional[Path] = None, env: Optional[dict] = None):
|
||||
self.name = name
|
||||
self.cmd = cmd
|
||||
self.log_path = log_path
|
||||
self.env = env
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._log_file = None
|
||||
self._lock = threading.Lock()
|
||||
self.started_at: Optional[float] = None
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self.is_running():
|
||||
return
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
if self.env:
|
||||
env.update(self.env)
|
||||
|
||||
stdout = subprocess.DEVNULL
|
||||
stderr = subprocess.DEVNULL
|
||||
if self.log_path is not None:
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._log_file = open(self.log_path, "w")
|
||||
stdout = self._log_file
|
||||
stderr = subprocess.STDOUT
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
["bash", "-c", self.cmd],
|
||||
stdout=stdout, stderr=stderr,
|
||||
preexec_fn=os.setsid, env=env,
|
||||
)
|
||||
self.started_at = time.monotonic()
|
||||
logger.info("started %s pid=%s", self.name, self._proc.pid)
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def pid(self) -> Optional[int]:
|
||||
return self._proc.pid if self._proc else None
|
||||
|
||||
def stop(self, timeout: float = 3.0) -> None:
|
||||
with self._lock:
|
||||
proc = self._proc
|
||||
if proc is None or proc.poll() is not None:
|
||||
self._cleanup()
|
||||
return
|
||||
try:
|
||||
parent = psutil.Process(proc.pid)
|
||||
children = parent.children(recursive=True)
|
||||
except psutil.NoSuchProcess:
|
||||
self._cleanup()
|
||||
return
|
||||
for p in children + [parent]:
|
||||
try:
|
||||
p.send_signal(signal.SIGINT)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
_, alive = psutil.wait_procs(children + [parent], timeout=timeout)
|
||||
for p in alive:
|
||||
try:
|
||||
p.kill()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
logger.info("stopped %s", self.name)
|
||||
self._cleanup()
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
self._proc = None
|
||||
self.started_at = None
|
||||
if self._log_file:
|
||||
self._log_file.close()
|
||||
self._log_file = None
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
"""App-lifetime singleton (stored on app.state) tracking all managed processes."""
|
||||
|
||||
def __init__(self):
|
||||
self._procs: dict[str, ManagedProcess] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start(self, name: str, cmd: str, log_path: Optional[Path] = None, env: Optional[dict] = None) -> ManagedProcess:
|
||||
with self._lock:
|
||||
mp = self._procs.get(name)
|
||||
if mp is None or not mp.is_running():
|
||||
mp = ManagedProcess(name, cmd, log_path=log_path, env=env)
|
||||
self._procs[name] = mp
|
||||
mp.start()
|
||||
return mp
|
||||
|
||||
def stop(self, name: str, timeout: float = 3.0) -> None:
|
||||
with self._lock:
|
||||
mp = self._procs.get(name)
|
||||
if mp is not None:
|
||||
mp.stop(timeout=timeout)
|
||||
|
||||
def is_running(self, name: str) -> bool:
|
||||
with self._lock:
|
||||
mp = self._procs.get(name)
|
||||
return mp is not None and mp.is_running()
|
||||
|
||||
def status(self) -> dict[str, dict]:
|
||||
with self._lock:
|
||||
items = list(self._procs.items())
|
||||
return {
|
||||
name: {"running": mp.is_running(), "pid": mp.pid()}
|
||||
for name, mp in items
|
||||
}
|
||||
|
||||
def stop_all(self, timeout: float = 3.0) -> None:
|
||||
with self._lock:
|
||||
names = list(self._procs.keys())
|
||||
for name in names:
|
||||
self.stop(name, timeout=timeout)
|
||||
Reference in New Issue
Block a user