用户定位:框架本身是给 agent 用的 MC 启动器 ⇒ 多实例、多版本隔离、 单个实例多次启动是天然要求。盘点发现缺的是**运行时管理**那一层。 新增 mcfold/launch.py: - claim():启动前的**闸门**(重复启动必须拦在启动之前 —— 不拦的话两个 JVM 抢同一个 run/ 与 X display,报错指向渲染异常, 完全指不到「重复启动」) - register():JVM 起来后登记 pid/启动时间/世代/版本/加载器 - status():四态分清 free / stale(有记录但进程已死=崩溃残留)/ running / foreign(pid 存活但不属于本实例=pid 被复用) - stop():先 SIGTERM 超时再 SIGKILL,**两次都复核身份** - cleanup():只清记录不杀进程(状态 foreign 时杀就是误杀) 防误杀:pid 会被系统回收复用 ⇒ 终止前必须校验 cmdline 含本实例路径。 判据 10 项,用**真 java 进程**(mock 测不出 pid 复用/残留这类缺陷)。 ★ 「模拟进程的形状」必须与真实启动一致:sleep 3600 带路径会拒绝参数退出; bash -c 会 exec 掉自己、cmdline 变成裸 sleep ⇒ 两种都测不到真实情形。 改用 java -cp … Hold <实例路径>(形状同真实 --gameDir)。 4 项变异全被抓。 ★ 又一次**空变异**:把身份校验的 return 改成恒真,看似改了,其实上面还有 「是不是 java」早退 ⇒ 判据假绿。结论重申:**必须先验证变异真的落地** (打印改动后的行为),再信它的结果。 210 项全通过。
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""M10 启动生命周期:多实例 / 多版本 / **重复启动** 的运行时管理。
|
||
|
||
## 为什么需要这一层(你 2026-09-26 的定位)
|
||
|
||
> 「我们这个框架本身类似给 agent 用的 MC 启动器,只不过后端是我们魔改出来的。
|
||
> 所以多实例,多版本隔离,单个实例多次启动是自然而然被要求支持的。」
|
||
|
||
把这句话翻成具体能力,缺的是这三样里的运行时部分:
|
||
|
||
| 要求 | 现状 | 缺什么 |
|
||
|---|---|---|
|
||
| 多实例 | 目录隔离已在(`instances/<name>`) | 缺「谁在占用它」的管理 |
|
||
| 多版本隔离 | 内容根按 `store/<loader>-<version>/` 分开 | 已有判据守着(`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 根:`<base>/instances/<name>` → `<base>/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 被复用,拒绝强杀(防误杀)"}
|