'init'
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
This commit is contained in:
323
web2_supervisor.py
Normal file
323
web2_supervisor.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
进程编排:默认使用内置 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")
|
||||
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(),
|
||||
)
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user