Files
agentmc/tests/test_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

314 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 启动器……多实例,多版本隔离,
> 单个实例多次启动是自然而然被要求支持的」
本文件把那三句要求翻成**可判**的行为,重点在**重复启动**与
**残留/误杀**这类真会出事的部分。
## 为什么用真进程而不是 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()