Files
agentmc/mcfold/launch.py
JianFeeeee 6a9a955472 M10:启动生命周期(多实例/重复启动/残留清理)+ 10 项判据
用户定位:框架本身是给 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 项全通过。
2026-09-26 14:08:06 +08:00

309 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 被复用,拒绝强杀(防误杀)"}