""" 进程编排:默认使用内置 Supervisor(Windows / 免 PM2)。 设置环境变量 WEB2_SUPERVISOR=pm2 时使用 PM2(与旧版 Linux 工作流兼容)。 """ from __future__ import annotations import asyncio import json import os import re import shutil import sys from pathlib import Path from typing import Any, Callable, Optional import psutil from web2_proxy_config import merge_child_env def use_builtin() -> bool: return os.environ.get("WEB2_SUPERVISOR", "builtin").strip().lower() != "pm2" def strip_ansi_codes(text: str) -> str: return re.sub(r"\x1b\[[0-9;]*m", "", text) class BuiltinSupervisor: """用本地 PID + 日志文件管理子进程,不依赖 Node/PM2。""" def __init__(self, base_dir: Path, console_script: str, script_resolver: Callable[[str], Optional[str]]): self.base_dir = base_dir.resolve() self.console_script = console_script self.console_stem = Path(console_script).stem self._script_for = script_resolver self.log_dir = self.base_dir / ".pm2" / "logs" self.log_dir.mkdir(parents=True, exist_ok=True) self.state_path = self.base_dir / ".pm2" / "supervisor_state.json" self._io_lock = asyncio.Lock() def _read_state(self) -> dict[str, Any]: if not self.state_path.exists(): return {} try: return json.loads(self.state_path.read_text(encoding="utf-8")) except Exception: return {} def _write_state(self, state: dict[str, Any]) -> None: self.state_path.parent.mkdir(parents=True, exist_ok=True) self.state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") def _pid_alive(self, pid: int | None) -> bool: if pid is None or pid <= 0: return False try: return psutil.Process(pid).is_running() except (psutil.NoSuchProcess, psutil.AccessDenied): return False def _log_paths(self, name: str) -> tuple[Path, Path]: out_p = self.log_dir / f"{name}-out.log" err_p = self.log_dir / f"{name}-error.log" return out_p, err_p def _build_cmd(self, script: str) -> Optional[list[str]]: path = self.base_dir / script if not path.is_file(): return None ext = path.suffix.lower() if ext == ".py": return [sys.executable, str(path)] if ext == ".bat": comspec = os.environ.get("COMSPEC") or "cmd.exe" return [comspec, "/c", str(path)] if ext == ".sh": bash = shutil.which("bash") or shutil.which("sh") if not bash: return None return [bash, str(path)] return None async def start(self, process_name: str) -> str: if process_name == self.console_stem: return "控制台已由本程序托管,无需单独启动。" script = self._script_for(process_name) if not script: return f"未知进程名: {process_name}" argv = self._build_cmd(script) if argv is None: path = self.base_dir / script if not path.is_file(): return f"脚本不存在: {script}" if script.endswith(".sh"): return "未找到 bash/sh,无法运行 .sh(可改用 obs*.bat 或安装 Git for Windows)" return f"无法构建启动命令: {script}" async with self._io_lock: state = self._read_state() entry = state.get(process_name, {}) pid = entry.get("pid") if self._pid_alive(pid): return f"进程已在运行中(PID {pid})。" out_p, err_p = self._log_paths(process_name) out_p.parent.mkdir(parents=True, exist_ok=True) out_f = open(out_p, "ab") err_f = open(err_p, "ab") extra: dict[str, str] = {} if process_name.startswith("youtube2__"): extra["RELAY_CHANNEL_ID"] = process_name[len("youtube2__") :] try: proc = await asyncio.create_subprocess_exec( *argv, cwd=str(self.base_dir), stdout=out_f, stderr=err_f, creationflags=0, env=merge_child_env(extra), ) except Exception as e: out_f.close() err_f.close() return f"启动失败: {e}" out_f.close() err_f.close() pid = proc.pid state[process_name] = { "pid": pid, "script": script, "out_log": str(out_p), "err_log": str(err_p), } self._write_state(state) return f"已启动 {script}(PID {pid}),日志见 .pm2/logs" async def stop(self, process_name: str) -> str: if process_name == self.console_stem: return "请关闭桌面应用窗口以结束控制台。" async with self._io_lock: state = self._read_state() entry = state.get(process_name, {}) pid = entry.get("pid") if not self._pid_alive(pid): state.pop(process_name, None) self._write_state(state) return "进程未在运行(或已退出)。" try: parent = psutil.Process(pid) children = parent.children(recursive=True) for c in children: try: c.terminate() except (psutil.NoSuchProcess, psutil.AccessDenied): pass try: parent.terminate() except (psutil.NoSuchProcess, psutil.AccessDenied): pass gone, alive = psutil.wait_procs([parent] + children, timeout=5) for p in alive: try: p.kill() except (psutil.NoSuchProcess, psutil.AccessDenied): pass except psutil.NoSuchProcess: pass state.pop(process_name, None) self._write_state(state) return f"已停止进程(原 PID {pid})。" async def restart(self, process_name: str) -> str: if process_name == self.console_stem: return "控制台由桌面应用托管,请关闭窗口后重新打开应用。" out = await self.stop(process_name) await asyncio.sleep(0.5) out2 = await self.start(process_name) return f"{out}\n{out2}" async def full_status_text(self) -> str: lines = [ "┌──────────────────┬────────────┬────────────────────────────────────────┐", "│ 进程名 │ 状态 │ 说明 │", "├──────────────────┼────────────┼────────────────────────────────────────┤", ] async with self._io_lock: state = self._read_state() lines.append( f"│ {self.console_stem:<16} │ {'online':<10} │ 控制台(本机 uvicorn) │" ) for name, entry in sorted(state.items()): pid = entry.get("pid") alive = self._pid_alive(pid) st = "online" if alive else "stopped" scr = (entry.get("script") or "")[:38] lines.append(f"│ {name:<16} │ {st:<10} │ {scr:<38} │") lines.append( "└──────────────────┴────────────┴────────────────────────────────────────┘" ) return "\n".join(lines) async def get_process_info(self, process_name: str) -> dict[str, Any]: if process_name == self.console_stem: out_p, err_p = self._log_paths(process_name) return { "process_status": "online", "out_path": str(out_p), "err_path": str(err_p), } async with self._io_lock: state = self._read_state() entry = state.get(process_name, {}) pid = entry.get("pid") out_path = entry.get("out_log") err_path = entry.get("err_log") if not out_path: out_path = str(self._log_paths(process_name)[0]) if not err_path: err_path = str(self._log_paths(process_name)[1]) if not self._pid_alive(pid): return { "process_status": "stopped" if entry else "not_found", "out_path": out_path, "err_path": err_path, } return { "process_status": "online", "out_path": out_path, "err_path": err_path, } async def run_pm2_shell(cmd: str, timeout: float = 15.0) -> str: process = await asyncio.create_subprocess_shell( f"pm2 {cmd}", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) result = (stdout or stderr).decode(errors="ignore").strip() return result if result else "命令执行完成(无输出)" except asyncio.TimeoutError: process.kill() await process.wait() return "ERROR: PM2 命令超时,已强制终止" async def force_kill_ffmpeg_cross_platform(process_name: str) -> None: """按命令行匹配业务进程名,结束相关 ffmpeg。""" def _sync_kill() -> None: for proc in psutil.process_iter(["pid", "name", "cmdline"]): try: name = (proc.info.get("name") or "").lower() if "ffmpeg" not in name: continue cmdline = proc.info.get("cmdline") or [] flat = " ".join(str(x) for x in cmdline) if process_name and process_name not in flat: continue p = psutil.Process(proc.info["pid"]) p.kill() except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): continue await asyncio.to_thread(_sync_kill) await asyncio.sleep(0.35) async def get_process_info_pm2( process: str, default_log_dir: Path, strip_fn: Callable[[str], str], ) -> dict[str, Any]: describe_raw = await run_pm2_shell(f"describe {process}", timeout=10.0) describe_clean = strip_fn(describe_raw) if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip(): return { "process_status": "not_found", "out_path": None, "err_path": None, } status = "unknown" out_path = None err_path = None for line in describe_raw.splitlines(): line_clean = strip_fn(line) if line_clean.count("│") < 2: continue parts = [p.strip() for p in line_clean.split("│") if p.strip()] if len(parts) >= 2: key = parts[0].lower() value = parts[1] if key == "status": status = value.lower() elif key == "out_log_path": out_path = value elif key == "err_log_path": err_path = value if not out_path: out_path = str(default_log_dir / f"{process}-out.log") if not err_path: err_path = str(default_log_dir / f"{process}-error.log") return { "process_status": status, "out_path": out_path, "err_path": err_path, }