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 项全通过。
This commit is contained in:
JianFeeeee
2026-09-26 14:07:45 +08:00
parent 4980d6e81f
commit 6a9a955472
3 changed files with 671 additions and 0 deletions

308
mcfold/launch.py Normal file
View File

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

50
plan.md
View File

@ -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/<name>`,7 个实例) | 缺「谁在占用它」的管理 |
| 多版本隔离 | 内容根 `store/<loader>-<version>/` 分开 | 已有判据守着(`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 <path>` 会拒绝多余参数退出;
`bash -c "sleep … # <path>"` 会 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 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。

313
tests/test_launch.py Normal file
View File

@ -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 <path>` / `bash -c "sleep … # <path>"` 都失败了:
# 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()