"""M10 启动生命周期:多实例 / 多版本 / **重复启动** 的运行时管理。 ## 为什么需要这一层(你 2026-09-26 的定位) > 「我们这个框架本身类似给 agent 用的 MC 启动器,只不过后端是我们魔改出来的。 > 所以多实例,多版本隔离,单个实例多次启动是自然而然被要求支持的。」 把这句话翻成具体能力,缺的是这三样里的运行时部分: | 要求 | 现状 | 缺什么 | |---|---|---| | 多实例 | 目录隔离已在(`instances/`) | 缺「谁在占用它」的管理 | | 多版本隔离 | 内容根按 `store/-/` 分开 | 已有判据守着(`test_version_isolation`) | | **单个实例多次启动** | 后端用 `Popen` 起了 JVM | **没有 pid 记录、没有重入防护、没有崩溃残留清理** | 「重复启动」具体会坏在哪(都是实测/推理出来的,不是假想): 1. **同实例并发启动** ⇒ 两个 JVM 抢同一个 `run/` 与同一个 X display ⇒ 一方起来,另一方渲染错乱或直接失败,而**报错完全指不到「重复启动」**。 2. **上次崩溃残留** ⇒ pid 文件还在,但没有进程;下次启动以为「在跑」而拒绝。 3. **pid 复用** ⇒ 旧 pid 被系统分给别的进程,误杀无关进程。 ⇒ 必须校验「这个 pid 是不是我们的 JVM」(读 cmdline)。 ## 三条纪律 1. **不静默**:重入冲突要**明确报**「这个实例已在运行(pid=N,启动于 T)」, 而不是让 JVM 起来后渲染出错。错误信息要能直接指导下一步。 2. **不误杀**:pid 必须经 cmdline 校验属于本实例的 JVM 才允许终止。 3. **残留可清**:识别「有 pid 文件但进程已死」⇒ 报告并允许清理, 不把垃圾状态留给下一次启动。 """ from __future__ import annotations import json import os import signal import time from pathlib import Path from typing import Any # ---------- pid 文件(每个实例私有) ---------- PID_FILE = "mcfold.pid" META_FILE = "mcfold.session.json" def runtime_dir_for(instance_path: str | os.PathLike[str], runtime_root: str | os.PathLike[str] | None = None) -> Path: """实例 → 运行时目录(**第三层**,与内容根/实例层同级)。 ★ 隔离要求(`tests/test_version_isolation.py` 守的同一口径): 运行时状态(pid / display / 截图 / 日志)属于**实例独占**, 放内容根或实例目录都违反分层。 """ inst = Path(instance_path).resolve() name = inst.name base = Path(runtime_root) if runtime_root else _default_runtime_root() d = base / name d.mkdir(parents=True, exist_ok=True) return d def _default_runtime_root() -> Path: """从实例位置推出 runtime 根:`/instances/` → `/runtime`。""" inst = Path(os.environ.get("MCFOLD_INSTANCES", "/home/newqqagent/mcview/instances")) return inst.parent / "runtime" # ---------- 进程身份校验(防 pid 复用误杀) ---------- def _cmdline_of(pid: int) -> str: try: with open(f"/proc/{pid}/cmdline", "rb") as f: return f.read().replace(b"\0", b" ").decode("utf-8", "replace") except OSError: return "" def _is_our_jvm(pid: int, instance_path: str | os.PathLike[str]) -> bool: """这个 pid 是不是**本实例的** JVM? ★ 必须校验,否则会误杀无关进程:pid 会被系统回收复用, 一个早已结束的 JVM 的 pid 可能已经属于别的程序。 """ if pid <= 0: return False cl = _cmdline_of(pid) if not cl: return False if "java" not in cl: return False inst = Path(instance_path).resolve() # 判据:cmdline 里带本实例的运行时目录或实例目录 return str(inst) in cl or inst.name in cl # ---------- 会话记录 ---------- def read_session(instance_path: str | os.PathLike[str], runtime_root: str | os.PathLike[str] | None = None) -> dict[str, Any] | None: """读该实例的会话记录(无则 None)。**不抛异常**。""" d = runtime_dir_for(instance_path, runtime_root) meta = d / META_FILE if not meta.exists(): return None try: return json.loads(meta.read_text(encoding="utf-8")) except Exception: return None def _write_session(instance_path, runtime_root, info: dict[str, Any]) -> Path: d = runtime_dir_for(instance_path, runtime_root) p = d / META_FILE p.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8") return p def _clear_session(instance_path, runtime_root) -> None: d = runtime_dir_for(instance_path, runtime_root) for f in (d / META_FILE, d / PID_FILE): try: f.unlink() except FileNotFoundError: pass # ---------- 对外:状态查询(不启动也能问) ---------- def status(instance_path: str | os.PathLike[str], runtime_root: str | os.PathLike[str] | None = None) -> dict[str, Any]: """这个实例现在有没有在跑?**只观察,不动手**。 返回三态,语义必须分清(agent 应对完全不同): - `running` 真的在跑(pid 存活且身份对得上) - `stale` **有记录但进程已死**(上次崩溃残留)⇒ 可清理 - `free` 没有记录,从没跑过 / 已正常退出 """ sess = read_session(instance_path, runtime_root) if not sess: return {"state": "free", "running": False, "instance": str(instance_path)} pid = sess.get("pid") alive = bool(pid) and _cmdline_of(int(pid)) != "" if not alive: return { "state": "stale", "running": False, "pid": pid, "started_at": sess.get("started_at"), "instance": str(instance_path), "why": f"记录里 pid={pid},但该进程已不存在(上次异常退出的残留)", "how_to": "用 cleanup() 清掉再启动", } ours = _is_our_jvm(int(pid), instance_path) return { "state": "running" if ours else "foreign", "running": ours, "pid": pid, "started_at": sess.get("started_at"), "loader": sess.get("loader"), "version": sess.get("version"), "generation": sess.get("generation"), "instance": str(instance_path), **({} if ours else { "why": f"pid={pid} 存活但**不属于**本实例的 JVM(pid 被复用了)", "how_to": "不要杀它;用 cleanup_stale() 只清自己的记录", }), } def cleanup(instance_path: str | os.PathLike[str], runtime_root: str | os.PathLike[str] | None = None) -> dict[str, Any]: """清理**残留**记录(只清记录,不杀进程)。 ★ 不杀进程的原因:状态是 `foreign` 时 pid 属于别的程序, 杀了就是误杀。只有调用方明确知道那是自己的 JVM 时才该终止。 """ st = status(instance_path, runtime_root) if st["state"] == "running": return {"ok": False, "state": "running", "why": "实例正在运行,不该清它的记录", "how_to": "先 stop() 再清", "pid": st.get("pid")} if st["state"] == "free": return {"ok": True, "state": "free", "cleared": False} _clear_session(instance_path, runtime_root) return {"ok": True, "state": st["state"], "cleared": True, "cleared_pid": st.get("pid")} # ---------- 对外:启动闸门与登记 ---------- class AlreadyRunning(RuntimeError): """重复启动:同一实例已有 JVM 在跑。 ★ 单独一个异常类型,是为了让上层能**区分**这类冲突与别的失败 —— 它们的处理完全不同(这个不该重试,别的可能要重试)。 """ def __init__(self, info: dict[str, Any]): self.info = info super().__init__( f"实例已在运行(pid={info.get('pid')},启动于 {info.get('started_at')})") def claim(instance_path: str | os.PathLike[str], *, runtime_root: str | os.PathLike[str] | None = None, generation: str = "", version: str = "", loader: str = "", allow_stale: bool = True, ) -> dict[str, Any]: """启动前**占位**:检查冲突、清理残留、写入会话记录。 ## 为什么要有这道闸 直接起 JVM 的话,同实例并发启动会让两个进程抢同一个 `run/` 与 同一个 X display,报错会指向渲染异常,**完全指不到「重复启动」**。 ⇒ 冲突必须在启动**之前**就报出来,并说清 pid 与启动时间。 ## 抛 AlreadyRunning 的条件 状态为 `running`(pid 存活且确认是本实例的 JVM)。 """ st = status(instance_path, runtime_root) if st["state"] == "running": raise AlreadyRunning(st) if st["state"] == "stale" and not allow_stale: return {"ok": False, "state": "stale", **st} if st["state"] == "stale": _clear_session(instance_path, runtime_root) # 残留自动清 return { "ok": True, "state": "free", "cleared_stale": st["state"] == "stale", "cleared_pid": st.get("pid") if st["state"] == "stale" else None, } def register(instance_path: str | os.PathLike[str], pid: int, *, runtime_root: str | os.PathLike[str] | None = None, generation: str = "", version: str = "", loader: str = "", extra: dict[str, Any] | None = None) -> Path: """JVM 起来了 ⇒ 登记会话(含 pid 与启动时间)。""" info = { "pid": int(pid), "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "started_ts": time.time(), "generation": generation, "version": version, "loader": loader, "instance": str(Path(instance_path).resolve()), } if extra: info.update(extra) d = runtime_dir_for(instance_path, runtime_root) (d / PID_FILE).write_text(str(int(pid)), encoding="utf-8") return _write_session(instance_path, runtime_root, info) def release(instance_path: str | os.PathLike[str], runtime_root: str | os.PathLike[str] | None = None) -> None: """进程退出了 ⇒ 清会话记录。""" _clear_session(instance_path, runtime_root) def stop(instance_path: str | os.PathLike[str], runtime_root: str | os.PathLike[str] | None = None, timeout: float = 20.0, ) -> dict[str, Any]: """停掉**本实例的** JVM(先校验身份,避免误杀)。 ★ 顺序很重要:先 SIGTERM,超时才 SIGKILL; 且两次都要复核身份 —— pid 可能在我们等待期间被回收复用。 """ st = status(instance_path, runtime_root) if st["state"] == "free": return {"ok": True, "state": "free", "killed": False} pid = st.get("pid") if not pid or not _is_our_jvm(int(pid), instance_path): _clear_session(instance_path, runtime_root) return {"ok": False, "killed": False, "state": st["state"], "why": st.get("why", "pid 不属于本实例,拒绝终止(防误杀)"), "how_to": "用 cleanup() 只清记录"} pid = int(pid) try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: _clear_session(instance_path, runtime_root) return {"ok": True, "killed": False, "state": "gone"} end = time.time() + timeout while time.time() < end: if _cmdline_of(pid) == "": _clear_session(instance_path, runtime_root) return {"ok": True, "killed": True, "pid": pid, "signal": "TERM"} time.sleep(0.25) # 超时 ⇒ 升级到 KILL,但**先复核身份** if _is_our_jvm(pid, instance_path): try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass _clear_session(instance_path, runtime_root) return {"ok": True, "killed": True, "pid": pid, "signal": "KILL", "why": f"{timeout}s 内没退出,已强杀"} _clear_session(instance_path, runtime_root) return {"ok": False, "killed": False, "why": "等待期间 pid 被复用,拒绝强杀(防误杀)"}