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:
308
mcfold/launch.py
Normal file
308
mcfold/launch.py
Normal 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
50
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/<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
313
tests/test_launch.py
Normal 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()
|
||||
Reference in New Issue
Block a user