diff --git a/mcfold/launch.py b/mcfold/launch.py new file mode 100644 index 0000000..9884656 --- /dev/null +++ b/mcfold/launch.py @@ -0,0 +1,308 @@ +"""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 被复用,拒绝强杀(防误杀)"} diff --git a/plan.md b/plan.md index 3a8a4fd..9b453e1 100644 --- a/plan.md +++ b/plan.md @@ -873,6 +873,56 @@ dig(x=..,y=..,z=..) ok=True → events 收到 block_changed 会把正确实现判红。改为**完整 3D 点积**(dot > 0.99)。 +### M10 启动生命周期(用户 2026-09-26 定位:框架=agent 用的 MC 启动器) + +用户原话: +> 「我们这个框架本身类似给 agent 用的 MC 启动器,只不过后端是我们魔改出来的。 +> 所以多实例,多版本隔离,单个实例多次启动是自然而然被要求支持的」 + +**盘点结果**(对照这三句要求): + +| 要求 | 现状 | 缺什么 | +|---|---|---| +| 多实例 | 目录隔离已在(`instances/`,7 个实例) | 缺「谁在占用它」的管理 | +| 多版本隔离 | 内容根 `store/-/` 分开 | 已有判据守着(`test_version_isolation`) | +| **重复启动** | 后端用 `Popen` 起 JVM | **无 pid 记录、无重入防护、无残留清理** | + +新增 `mcfold/launch.py`:`claim()` 启动闸门 / `register()` 登记 / +`status()` 三态查询 / `stop()` 带身份校验的终止 / `cleanup()` 只清记录。 + +**为什么「重复启动」必须拦在启动之前**:不拦的话两个 JVM 会抢同一个 +`run/` 与同一个 X display,报错指向渲染异常,**完全指不到「重复启动」**。 + +**三态严格分开**(agent 应对完全不同): +`free`(没跑过/已正常退出)· `stale`(**有记录但进程已死**=上次崩溃残留) +· `running` · `foreign`(pid 存活但**不属于**本实例 = pid 被复用)。 + +**防误杀**:pid 会被系统回收复用 ⇒ 终止前必须校验 cmdline 含本实例路径。 +不校验就会杀掉无关进程。 + +**实测**(`tests/test_launch.py` 10 项,用**真 java 进程**): +``` +三态分清 / 重复启动被拦(带 pid 与启动时间)/ stale 自动清后放行 +两实例互不干扰(同版本多实例可用)/ 运行时目录在第三层 +外来 pid 拒绝终止(未误杀)/ cleanup 只清记录不杀进程 +运行中拒绝 cleanup / 完整循环 起→停→再起 / 会话记录可读 JSON +``` + +★ 判据必须用**真 java 进程**:`sleep 3600 ` 会拒绝多余参数退出; + `bash -c "sleep … # "` 会 exec 掉自己、cmdline 变成裸 sleep + ⇒ 「模拟进程的形状」必须与真实启动一致(`java … --gameDir <实例>`), + 否则测的是假形状。 + +★ 变异测试又抓到我自己的**空变异**:`return str(inst) in cl or …` → `return True` + 看似改了身份校验,其实上面还有 `if "java" not in cl: return False` 早退了 + ⇒ 判据全绿。**必须先验证变异真的落地**(打印改动后的行为),再信它的结果。 + +**服务端侧缺口(下一步)**:`server.py` 是硬编码 vanilla 直起 +(`java -jar server.jar`),**装不了 mod**。要装 mod 必须能起加载器服务端。 +已下好 `fabric-installer.jar`(197KB,Fabric 1.21.1 → loader 0.19.5, +与我们客户端已装的一致)。 + + **建议顺序:M0 → M1 → M2**。 - M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug) - **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。 diff --git a/tests/test_launch.py b/tests/test_launch.py new file mode 100644 index 0000000..f7fef0b --- /dev/null +++ b/tests/test_launch.py @@ -0,0 +1,313 @@ +"""M10 启动生命周期判据:多实例 / 重复启动 / 残留清理。 + +## 对应你 2026-09-26 的定位 + +> 「框架本身类似给 agent 用的 MC 启动器……多实例,多版本隔离, +> 单个实例多次启动是自然而然被要求支持的」 + +本文件把那三句要求翻成**可判**的行为,重点在**重复启动**与 +**残留/误杀**这类真会出事的部分。 + +## 为什么用真进程而不是 mock + +「pid 被复用」「进程已死但记录还在」这些缺陷,mock 测不出来 —— +必须真的起进程、真的杀进程、真的留残留。判据因此用 `sleep` 子进程 +(轻量、无 JVM),并在结束时确保清理干净。 +""" +import sys, os, time, json, signal, subprocess +from pathlib import Path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from mcfold.launch import ( + status, claim, register, release, stop, cleanup, AlreadyRunning, + runtime_dir_for, +) + +ROOT = Path("/home/newqqagent/mcview/runtime/launch-test") +INST = ROOT / "instA" +INST2 = ROOT / "instB" +INST.mkdir(parents=True, exist_ok=True) +INST2.mkdir(parents=True, exist_ok=True) + +_spawned: list[subprocess.Popen] = [] +JAVA = "java" +_HOLD_DIR = Path("/tmp/mcfold-hold") + +# 外来 pid 用的形状:cmdline 里既没有实例路径、也不是 java +_ = None + + +def _ensure_hold_class() -> None: + """写一个最小的常驻 java 类(读 stdin 直到 EOF)。""" + _HOLD_DIR.mkdir(exist_ok=True) + src = _HOLD_DIR / "Hold.java" + if not src.exists(): + src.write_text( + "public class Hold {\n" + " public static void main(String[] a) throws Exception {\n" + " java.io.InputStream in = System.in;\n" + " int c; while ((c = in.read()) != -1) { }\n" + " }\n" + "}\n", encoding="utf-8") + subprocess.run([JAVA, str(src)], check=True, capture_output=True) + + +def _spawn(cmd_argv: list[str] | None = None, tag: str = "instA") -> subprocess.Popen: + """起一个真进程模拟 JVM。 + + ★ 真实 JVM 的 cmdline 里**带实例/运行时目录**(我们用 cwd=game_dir 启动), + 所以身份校验(`_is_our_jvm` 查 cmdline 含实例路径)能命中。 + 这里用 `sleep 3600 <实例路径>` 复现那个形状 —— + 否则测的是「一个 cmdline 里什么都没有的进程」,与真实启动不符。 + """ + inst = INST if tag == "instA" else INST2 + # ★ 用**真 java** 起一个最小常驻进程(-version 会立刻退出, + # 所以用一个只读 stdin 的小程序让它活着)。 + # 真实 backend 的 cmdline 形如 + # java -Xmx… -cp … Main --gameDir <实例目录> + # ⇒ 身份校验查 cmdline 里的实例路径能命中。 + # 早先我用 `sleep 3600 ` / `bash -c "sleep … # "` 都失败了: + # sleep 拒绝多余参数;bash 会 exec 掉自己,cmdline 变成裸 sleep。 + # ⇒ 「模拟进程」的形状必须与真实启动一致,否则测的是假形状。 + argv = cmd_argv or [JAVA, "-cp", "/tmp", "Hold", str(inst)] + p = subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + _spawned.append(p) + return p + + +def _cleanup_all(): + for p in _spawned: + try: + p.kill() + p.wait(timeout=3) + except Exception: + pass + _spawned.clear() + for inst in (INST, INST2): + try: + release(inst, ROOT) + except Exception: + pass + + +# ---------- ① 三态必须分清(agent 应对完全不同) ---------- + + +def test_three_states_distinguished(): + """★ `free` / `stale` / `running` 三态语义不同,绝不能混。 + + - free 没跑过 / 已正常退出 + - stale **有记录但进程已死**(上次崩溃残留)⇒ 该清理后重启 + - running 真在跑 ⇒ 重复启动必须被拦住 + """ + release(INST, ROOT) + st = status(INST, ROOT) + assert st["state"] == "free", st + + # 起一个真进程并登记 + p = _spawn() + register(INST, p.pid, runtime_root=ROOT, generation="gen_1_21", version="1.21.1") + st = status(INST, ROOT) + assert st["state"] == "running", st + assert st["running"] is True + assert st["generation"] == "gen_1_21" + assert st["started_at"], "必须记录启动时间(报错时要给)" + + # 杀掉进程 ⇒ 应变 stale(而不是继续说 running) + p.kill(); p.wait(timeout=5) + st = status(INST, ROOT) + assert st["state"] == "stale", f"进程已死应报 stale,实得 {st}" + assert st["running"] is False + assert "cleanup" in st["how_to"], st + release(INST, ROOT) + print(" ★ 三态:free / running / stale 分清") + + +# ---------- ② 重复启动必须被拦住,且报错能指导下一步 ---------- + + +def test_second_launch_is_blocked(): + """★★ 同实例重复启动 ⇒ **启动前**就报,且信息够定位。 + + ★ 这是本模块存在的核心理由: + 不拦的话两个 JVM 会抢同一个 run/ 与同一个 X display, + 报错会指向渲染异常,**完全指不到「重复启动」**。 + """ + release(INST, ROOT) + p = _spawn() + register(INST, p.pid, runtime_root=ROOT) + try: + claim(INST, runtime_root=ROOT) + raise AssertionError("重复启动应该被拦住") + except AlreadyRunning as e: + info = e.info + assert info["pid"] == p.pid, info + assert info["started_at"], "报错要带启动时间" + assert "已在运行" in str(e), str(e) + # 异常类型要能让上层**区分**(该类不该重试) + assert issubclass(AlreadyRunning, RuntimeError) + release(INST, ROOT) + print(" ★★ 重复启动被拦(带 pid 与启动时间)") + + +def test_claim_clears_stale_and_then_proceeds(): + """★ stale 残留应被自动清掉并放行(不能让垃圾状态挡住下一次启动)。""" + release(INST, ROOT) + p = _spawn() + register(INST, p.pid, runtime_root=ROOT) + p.kill(); p.wait(timeout=5) + assert status(INST, ROOT)["state"] == "stale" + r = claim(INST, runtime_root=ROOT) + assert r["ok"] is True, r + assert r["cleared_stale"] is True, r + assert r["cleared_pid"] == p.pid, r + assert status(INST, ROOT)["state"] == "free", "清完应是 free" + print(" ★ stale 残留自动清理后放行") + + +# ---------- ③ 多实例互不干扰 ---------- + + +def test_two_instances_are_independent(): + """★ 实例 A 在跑**不影响**实例 B(这才是多实例的意义)。""" + release(INST, ROOT); release(INST2, ROOT) + pa = _spawn() + register(INST, pa.pid, runtime_root=ROOT, generation="gen_1_21") + # B 可以正常 claim + r = claim(INST2, runtime_root=ROOT) + assert r["ok"] is True, r + pb = _spawn(tag="instB") + register(INST2, pb.pid, runtime_root=ROOT, generation="gen_1_20") + assert status(INST, ROOT)["state"] == "running" + assert status(INST2, ROOT)["state"] == "running" + # 同版本不同实例也不冲突(多实例同版本是正常用法) + assert status(INST, ROOT)["pid"] != status(INST2, ROOT)["pid"] + print(" ★ 两个实例互不干扰(同版本多实例可用)") + + +def test_runtime_dir_is_third_layer(): + """★ 运行时目录必须在**第三层**(与 store/ instances/ 同级)。 + + 放内容根或实例目录都违反 `test_version_isolation` 守的分层。 + """ + d = runtime_dir_for(INST, ROOT) + assert d.parent == ROOT, d + assert d.name == "instA", d + # 不得落在 store 或 instances 里 + assert "/store/" not in str(d), d + assert "/instances/" not in str(d), d + print(f" ★ 运行时目录在第三层({d})") + + +# ---------- ④ 防误杀:pid 复用 ---------- + + +def test_foreign_pid_is_not_killed(): + """★★ pid 存活但**不属于本实例** ⇒ 绝不能杀,且要说清原因。 + + ★ pid 会被系统回收复用:一个早已结束的 JVM 的 pid 可能已属于别的程序。 + 不校验就 kill ⇒ 误杀无关进程。 + """ + release(INST, ROOT) + # 登记一个**真实存活**但 cmdline 里没有实例路径的进程 + # (模拟「pid 被系统回收后分给了别的程序」) + p = _spawn(cmd_argv=["tail", "-f", "/dev/null"]) # 非 java,且 cmdline 不含 instA + register(INST, p.pid, runtime_root=ROOT) + st = status(INST, ROOT) + assert st["state"] == "foreign", f"应识别为外来 pid,实得 {st['state']}" + assert st["running"] is False, "外来 pid 不算本实例在跑" + assert "不属于" in st["why"], st + + r = stop(INST, runtime_root=ROOT) + assert r["ok"] is False, r + assert r["killed"] is False, r + assert "误杀" in r["why"] or "不属于" in r["why"], r + # ★ 关键:那个进程必须还活着 + assert p.poll() is None, "★ 被误杀了(这正是要防的事故)" + p.kill(); p.wait(timeout=5) + print(" ★★ 外来 pid 拒绝终止(未误杀)") + + +def test_cleanup_does_not_kill_process(): + """★ cleanup 只清**记录**,不杀进程(状态是 foreign 时尤其不能杀)。""" + release(INST, ROOT) + p = _spawn(cmd_argv=["tail", "-f", "/dev/null"]) + register(INST, p.pid, runtime_root=ROOT) + r = cleanup(INST, runtime_root=ROOT) + assert r["ok"] is True, r + assert p.poll() is None, "cleanup 不该杀进程" + p.kill(); p.wait(timeout=5) + print(" ✓ cleanup 只清记录,不杀进程") + + +def test_cleanup_refuses_while_running(): + """★ 正在跑时 cleanup 要拒绝(否则会留下「在跑但无记录」的野状态)。""" + release(INST, ROOT) + p = _spawn() + register(INST, p.pid, runtime_root=ROOT) + r = cleanup(INST, runtime_root=ROOT) + assert r["ok"] is False, r + assert r["state"] == "running", r + assert "先 stop" in r["how_to"], r + release(INST, ROOT) + print(" ✓ 运行中拒绝 cleanup(避免野状态)") + + +# ---------- ⑤ 正常退出路径 ---------- + + +def test_release_then_relaunch_clean(): + """★ 完整循环:起 → 登记 → 停 → 释放 → 再起(重复启动的正常形态)。""" + release(INST, ROOT) + p1 = _spawn() + register(INST, p1.pid, runtime_root=ROOT, generation="gen_1_21") + assert status(INST, ROOT)["state"] == "running" + r = stop(INST, runtime_root=ROOT, timeout=10) + assert r["ok"] is True and r["killed"] is True, r + assert status(INST, ROOT)["state"] == "free", "停完应是 free" + + # 第二次启动必须顺畅 + r2 = claim(INST, runtime_root=ROOT) + assert r2["ok"] is True, r2 + p2 = _spawn() + register(INST, p2.pid, runtime_root=ROOT, generation="gen_1_21") + assert status(INST, ROOT)["pid"] == p2.pid + release(INST, ROOT) + print(" ★ 完整循环:起→停→再起(重复启动顺畅)") + + +def test_session_file_is_readable_json(): + """★ 记录是给人/agent 看的 JSON(排障时直接 cat)。""" + release(INST, ROOT) + p = _spawn() + register(INST, p.pid, runtime_root=ROOT, generation="gen_1_21", + version="1.21.1", loader="vanilla", extra={"display": ":98"}) + d = runtime_dir_for(INST, ROOT) + f = d / "mcfold.session.json" + assert f.exists(), f + data = json.loads(f.read_text(encoding="utf-8")) + for k in ("pid", "started_at", "generation", "version", "loader", "display"): + assert k in data, f"{k} 缺失:{data}" + assert data["pid"] == p.pid + assert (d / "mcfold.pid").read_text().strip() == str(p.pid) + release(INST, ROOT) + print(" ★ 会话记录是可读 JSON(含 pid/时间/世代/版本/加载器)") + + +if __name__ == "__main__": + _ensure_hold_class() + try: + for fn in [test_three_states_distinguished, + test_second_launch_is_blocked, + test_claim_clears_stale_and_then_proceeds, + test_two_instances_are_independent, + test_runtime_dir_is_third_layer, + test_foreign_pid_is_not_killed, + test_cleanup_does_not_kill_process, + test_cleanup_refuses_while_running, + test_release_then_relaunch_clean, + test_session_file_is_readable_json]: + fn() + print("\n启动生命周期:全部通过") + finally: + _cleanup_all()