'init'
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled

This commit is contained in:
eric
2026-03-24 14:46:41 -05:00
parent c5a1a9a404
commit b77a8cadb2
164 changed files with 57060 additions and 58 deletions

323
web2_supervisor.py Normal file
View File

@@ -0,0 +1,323 @@
"""
进程编排:默认使用内置 SupervisorWindows / 免 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,
}