This commit is contained in:
root
2026-05-17 12:09:14 +00:00
parent a10ffe332d
commit ffdc887b8a
30 changed files with 728 additions and 111 deletions

View File

@@ -84,13 +84,17 @@ async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
def _ffmpeg_cmdline_markers(process_name: str, project_root: Path | None) -> list[str]:
safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", process_name).strip()
markers = {str(process_name or "").strip(), safe}
markers: set[str] = set()
if project_root is not None and safe:
config_dir = project_root / "config"
markers.add(f"youtube.{safe}.ini")
markers.add(f"URL_config.{safe}.ini")
markers.add(str(config_dir / f"youtube.{safe}.ini"))
markers.add(str(config_dir / f"URL_config.{safe}.ini"))
markers.update(
{
f"youtube.{safe}.ini",
f"URL_config.{safe}.ini",
str(config_dir / f"youtube.{safe}.ini"),
str(config_dir / f"URL_config.{safe}.ini"),
}
)
return [m for m in markers if m]
@@ -116,6 +120,16 @@ def _read_proc_env_value(pid: int, key: str) -> str:
return ""
def _ffmpeg_pid_matches_process(pid: int, process_name: str) -> bool:
target = (process_name or "").strip()
if not target:
return False
for key in ("LIVE_PM2_PROCESS_NAME", "PM2_SERVICE_NAME"):
if _read_proc_env_value(pid, key) == target:
return True
return False
def _proc_parent_and_group(pid: int) -> tuple[int | None, int | None]:
try:
raw = (Path("/proc") / str(pid) / "stat").read_text(encoding="utf-8", errors="ignore")
@@ -178,7 +192,7 @@ def _linux_ffmpeg_pids_for_process_tree(root_pid: int, *, root_pgid: int | None
def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) -> list[int]:
"""仅匹配与本项目直播栈相关的 ffmpeg命令行含 YouTube 推流、抖音拉流或项目路径),避免 killall 误伤整机其它 ffmpeg"""
"""仅匹配继承了目标进程环境变量的 ffmpeg避免按关键词误杀其它线路"""
markers = _ffmpeg_cmdline_markers(process_name, project_root)
seen: set[int] = set()
proc_root = Path("/proc")
@@ -198,6 +212,9 @@ def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) -
continue
if not raw or b"ffmpeg" not in raw.lower():
continue
if _ffmpeg_pid_matches_process(pid, process_name):
seen.add(pid)
continue
cmd = raw.replace(b"\0", b" ").decode("utf-8", "replace")
if any(m in cmd for m in markers):
seen.add(pid)
@@ -420,9 +437,64 @@ def resolve_pm2_bin() -> Optional[str]:
def preferred_python_interpreter(base_dir: Path) -> str:
venv_python = base_dir / ".venv" / "bin" / "python"
if venv_python.is_file():
return str(venv_python)
return sys.executable or "python3"
candidates = [
str(venv_python),
shutil_which("python3"),
shutil_which("python"),
sys.executable,
]
for candidate in candidates:
if not candidate:
continue
path = Path(candidate).expanduser()
if path.is_file() and os.access(str(path), os.X_OK):
return str(path)
return "python3"
def _resolved_pm2_item_path(item: dict[str, Any], *keys: str) -> Optional[Path]:
env = item.get("pm2_env") if isinstance(item.get("pm2_env"), dict) else {}
for key in keys:
raw = env.get(key)
if raw in (None, ""):
raw = item.get(key)
value = str(raw or "").strip()
if value:
return Path(value).expanduser()
return None
def _samefile_or_resolved_path(left: Path, right: Path) -> bool:
try:
return left.resolve() == right.resolve()
except OSError:
return False
def pm2_process_entry_needs_recreate(base_dir: Path, process: str, script: str, item: dict[str, Any] | None) -> bool:
if not item:
return False
script_path = (base_dir / script).resolve()
configured_script = _resolved_pm2_item_path(item, "pm_exec_path")
if configured_script is None or not _samefile_or_resolved_path(configured_script, script_path):
return True
configured_cwd = _resolved_pm2_item_path(item, "pm_cwd", "cwd")
if configured_cwd is None or not _samefile_or_resolved_path(configured_cwd, base_dir.resolve()):
return True
suffix = script_path.suffix.lower()
if suffix == ".py":
interpreter = _resolved_pm2_item_path(item, "exec_interpreter")
if interpreter is None or not interpreter.is_file() or not os.access(str(interpreter), os.X_OK):
return True
elif suffix == ".sh":
interpreter = _resolved_pm2_item_path(item, "exec_interpreter")
if interpreter is not None and (not interpreter.is_file() or not os.access(str(interpreter), os.X_OK)):
return True
return False
def build_pm2_start_command(base_dir: Path, process: str, script: str) -> Optional[str]:
@@ -946,12 +1018,33 @@ class WebProcessBackend:
with contextlib.suppress(Exception):
await run_pm2("save", timeout=20.0)
async def _pm2_start_fresh(self, process: str, script: str) -> str:
await self.clear_process_logs(process)
cmd = build_pm2_start_command(self.base_dir, process, script)
if not cmd:
return f"ERROR: script not found: {script}"
code, text = await _shell_out(cmd, timeout=45.0)
if code == 0:
await self._set_pm2_desired_running(process, True)
await self._pm2_save()
self.invalidate_pm2_cache()
return text
async def _pm2_delete_existing_process(self, process: str) -> None:
with contextlib.suppress(Exception):
await run_pm2_result(f"delete {process}", timeout=20.0)
self.invalidate_pm2_cache()
await asyncio.sleep(0.2)
async def start(self, process: str, script: str) -> str:
await self.ensure_pm2_runtime()
if self._use_pm2:
registered = await self._pm2_registered_names()
if process in registered:
info = await self.parse_pm2_describe(process)
item = (await self._pm2_jlist_index(force_refresh=True)).get(process)
if pm2_process_entry_needs_recreate(self.base_dir, process, script, item):
await self._pm2_delete_existing_process(process)
item = None
if item is not None:
info = self._pm2_info_from_item(process, item)
if str(info.get("process_status") or "").lower() not in ("online", "running"):
await self.clear_process_logs(process)
code, text = await run_pm2_result(f"start {process}")
@@ -960,16 +1053,7 @@ class WebProcessBackend:
await self._pm2_save()
self.invalidate_pm2_cache()
return text
await self.clear_process_logs(process)
cmd = build_pm2_start_command(self.base_dir, process, script)
if not cmd:
return f"ERROR: script not found: {script}"
code, text = await _shell_out(cmd, timeout=45.0)
if code == 0:
await self._set_pm2_desired_running(process, True)
await self._pm2_save()
self.invalidate_pm2_cache()
return text
return await self._pm2_start_fresh(process, script)
return await self._local.start(process, script)
async def stop(self, process: str) -> str:
@@ -986,9 +1070,12 @@ class WebProcessBackend:
async def restart(self, process: str, script: str) -> str:
await self.ensure_pm2_runtime()
if self._use_pm2:
registered = await self._pm2_registered_names()
if process not in registered:
item = (await self._pm2_jlist_index(force_refresh=True)).get(process)
if item is None:
return await self.start(process, script)
if pm2_process_entry_needs_recreate(self.base_dir, process, script, item):
await self._pm2_delete_existing_process(process)
return await self._pm2_start_fresh(process, script)
await self.clear_process_logs(process)
code, text = await run_pm2_result(f"restart {process}")
if code == 0: