diff options
Diffstat (limited to 'scripts/m3_supervise.py')
| -rwxr-xr-x | scripts/m3_supervise.py | 509 |
1 files changed, 419 insertions, 90 deletions
diff --git a/scripts/m3_supervise.py b/scripts/m3_supervise.py index 62fe94d..e5a2315 100755 --- a/scripts/m3_supervise.py +++ b/scripts/m3_supervise.py @@ -3,30 +3,36 @@ 状态机: idle -> phase1(--batch 30 20) -> phase2(--batch 50 100) -> done - flock 单实例防重入;抢不到锁静默退出 -- 冻结运行: 首次启动把 Release 输出目录整体复制到 scripts/m3_frozen/<stamp>_<commit>/, - 记录全部文件 sha256 清单;两阶段都从冻结副本运行,后续构建不会覆盖运行中二进制 +- 冻结运行: 首次启动把 Release 输出目录递归复制到 scripts/m3_frozen/<stamp>_<commit>/, + v2 清单覆盖全部子目录文件 sha256;两阶段都从冻结副本运行,后续构建不会覆盖 - 真实退出码: 子进程为 sh -c 'dotnet <frozen.dll> --batch K D; echo $? > exitfile', - 结束后读 exitfile 得到真实退出码(含信号 128+sig;shell 被杀则无 exitfile -> interrupted) -- PID 身份: 记录 /proc/<pid>/stat 字段22 starttime,存活判定要求 pid+starttime+cmdline 全匹配(防 PID 复用) + 读 exitfile 得真实退出码(信号 128+sig;shell 被杀无 exitfile -> interrupted) +- PID 身份: pid + /proc starttime + cmdline 片段全匹配(防 PID 复用) - PASS 判定收紧(全部满足才 PASS,任一不满足即 FAIL/停机): 1) exitfile 退出码 == 0 - 2) 日志含 `batch worlds=K days=D` 且 K/D 与该阶段参数完全一致 - 3) `world=` 行数 == K 且每行末 token 为 OK + 2) `batch worlds=K days=D` 与固定 PHASES[phase] 完全一致(不用 state 里可篡改值) + 3) `world=` 行: 行数==K、索引恰为 0..K-1 无重复无缺失、seed==42+i 4) `batch_summary worlds=K passed=K failed=0` 5) 行 `M3_ACCEPTANCE=PASS` - 6) 冻结清单重新校验 sha256 全部一致,且清单 commit == 启动时记录 commit(commit 绑定) -- FAIL/无结论/校验失败: 停机不自动重启,并在日志旁写 <log>.diagnosis.txt 可诊断报告 -- 状态文件 scripts/m3_state.json 原子写(tmp + os.replace) -- --selftest: 全部合成测试(解析/判据校验/原子状态/锁互斥/PID身份解析/诊断报告/退出码读取), - 不启动任何真实模拟,不伪造真实结果 + 6) 冻结清单 sha256 全部一致 + commit 绑定;v2 清单另查额外/缺失文件 +- shell 死亡留下存活 dotnet(孤儿): interrupted 停机并记录孤儿 pid,绝不自动重启新 batch +- FAIL/无结论/校验失败: 停机不自动重启,写 <log>.diagnosis.txt 可诊断报告 +- 状态文件原子写(tmp + os.replace);旧 v1 清单只校验所列文件(兼容正在运行的旧冻结副本, + 不修改任何旧冻结目录) +- --selftest: 合成断言 + 真实隔离生命周期测试(临时目录 + 短真实子进程: + 退出非零/重复调用/控制器重启/子进程中断/孤儿防重启),不伪造任何 M3 结果, + 不触碰项目真实 state/log/frozen """ +import contextlib import fcntl import hashlib +import io import json import os import re import shlex import shutil +import signal import subprocess import sys import tempfile @@ -44,9 +50,10 @@ NEXT = {"phase1": "phase2", "phase2": None} FRAG = "LivingVillage.Headless.dll" -def git_commit(root=ROOT): +def git_commit(root=None): + root = ROOT if root is None else Path(root) try: - r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, + r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(root), capture_output=True, text=True, timeout=10) c = r.stdout.strip() return c if r.returncode == 0 and c else None @@ -62,26 +69,40 @@ def sha256_file(path): return h.hexdigest() -def freeze_release(commit, src_binary=SRC_BINARY, frozen_root=None): - """复制 Release 输出目录全部文件到冻结目录并生成 sha256 清单。""" +def freeze_release(commit, src_binary=None, frozen_root=None): + """递归复制 Release 输出目录(含子目录)到冻结目录,生成 v2 sha256 清单。""" frozen_root = Path(frozen_root) if frozen_root else FROZEN_ROOT + src = Path(src_binary) if src_binary else SRC_BINARY stamp = time.strftime("%Y%m%dT%H%M%S") - dst = frozen_root / f"{stamp}_{(commit or 'nocommit')[:8]}" - dst.mkdir(parents=True, exist_ok=False) + base = frozen_root / f"{stamp}_{(commit or 'nocommit')[:8]}" + dst = base + suffix = 1 + while True: + try: + dst.mkdir(parents=True, exist_ok=False) + break + except FileExistsError: + dst = base.parent / f"{base.name}_{suffix}" + suffix += 1 files = {} - for f in sorted(Path(src_binary).parent.iterdir()): + for f in sorted(src.parent.rglob("*")): if f.is_file(): - shutil.copy2(f, dst / f.name) - files[f.name] = sha256_file(dst / f.name) - manifest = {"commit": commit, "created": time.strftime("%Y-%m-%dT%H:%M:%S"), - "source": str(src_binary), "files": files} + rel = f.relative_to(src.parent).as_posix() + target = dst / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(f, target) + files[rel] = sha256_file(target) + manifest = {"version": 2, "commit": commit, "created": time.strftime("%Y-%m-%dT%H:%M:%S"), + "source": str(src), "files": files} mpath = dst / "manifest.json" mpath.write_text(json.dumps(manifest, indent=1, ensure_ascii=False) + "\n") return dst, mpath, manifest def verify_manifest(frozen_dir, manifest_path, expected_commit): - """重校验冻结目录: commit 绑定 + 全部文件 sha256。返回问题列表(空=通过)。""" + """重校验冻结目录。v2: commit 绑定 + 所列文件 sha256 + 额外/缺失文件检测 + (manifest.json 自身除外)。旧 v1 清单(无 version): 只校验所列文件,兼容运行中旧副本。 + 返回问题列表(空=通过)。""" problems = [] try: m = json.loads(Path(manifest_path).read_text()) @@ -100,21 +121,31 @@ def verify_manifest(frozen_dir, manifest_path, expected_commit): got = sha256_file(p) if got != want: problems.append(f"hash mismatch {name}: got {got[:16]} want {want[:16]}") + if m.get("version") == 2: + for f in Path(frozen_dir).rglob("*"): + if not f.is_file(): + continue + rel = f.relative_to(Path(frozen_dir)).as_posix() + if rel == "manifest.json" or rel in files: + continue + problems.append(f"extra file not in manifest: {rel}") return problems -def load_state(path=STATE_PATH): - if Path(path).exists(): +def load_state(path=None): + # path 延迟到调用时解析(模块级常量可能被测试替换; 默认参数在 def 时绑定是已知事故源) + p = Path(path) if path is not None else STATE_PATH + if p.exists(): try: - return json.loads(Path(path).read_text()) + return json.loads(p.read_text()) except Exception: return {"phase": "corrupt_state"} return {"phase": "idle"} -def save_state(state, path=STATE_PATH): - """原子写: 同目录 tmp + os.replace。""" - p = Path(path) +def save_state(state, path=None): + """原子写: 同目录 tmp + os.replace。path 延迟到调用时解析。""" + p = Path(path) if path is not None else STATE_PATH p.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=str(p.parent), prefix=".m3_state.", suffix=".tmp") try: @@ -129,8 +160,8 @@ def save_state(state, path=STATE_PATH): raise -def read_starttime(pid, proc_root="/proc"): - """/proc/<pid>/stat 字段22 starttime(boot 后时钟滴答)。解析失败返回 None。""" +def _read_proc_stat(pid, proc_root="/proc"): + """读取 /proc/<pid>/stat 的字段(字段3之后从 parts[0] 开始)。""" try: data = (Path(proc_root) / str(pid) / "stat").read_text() except OSError: @@ -139,9 +170,17 @@ def read_starttime(pid, proc_root="/proc"): if rp < 0: return None parts = data[rp + 1:].split() - # parts[i] 对应字段 i+3;字段22 -> parts[19] if len(parts) < 20: return None + return parts + + +def read_starttime(pid, proc_root="/proc"): + """/proc/<pid>/stat 字段22 starttime(boot 后时钟滴答)。解析失败返回 None。""" + parts = _read_proc_stat(pid, proc_root) + if parts is None: + return None + # parts[i] 对应字段 i+3;字段22 -> parts[19] return parts[19] @@ -156,12 +195,40 @@ def proc_alive(pid, start, frag, proc_root="/proc"): """pid + starttime + cmdline 片段三者全匹配才算同一进程(防 PID 复用)。""" if not pid or start is None: return False - now = read_starttime(pid, proc_root) - if now != str(start): + parts = _read_proc_stat(pid, proc_root) + if parts is None or parts[0] == "Z": + return False + if parts[19] != str(start): return False return frag in read_cmdline(pid, proc_root) +def find_orphans(marker, proc_root="/proc"): + """扫描 /proc,返回 cmdline 含 marker 的存活 pid(孤儿检测)。""" + out = [] + if not marker: + return out + for d in Path(proc_root).iterdir(): + if not d.name.isdigit(): + continue + if marker in read_cmdline(int(d.name), proc_root): + out.append(int(d.name)) + return out + + +def live_batch_orphans(proc_root="/proc"): + """state 丢失/_idle 时兜底: 存活 cmdline 同时含冻结 dll 片段与 --batch 的进程。 + 命中则绝不允许再拉起新批次(防止 state 被破坏后重复 launch)。排除自身。""" + out = [] + for d in Path(proc_root).iterdir(): + if not d.name.isdigit() or int(d.name) == os.getpid(): + continue + cl = read_cmdline(int(d.name), proc_root) + if FRAG in cl and "--batch" in cl: + out.append(int(d.name)) + return out + + def read_exitcode(exitfile): p = Path(exitfile) if not p.exists(): @@ -173,7 +240,8 @@ def read_exitcode(exitfile): def verify_batch(text, worlds, days): - """完整批次判据校验。返回问题列表(空=满足全部收紧条件)。""" + """完整批次判据校验(固定 worlds/days 由调用方从 PHASES 取)。 + 返回问题列表(空=满足全部收紧条件)。""" problems = [] m = re.search(r"^batch worlds=(\d+) days=(\d+) ", text, re.M) if not m: @@ -184,6 +252,25 @@ def verify_batch(text, worlds, days): wlines = [l for l in text.splitlines() if l.startswith("world=")] if len(wlines) != worlds: problems.append(f"world line count {len(wlines)} != expected {worlds}") + parsed = [] + malformed = [] + for l in wlines: + mw = re.match(r"^world=(\d+) seed=(\d+) ", l) + if not mw: + malformed.append((l.split() or ["?"])[0]) + else: + parsed.append((int(mw.group(1)), int(mw.group(2)))) + if malformed: + problems.append(f"malformed world lines: {malformed[:5]}") + idxs = [i for i, _ in parsed] + if idxs != list(range(worlds)): + dup = sorted({i for i in idxs if idxs.count(i) > 1}) + missing = sorted(set(range(worlds)) - set(idxs)) + extra = sorted(set(idxs) - set(range(worlds))) + problems.append(f"world index set mismatch: duplicates={dup[:5]} missing={missing[:5]} extra={extra[:5]} (count {len(idxs)} vs {worlds})") + badseed = [i for i, s in parsed if s != 42 + i] + if badseed: + problems.append(f"seed binding mismatch (expect seed=42+i): worlds={badseed[:8]}") bad = [l.split()[0] for l in wlines if not l.split() or l.split()[-1] != "OK"] if bad: problems.append(f"non-OK world lines: {bad[:8]}{'...' if len(bad) > 8 else ''}") @@ -205,17 +292,18 @@ def generate_diagnosis(state, problems, tag): if log and Path(log).exists(): lines = Path(log).read_text(errors="replace").splitlines() tail = "\n".join(lines[-60:]) - exitcode = state.get("exitcode") + orphans = state.get("orphans") or [] return ( f"M3 DIAGNOSIS {tag}\n" f"time: {time.strftime('%Y-%m-%dT%H:%M:%S')}\n" f"phase: {state.get('phase')}\n" - f"expected batch: {state.get('worlds')}x{state.get('days')}\n" + f"expected batch: fixed PHASES={PHASES.get(state.get('phase'))} state={state.get('worlds')}x{state.get('days')}\n" f"commit: {state.get('commit')}\n" f"frozen dir: {state.get('frozen')}\n" f"manifest: {state.get('manifest')}\n" f"pid: {state.get('pid')} starttime: {state.get('start')}\n" - f"exit code: {exitcode}\n" + f"exit code: {state.get('exitcode')}\n" + f"orphan child pids (shell dead, child alive): {orphans if orphans else 'none'}\n" f"log: {log}\n" f"problems:\n" + "".join(f" - {p}\n" for p in problems) + (f"\nlog tail (last 60 lines):\n{tail}\n" if tail else "\nlog missing or empty\n") @@ -231,24 +319,34 @@ def write_diagnosis(state, problems, tag): return rep -def build_sh_cmd(dll, batch_args, exitfile): +def runner_cmd(dll, batch_args, exitfile): return f"exec 2>&1; {shlex.quote('dotnet')} {shlex.quote(str(dll))} {batch_args}; echo $? > {shlex.quote(str(exitfile))}" -def launch(phase, state): +def launch(phase, state, runner=None): worlds, days = PHASES[phase] + if runner is None: + runner = runner_cmd frozen, manifest, commit = state.get("frozen"), state.get("manifest"), state.get("commit") dll = Path(frozen) / FRAG if frozen else None if not dll or not dll.exists() or not manifest or not commit: return "freeze-missing" LOG_DIR.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%dT%H%M%S") - log = LOG_DIR / f"{phase}_{stamp}.log" + base_log = LOG_DIR / f"{phase}_{stamp}.log" + log = base_log + suffix = 1 + # A controller restart can happen within the same second; never reuse a + # previous log or its exit marker when that happens. + while (log.exists() or Path(str(log) + ".exit").exists() + or Path(str(log) + ".diagnosis.txt").exists()): + log = LOG_DIR / f"{phase}_{stamp}_{suffix}.log" + suffix += 1 exitfile = Path(str(log) + ".exit") - cmd = ["sh", "-c", build_sh_cmd(dll, f"--batch {worlds} {days}", exitfile)] + cmd = ["sh", "-c", runner(dll, f"--batch {worlds} {days}", exitfile)] with open(log, "w") as fh: proc = subprocess.Popen(cmd, stdout=fh, stderr=subprocess.STDOUT, - stdin=subprocess.DEVNULL, start_new_session=True, cwd=ROOT) + stdin=subprocess.DEVNULL, start_new_session=True, cwd=str(ROOT)) start = read_starttime(proc.pid) if start is None: # 无法建立进程身份: 立即终止并按失败停机,不进入无身份运行 @@ -268,13 +366,14 @@ def launch(phase, state): "cmd": " ".join(cmd), "log": str(log), "exitfile": str(exitfile), "frozen": str(frozen), "manifest": str(manifest), "commit": commit, "started": time.strftime("%Y-%m-%dT%H:%M:%S"), - "verdict": None, "exitcode": None, "problems": []}) + "verdict": None, "exitcode": None, "problems": [], "orphans": []}) save_state(state) return f"started {phase} pid={proc.pid} start={start} batch={worlds}x{days} frozen={frozen} log={log}" def decide(state, alive): - """纯状态机转移。alive(pid,start,frag)->bool 注入便于合成测试。""" + """纯状态机转移。alive(pid,start,frag)->bool 注入便于合成测试。 + 批次参数固定取 PHASES[phase];state 中的 worlds/days 仅作篡改检测,不作判据。""" phase = state.get("phase", "idle") if phase == "done": return None, state, None @@ -282,22 +381,25 @@ def decide(state, alive): if state.get("pid") and alive(state["pid"], state.get("start"), state.get("frag") or FRAG): return None, state, None # 进程结束: 收集全部证据 + problems = [] + worlds, days = PHASES[phase] + if state.get("worlds") != worlds or state.get("days") != days: + problems.append(f"state phase params tampered: state={state.get('worlds')}x{state.get('days')}, expected fixed {worlds}x{days}") state["exitcode"] = read_exitcode(state.get("exitfile")) log = state.get("log") p = Path(log) if log else None text = p.read_text(errors="replace") if p and p.exists() else "" - worlds, days = state.get("worlds"), state.get("days") - problems = [] - if worlds is None or days is None: - problems.append("state missing worlds/days (legacy or corrupt state)") - else: - problems += verify_batch(text, worlds, days) + problems += verify_batch(text, worlds, days) problems += verify_manifest(state.get("frozen"), state.get("manifest"), state.get("commit")) state["problems"] = problems state["pid"] = None if state["exitcode"] is None: + state["verdict"] = "FAIL" + state["problems"].insert(0, "missing exit code: process ended or disappeared before exitfile was written") + state["orphans"] = find_orphans(state.get("frozen") or "") state["phase"] = "interrupted" - return "save", state, f"{phase} interrupted without exit code (log={log}); stopped, awaiting human" + extra = f"; orphan child pids alive: {state['orphans']}" if state["orphans"] else "" + return "save", state, f"{phase} interrupted without exit code (log={log}){extra}; stopped, awaiting human" if state["exitcode"] == 0 and not problems: nxt = NEXT[phase] state["verdict"] = "PASS" @@ -314,9 +416,10 @@ def decide(state, alive): return None, state, None -def acquire_lock(path=LOCK_PATH): - Path(path).parent.mkdir(parents=True, exist_ok=True) - fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o644) +def acquire_lock(path=None): + p = Path(path) if path is not None else LOCK_PATH + p.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(p), os.O_RDWR | os.O_CREAT, 0o644) try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: @@ -325,6 +428,187 @@ def acquire_lock(path=LOCK_PATH): return fd +def make_batch_log(worlds, days, ok_token="OK"): + lines = [f"batch worlds={worlds} days={days} half_life_ticks=1 rel_threshold=0.50"] + for i in range(worlds): + ok = "OK" if ok_token == "OK" else "BAD" + lines.append(f"world={i} seed={42 + i} chats_max=10 chats_avg=1.00 ratio=3.000 gini=0.100 relcnt_max=2 relcnt_min=0 relconn=5/5 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS {ok}") + lines.append(f"batch_summary worlds={worlds} passed={worlds if ok_token == 'OK' else 0} failed={0 if ok_token == 'OK' else worlds} old_passed={worlds}/{worlds} ratio_min=1.0 ratio_max=1.0 gini_min=0.1 gini_max=0.1 elapsed_s=1.0") + lines.append(f"M3_ACCEPTANCE={'PASS' if ok_token == 'OK' else 'FAIL'}") + return "\n".join(lines) + "\n" + + +def _run_main(): + buf = io.StringIO() + argv_saved = sys.argv + sys.argv = [argv_saved[0]] # 剥离 --selftest, 防止 main() 递归重入 selftest + try: + with contextlib.redirect_stdout(buf): + code = main() + finally: + sys.argv = argv_saved + return code, buf.getvalue() + + +def lifecycle_section(check): + """真实隔离生命周期测试: 临时目录 + 短真实子进程。不伪造 M3 结果。""" + global ROOT, SRC_BINARY, STATE_PATH, LOCK_PATH, LOG_DIR, FROZEN_ROOT, FRAG + with tempfile.TemporaryDirectory() as tds: + root = Path(tds) / "proj" + srcbin = root / "bin" + srcbin.mkdir(parents=True) + test_frag = f"M3SelftestHeadless_{os.getpid()}_{time.time_ns()}.dll" + (srcbin / test_frag).write_text("fake dll payload marker") + (srcbin / "runtimes").mkdir() + (srcbin / "runtimes/dep.dll").write_text("dep") + env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"} + (root / "f.txt").write_text("x") + subprocess.run(["git", "init", "-q"], cwd=str(root), check=True) + subprocess.run(["git", "add", "-A"], cwd=str(root), check=True) + subprocess.run(["git", "commit", "-qm", "init"], cwd=str(root), check=True, env=env) + commit = git_commit(root) + check("lifecycle tmp git commit", bool(commit)) + + saved = (ROOT, SRC_BINARY, STATE_PATH, LOCK_PATH, LOG_DIR, FROZEN_ROOT, FRAG, runner_cmd) + try: + ROOT = root + FRAG = test_frag + SRC_BINARY = srcbin / FRAG + STATE_PATH = root / "scripts/m3_state.json" + LOCK_PATH = root / "scripts/lock" + LOG_DIR = root / "scripts/logs" + FROZEN_ROOT = root / "frozen" + + frozen, mpath, man = freeze_release(commit) + check("freeze recursive incl subdirs", (frozen / "runtimes/dep.dll").exists() + and man["files"].get("runtimes/dep.dll") is not None) + check("v2 manifest verify ok", verify_manifest(frozen, mpath, commit) == []) + same_second_root = root / "same-second-frozen" + old_strftime = time.strftime + same_second_error = None + same_second_dirs = [] + try: + time.strftime = lambda *args, **kwargs: "20260919T200000" + same_second_dirs.append(freeze_release(commit, frozen_root=same_second_root)[0]) + same_second_dirs.append(freeze_release(commit, frozen_root=same_second_root)[0]) + except Exception as exc: + same_second_error = exc + finally: + time.strftime = old_strftime + check("freeze same-second paths unique", + same_second_error is None and len(same_second_dirs) == 2 + and same_second_dirs[0] != same_second_dirs[1]) + (frozen / "extra.bin").write_text("e") + check("v2 extra file detected", any("extra file" in p for p in verify_manifest(frozen, mpath, commit))) + (frozen / "extra.bin").unlink() + (frozen / "runtimes/dep.dll").unlink() + check("v2 missing file detected", any("frozen file missing" in p + for p in verify_manifest(frozen, mpath, commit))) + # legacy v1 清单兼容: 所列文件通过即可,不做额外文件检测(兼容运行中的旧冻结副本) + (srcbin / "runtimes/dep.dll").write_text("dep") + leg = frozen / "legacy_dir" + leg.mkdir() + (leg / "a.dll").write_text("A") + legman = leg / "manifest.json" + legman.write_text(json.dumps({"commit": commit, + "files": {"a.dll": hashlib.sha256(b"A").hexdigest()}})) + (leg / "unlisted.txt").write_text("u") + check("legacy manifest verify ok (no extra check)", verify_manifest(leg, legman, commit) == []) + + def long_runner(dll, batch_args, exitfile): + q = shlex.quote + py = q(sys.executable) + code = q("import subprocess, time; subprocess.Popen(['sleep', '2']); time.sleep(2)") + return f"test -f {q(str(dll))}; {py} -c {code} {q(str(dll))}; echo $? > {q(str(exitfile))}" + + def fail_runner(dll, batch_args, exitfile): + q = shlex.quote + return f"sh -c 'exit 7'; echo $? > {q(str(exitfile))}" + + n_logs = lambda: len(list(LOG_DIR.glob("*.log"))) + + # (a) 控制器启动 phase1(真实 sh + Python 子进程, ~2s 存活) + save_state({"phase": "idle"}) + globals()["runner_cmd"] = long_runner + code, printed = _run_main() + check("controller launches phase1", code == 0 and "started phase1" in printed) + st = load_state() + check("launch state bound to frozen+commit", st["commit"] == commit and st["worlds"] == 30 and st["days"] == 20) + + # (b) 运行中重复调用: 静默, 不重复拉起 + code, printed = _run_main() + check("repeat call silent while alive", code == 0 and printed == "" and n_logs() == 1) + + # (c) 子进程正常退出但日志无有效批次 -> fail-closed FAIL 停机 + time.sleep(2.3) + code, printed = _run_main() + st = load_state() + diag = Path(str(st["log"]) + ".diagnosis.txt") + check("exit0 without valid batch -> FAIL stop", st["phase"] == "failed" and st["verdict"] == "FAIL" + and code == 0 and diag.exists()) + + # (d) 真实退出码 7 传播 + save_state({"phase": "idle"}) + globals()["runner_cmd"] = fail_runner + _run_main() + time.sleep(0.4) + _run_main() + st = load_state() + check("real child exit 7 propagated", st["exitcode"] == 7 and st["phase"] == "failed") + + # (e) 子进程被杀: shell 死 -> interrupted + 孤儿记录 + save_state({"phase": "idle"}) + globals()["runner_cmd"] = long_runner + _run_main() + st = load_state() + time.sleep(0.2) + os.kill(st["pid"], signal.SIGKILL) # 杀外层 sh, 内层 sleep sh 成为孤儿 + deadline = time.monotonic() + 1.5 + while proc_alive(st["pid"], st["start"], st["frag"]) and time.monotonic() < deadline: + time.sleep(0.02) + while not find_orphans(str(st["frozen"])) and time.monotonic() < deadline: + time.sleep(0.02) + _run_main() + st2 = load_state() + check("killed shell -> interrupted FAILED", + st2["phase"] == "interrupted" and st2["verdict"] == "FAIL") + check("orphan recorded", bool(st2.get("orphans"))) + check("interrupted diagnosis written", Path(str(st2["log"]) + ".diagnosis.txt").exists()) + + # (f) 控制器重启后绝不自动重启新 batch(孤儿/中断均终态) + logs_before = n_logs() + code, printed = _run_main() + st3 = load_state() + check("no relaunch after interrupted (controller restart)", + st3["phase"] == "interrupted" and n_logs() == logs_before and printed == "") + + # (g) state 丢失(idle) 但批次子进程存活: 拒绝重复拉起 + save_state({"phase": "idle"}) + guard = subprocess.Popen([ + sys.executable, "-c", "import time; time.sleep(2)", + str(SRC_BINARY), "--batch", "30", "20" + ]) + try: + logs_before = n_logs() + code, printed = _run_main() + check("idle refuses launch while batch child alive", + "refusing duplicate launch" in printed and n_logs() == logs_before) + finally: + if guard.poll() is None: + guard.kill() + guard.wait() + + for p in find_orphans(str(st3.get("frozen") or frozen)): + try: + os.kill(p, signal.SIGKILL) + except OSError: + pass + finally: + ROOT, SRC_BINARY, STATE_PATH, LOCK_PATH, LOG_DIR, FROZEN_ROOT, FRAG, rc = saved + globals()["runner_cmd"] = rc + + def selftest(): ok = True @@ -333,37 +617,58 @@ def selftest(): print(("PASS " if cond else "FAIL ") + name) ok = ok and cond - # ---- 解析 ---- - check("parse PASS", verify_batch("x\nM3_ACCEPTANCE=PASS\n", 1, 1) == ["missing 'batch worlds=K days=D' header line", "world line count 0 != expected 1", "missing batch_summary line"]) - check("parse empty", verify_batch("world=0 ...\n", 1, 1) != []) - check("verify full PASS log", verify_batch( - "batch worlds=2 days=20 half_life_ticks=1 rel_threshold=0.50\n" - "world=0 seed=42 ... OK\nworld=1 seed=43 ... OK\n" - "batch_summary worlds=2 passed=2 failed=0 old_passed=2/2 ratio_min=1.0 ratio_max=1.0 gini_min=0.1 gini_max=0.1 elapsed_s=1.0\n" - "M3_ACCEPTANCE=PASS\n", 2, 20) == []) - check("verify wrong params", any("params mismatch" in p for p in verify_batch( - "batch worlds=3 days=20 x\nworld=0 a OK\nworld=1 b OK\nworld=2 c OK\nbatch_summary worlds=3 passed=3 failed=0 x\nM3_ACCEPTANCE=PASS\n", 2, 20))) - check("verify BAD world", any("non-OK" in p for p in verify_batch( - "batch worlds=1 days=1 x\nworld=0 a BAD\nbatch_summary worlds=1 passed=0 failed=1 x\nM3_ACCEPTANCE=FAIL\n", 1, 1))) + # ---- 批次判据校验(固定参数注入) ---- + check("missing everything", verify_batch("x\nM3_ACCEPTANCE=PASS\n", 1, 1) == [ + "missing 'batch worlds=K days=D' header line", + "world line count 0 != expected 1", + "world index set mismatch: duplicates=[] missing=[0] extra=[] (count 0 vs 1)", + "missing batch_summary line"]) + check("verify full PASS log", verify_batch(make_batch_log(2, 20), 2, 20) == []) + check("verify wrong params", any("params mismatch" in p for p in verify_batch(make_batch_log(3, 20), 2, 20))) + check("1x1 log cannot certify phase2", any("params mismatch" in p for p in verify_batch(make_batch_log(1, 1), 50, 100))) + check("verify BAD world", any("non-OK" in p for p in verify_batch(make_batch_log(1, 1, ok_token="BAD"), 1, 1))) check("verify truncated worlds", any("world line count" in p for p in verify_batch( - "batch worlds=3 days=1 x\nworld=0 a OK\nbatch_summary worlds=3 passed=3 failed=0 x\nM3_ACCEPTANCE=PASS\n", 3, 1))) + "batch worlds=3 days=1 x\nworld=0 seed=42 a OK\nworld=1 seed=43 a OK\nbatch_summary worlds=3 passed=3 failed=0 x\nM3_ACCEPTANCE=PASS\n", 3, 1))) + check("duplicate world index", any("duplicates=[0]" in p for p in verify_batch( + "batch worlds=2 days=1 x\nworld=0 seed=42 a OK\nworld=0 seed=42 a OK\nbatch_summary worlds=2 passed=2 failed=0 x\nM3_ACCEPTANCE=PASS\n", 2, 1))) + check("wrong seed binding", any("seed binding mismatch" in p for p in verify_batch( + "batch worlds=2 days=1 x\nworld=0 seed=42 a OK\nworld=1 seed=99 a OK\nbatch_summary worlds=2 passed=2 failed=0 x\nM3_ACCEPTANCE=PASS\n", 2, 1))) + check("seed off-by-one rejected", any("seed binding mismatch" in p for p in verify_batch( + "batch worlds=1 days=1 x\nworld=0 seed=43 a OK\nbatch_summary worlds=1 passed=1 failed=0 x\nM3_ACCEPTANCE=PASS\n", 1, 1))) + check("malformed world line", any("malformed" in p for p in verify_batch( + "batch worlds=1 days=1 x\nworld=junk\nbatch_summary worlds=1 passed=1 failed=0 x\nM3_ACCEPTANCE=PASS\n", 1, 1))) check("verify summary mismatch", any("batch_summary mismatch" in p for p in verify_batch( - "batch worlds=2 days=1 x\nworld=0 a OK\nworld=1 b OK\nbatch_summary worlds=2 passed=1 failed=1 x\nM3_ACCEPTANCE=PASS\n", 2, 1))) + "batch worlds=2 days=1 x\nworld=0 seed=42 a OK\nworld=1 seed=43 a OK\nbatch_summary worlds=2 passed=1 failed=1 x\nM3_ACCEPTANCE=PASS\n", 2, 1))) check("verify missing verdict token", any("M3_ACCEPTANCE=PASS" in p for p in verify_batch( - "batch worlds=1 days=1 x\nworld=0 a OK\nbatch_summary worlds=1 passed=1 failed=0 x\nM3_ACCEPTANCE=FAIL\n", 1, 1))) + "batch worlds=1 days=1 x\nworld=0 seed=42 a OK\nbatch_summary worlds=1 passed=1 failed=0 x\nM3_ACCEPTANCE=FAIL\n", 1, 1))) - # ---- 冻结清单(合成目录) ---- + # ---- 冻结清单(合成目录, v2 递归 + 额外/缺失检测) ---- with tempfile.TemporaryDirectory() as td: td = Path(td) src = td / "bin"; src.mkdir() (src / "a.dll").write_bytes(b"hello") - (src / "b.json").write_text("{}") + (src / "b.json").write_bytes(b"{}") + (src / "runtimes").mkdir(); (src / "runtimes/r.dll").write_bytes(b"r") dst, mpath, man = freeze_release("deadbeef" * 8, src / "a.dll", td / "frozen") - check("freeze copies deps", (dst / "b.json").exists() and (dst / "a.dll").read_bytes() == b"hello" and man["files"]["b.json"]) + check("freeze copies deps recursively", (dst / "b.json").exists() + and (dst / "runtimes/r.dll").read_bytes() == b"r" + and man["files"].get("runtimes/r.dll") and man["version"] == 2) check("manifest verify ok", verify_manifest(dst, mpath, "deadbeef" * 8) == []) check("manifest commit bind", verify_manifest(dst, mpath, "f" * 64) != []) (dst / "a.dll").write_bytes(b"tampered") check("manifest detect tamper", any("hash mismatch" in p for p in verify_manifest(dst, mpath, "deadbeef" * 8))) + (dst / "sneaky.dll").write_bytes(b"x") + check("manifest detect extra", any("extra file" in p for p in verify_manifest(dst, mpath, "deadbeef" * 8))) + (dst / "sneaky.dll").unlink() + (dst / "runtimes/r.dll").unlink() + check("manifest detect missing", any("frozen file missing" in p for p in verify_manifest(dst, mpath, "deadbeef" * 8))) + # 旧 v1 清单: 只校验所列文件, 不做额外文件检测(兼容运行中的旧冻结副本) + leg = td / "leg"; leg.mkdir() + (leg / "a.dll").write_bytes(b"hello") + (leg / "unlisted.txt").write_text("u") + lm = leg / "manifest.json" + lm.write_text(json.dumps({"commit": "deadbeef" * 8, "files": {"a.dll": hashlib.sha256(b"hello").hexdigest()}})) + check("legacy manifest compatible", verify_manifest(leg, lm, "deadbeef" * 8) == []) # ---- 原子状态 ---- with tempfile.TemporaryDirectory() as td: @@ -387,13 +692,23 @@ def selftest(): td = Path(td) pr = td / "proc" / "4242" pr.mkdir(parents=True) - (pr / "stat").write_text("4242 (sh) S 1 4242 4242 0 -1 4194304 0 0 0 0 0 0 0 0 0 0 0 0 12345 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n") + (pr / "stat").write_text("4242 (sh) S 1 4242 4242 0 -1 4194304 0 0 0 0 0 0 0 0 0 0 0 0 12345 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n") (pr / "cmdline").write_bytes(b"sh -c dotnet LivingVillage.Headless.dll --batch 30 20\x00") check("starttime parsed", read_starttime(4242, td / "proc") == "12345") check("alive match", proc_alive(4242, "12345", "LivingVillage.Headless.dll", td / "proc")) + (pr / "stat").write_text((pr / "stat").read_text().replace("(sh) S ", "(sh) Z ")) + check("zombie rejected", not proc_alive(4242, "12345", "LivingVillage.Headless.dll", td / "proc")) + (pr / "stat").write_text((pr / "stat").read_text().replace("(sh) Z ", "(sh) S ")) check("starttime mismatch rejected", not proc_alive(4242, "99999", "LivingVillage.Headless.dll", td / "proc")) check("frag mismatch rejected", not proc_alive(4242, "12345", "other.dll", td / "proc")) check("missing proc rejected", not proc_alive(9999, "12345", "LivingVillage.Headless.dll", td / "proc")) + # state 丢失兜底: FRAG + --batch 存活进程必须被识别, 自身/无 --batch 排除 + (pr / "cmdline").write_bytes(b"dotnet /x/LivingVillage.Headless.dll --batch 30 20\x00") + pr2 = td / "proc" / "777"; pr2.mkdir() + (pr2 / "cmdline").write_bytes(b"sh -c sleep 9\x00") + pr3 = td / "proc" / str(os.getpid()); pr3.mkdir() + (pr3 / "cmdline").write_bytes(b"dotnet /x/LivingVillage.Headless.dll --batch 30 20\x00") + check("live batch orphan detected", live_batch_orphans(td / "proc") == [4242]) # ---- 退出码读取 ---- with tempfile.TemporaryDirectory() as td: @@ -404,41 +719,46 @@ def selftest(): ef.write_text("junk") check("exit junk -> None", read_exitcode(ef) is None) - # ---- sh 命令构造 ---- - c = build_sh_cmd("/tmp/a b/LivingVillage.Headless.dll", "--batch 30 20", "/tmp/x y.exit") - check("sh cmd quotes paths", "'/tmp/a b/LivingVillage.Headless.dll'" in c and "'/tmp/x y.exit'" in c and c.rstrip().endswith("> '/tmp/x y.exit'")) - - # ---- 状态机(合成日志/注入存活函数) ---- + # ---- 状态机(合成日志; worlds/days 以固定 PHASES 为准, state 篡改即 FAIL) ---- with tempfile.TemporaryDirectory() as td: td = Path(td) plog = td / "p.log" - plog.write_text( - "batch worlds=1 days=1 x\nworld=0 a OK\nbatch_summary worlds=1 passed=1 failed=0 x\nM3_ACCEPTANCE=PASS\n") - base = {"phase": "phase1", "worlds": 1, "days": 1, "pid": 123, "start": "1", - "frag": FRAG, "cmd": "c", "log": str(plog), - "exitfile": str(td / "e.exit"), "frozen": str(td / "fr"), - "manifest": str(td / "fr/manifest.json"), "commit": "c" * 40, - "verdict": None, "exitcode": None, "started": None, "problems": []} + plog.write_text(make_batch_log(30, 20)) (td / "fr").mkdir() (td / "fr/k.dll").write_bytes(b"kernel") (td / "fr/manifest.json").write_text(json.dumps( - {"commit": "c" * 40, "files": {"k.dll": hashlib.sha256(b"kernel").hexdigest()}})) + {"version": 2, "commit": "c" * 40, "files": {"k.dll": hashlib.sha256(b"kernel").hexdigest()}})) + base = {"phase": "phase1", "worlds": 30, "days": 20, "pid": 123, "start": "1", + "frag": FRAG, "cmd": "c", "log": str(plog), + "exitfile": str(td / "e.exit"), "frozen": str(td / "fr"), + "manifest": str(td / "fr/manifest.json"), "commit": "c" * 40, + "verdict": None, "exitcode": None, "started": None, "problems": [], "orphans": []} a, s, _ = decide(dict(base), lambda pid, st, fr: True) check("alive -> silent", a is None) (td / "e.exit").write_text("0\n") a, s, _ = decide(dict(base), lambda pid, st, fr: False) check("phase1 PASS -> launch phase2", a == "launch:phase2" and s["verdict"] == "PASS") - a, s, m = decide(dict(base, phase="phase2"), lambda pid, st, fr: False) + a, s, m = decide(dict(base, phase="phase2", log=str(plog)), lambda pid, st, fr: False) + check("phase2 with phase1 log -> FAIL (fixed PHASES)", s["phase"] == "failed" and s["verdict"] == "FAIL") + plog2 = td / "p2.log" + plog2.write_text(make_batch_log(50, 100)) + a, s, m = decide(dict(base, phase="phase2", worlds=50, days=100, log=str(plog2)), lambda pid, st, fr: False) check("phase2 PASS -> done", s["phase"] == "done" and m and "complete" in m) + (td / "e.exit").write_text("0\n") + tampered = dict(base, worlds=1, days=1) + a, s, _ = decide(tampered, lambda pid, st, fr: False) + check("state tamper fail-closed (1x1 state cannot pass phase1)", + s["phase"] == "failed" and any("tampered" in p for p in s["problems"])) (td / "e.exit").write_text("1\n") a, s, m = decide(dict(base), lambda pid, st, fr: False) check("exit 1 -> FAIL stop", s["phase"] == "failed" and s["verdict"] == "FAIL" and "FAIL" in m) (td / "e.exit").unlink() a, s, _ = decide(dict(base), lambda pid, st, fr: False) - check("no exitcode -> interrupted", s["phase"] == "interrupted") + check("no exitcode -> interrupted FAILED", + s["phase"] == "interrupted" and s["verdict"] == "FAIL") (td / "e.exit").write_text("0\n") (td / "fr/manifest.json").write_text(json.dumps( - {"commit": "d" * 40, "files": {"k.dll": hashlib.sha256(b"kernel").hexdigest()}})) + {"version": 2, "commit": "d" * 40, "files": {"k.dll": hashlib.sha256(b"kernel").hexdigest()}})) a, s, m = decide(dict(base), lambda pid, st, fr: False) check("commit mismatch -> FAIL even with token", s["phase"] == "failed" and any("commit" in p for p in s["problems"])) a, s, _ = decide({"phase": "done"}, lambda pid, st, fr: False) @@ -446,6 +766,9 @@ def selftest(): rep = generate_diagnosis(dict(base, phase="failed", exitcode=0, problems=["boom"]), ["boom"], "FAIL") check("diagnosis contains evidence", "M3 DIAGNOSIS FAIL" in rep and "boom" in rep and "exit code: 0" in rep) + # ---- 真实隔离生命周期测试(临时目录 + 短真实子进程) ---- + lifecycle_section(check) + print("SELFTEST=" + ("PASS" if ok else "FAIL")) return 0 if ok else 1 @@ -462,6 +785,12 @@ def main(): print("state file corrupt; stopped, awaiting human") return 1 if state.get("phase") == "idle": + live = live_batch_orphans() + if live: + # state 丢失/被清但批次子进程仍存活: 拒绝重复拉起, 保留现场等人 + print(f"live batch process(es) {live} found while state idle; " + "refusing duplicate launch, awaiting human") + return 0 if not SRC_BINARY.exists(): print(f"binary missing: {SRC_BINARY}; run dotnet build src/LivingVillage.Headless -c Release") return 1 |
