From c4e50d92e0949da8081a13f34d19b460ccbc694f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:48:36 -0700 Subject: [PATCH] tools: fp_identity review fixes (run failures, mixed FMV-quiet, Windows) - run: a crash, hang-up or socket timeout on the final frame_fingerprint / dispatch_stats queries is a RunError (exit 3, INCOMPLETE), not a JSONDecodeError traceback with exit 1 (MISMATCH). Seeding, log and dump I/O errors and a runtime that survives SIGKILL also exit 3. A missing output directory is a usage error. The dump is written to OUT.tmp and renamed. - compare: with different FMV-quiet settings, once a quiet frame occurred (qc > 0, a different frame set, or no qc column) the runs are not comparable. Report INCOMPLETE and skip the DIVERGE, missing-frame and locator lines, which were artefacts of the setting. Mixed settings with no quiet frame are judged as usual, with a note. - --launch on Windows keeps backslashes (shlex with no escape character), so C:\psx\build\game-runtime.exe is not mangled into C:psxbuild... Self-test: 59 cases (11 new), ctest fp_identity passes. R4 dumps re-judged and fresh warm/cold runs repeat the IDENTICAL results with 497 tolerated straddles. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 7 +- tools/fp_identity.py | 256 +++++++++++++++++++++++--------- tools/tests/test_fp_identity.py | 175 ++++++++++++++++++++++ 3 files changed, 367 insertions(+), 71 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index a8c28b0c..90855291 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -249,8 +249,11 @@ only as the frame where the runs part. It tolerates the one-write straddle above, and the `wc + qc` shift when FMV-quiet was on. Every tolerated frame is counted and listed, and `--strict` disables both tolerances. It exits `0` IDENTICAL, `1` MISMATCH, `3` INCOMPLETE (frame window, wrapped ring, -missing columns from an older runtime, or an unconfirmable last-frame -straddle), `4` MISSES (dispatch misses), or `2` on a usage error. +missing columns from an older runtime, an unconfirmable last-frame +straddle, or different FMV-quiet settings once a quiet frame occurred, +which leaves the runs not comparable), `4` MISSES (dispatch misses), or `2` +on a usage error. `run` exits `3` when it writes no dump. On Windows, a +`--launch` backslash is a path separator, not an escape. ```sh L='tools/run_game.sh {build} --debug-port {port} {headless}' diff --git a/tools/fp_identity.py b/tools/fp_identity.py index 7206d31a..678c9353 100644 --- a/tools/fp_identity.py +++ b/tools/fp_identity.py @@ -25,8 +25,10 @@ the game repo's root: --launch placeholders: {build} (--build, default "build"), {port}, {headless} ("--headless", or nothing with --windowed) and {state_dir}. The template is -split like a shell command line but not run through a shell. Arguments after -`--` are appended unchanged. --runtime launches +split like a shell command line but not run through a shell; on Windows a +backslash is a path separator, not an escape, so C:\\psx\\game.exe works +(quote paths with spaces). Arguments after `--` are appended unchanged. +--runtime launches `RUNTIME --game TOML --disc DISC --no-launcher --debug-port PORT [--headless]`. Environment: runs set PSX_DEBUG_FMV_QUIET=0 (FMV-quiet would keep MDEC-era @@ -68,14 +70,19 @@ Tolerances, each counted and listed in the output, never silent: --frames, frames missing at either end of one run, a wrapped fingerprint ring, an empty run, no dispatch-miss count, a judge column the runtime did not report (runtimes before - psxrecomp #420 lack ws and qc), different FMV-quiet settings, - or a straddle on the last shared frame + psxrecomp #420 lack ws and qc), or a straddle on the last + shared frame. Also different FMV-quiet settings once a quiet + frame occurred (qc > 0, or the frame sets differ): quiet + frames are not fingerprinted and their writes count only in + qc, so the frames and columns are not judged at all 4 MISSES everything else holds but a run had dispatch misses (resolve them before trusting an identity result) 2 usage error Every problem found is listed, not only the first. `run` exits 0 once the -dump is written, 3 when the runtime did not produce one (did not start, port -already taken, exited, stalled or timed out), and 2 on usage errors. +dump is written, 3 when it did not write one (the runtime did not start, the +port was taken, the runtime exited, stalled, timed out or dropped the +connection, or seeding or writing failed), and 2 on usage errors. `snapshot` +exits 3 when the copy fails. """ import argparse import json @@ -139,14 +146,32 @@ def resolve_state_dir(args, cwd): return None -def launch_argv(args, state_dir): +def split_template(text, windows=None): + """Split a --launch template into arguments, without a shell. + + POSIX: shell quoting and backslash escapes (shlex.split). Windows: + backslashes are path separators, not escapes, so C:\\psx\\game.exe stays + as written; quote a path with spaces as "C:\\Program Files\\...". + """ + if windows is None: + windows = os.name == "nt" + if not windows: + return shlex.split(text) + lex = shlex.shlex(text, posix=True) + lex.whitespace_split = True + lex.commenters = "" + lex.escape = "" + return list(lex) + + +def launch_argv(args, state_dir, windows=None): headless = [] if args.windowed else ["--headless"] if not args.launch: return [args.runtime, "--game", args.game, "--disc", args.disc, "--no-launcher", "--debug-port", str(args.port), *headless, *args.extra] try: - tokens = shlex.split(args.launch) + tokens = split_template(args.launch, windows) except ValueError as e: raise UsageError(f"--launch: {e}") if args.windowed and "--headless" in tokens: @@ -178,7 +203,9 @@ def port_in_use(port): def stop(proc): - """Stop the launched process group: a launcher script may not exec.""" + """Stop the launched process group: a launcher script may not exec. + + Returns None, or why the runtime could not be stopped.""" try: if os.name == "nt": subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], @@ -189,15 +216,22 @@ def stop(proc): pass try: proc.wait(timeout=15) + return None except subprocess.TimeoutExpired: + pass + try: if os.name == "nt": proc.kill() else: - try: - os.killpg(proc.pid, signal.SIGKILL) - except OSError: - pass + os.killpg(proc.pid, signal.SIGKILL) + except OSError: + pass + try: proc.wait(timeout=10) + return None + except subprocess.TimeoutExpired: + return (f"runtime pid {proc.pid} did not exit after SIGKILL; it may " + f"still hold its port") def ask(port, obj, timeout): @@ -245,12 +279,32 @@ def drive(proc, args, log_path): raise RunError(f"reached only frame {frame} of {args.frames} in " f"{args.timeout}s (see {log_path})") time.sleep(0.2) - fp = ask(port, {"cmd": "frame_fingerprint", - "count": min(args.frames + 1, RING_CAP), - "frame_lo": 0, "frame_hi": args.frames}, 300) - if fp.get("ok") is False or "entries" not in fp: - raise RunError(f"frame_fingerprint failed: {fp.get('error', fp)}") - stats = ask(port, {"cmd": "dispatch_stats"}, 120) + replies = [] + for req, timeout in (({"cmd": "frame_fingerprint", + "count": min(args.frames + 1, RING_CAP), + "frame_lo": 0, "frame_hi": args.frames}, 300), + ({"cmd": "dispatch_stats"}, 120)): + try: + reply = ask(port, req, timeout) + except (OSError, ValueError) as e: + # A crash, a dropped connection or a socket timeout after frame + # N: no dump, so INCOMPLETE rather than a traceback. + try: + proc.wait(timeout=2) # a crash drops the socket first + except subprocess.TimeoutExpired: + pass + state = ("exited (status %s)" % proc.returncode + if proc.returncode is not None else "did not answer") + raise RunError(f"runtime {state} on {req['cmd']} after reaching " + f"frame {frame} ({e!r}; see {log_path})") + if not isinstance(reply, dict): + raise RunError(f"{req['cmd']} replied {reply!r}, not an object") + if reply.get("ok") is False: + raise RunError(f"{req['cmd']} failed: {reply.get('error', reply)}") + replies.append(reply) + fp, stats = replies + if "entries" not in fp: + raise RunError(f"frame_fingerprint returned no entries: {fp}") return fp, stats @@ -270,6 +324,10 @@ def cmd_run(args): raise UsageError(f"--seed {args.seed} is not a directory") if not os.path.isdir(state_dir): raise UsageError(f"state directory {state_dir} does not exist") + out = os.path.abspath(args.out) + if not os.path.isdir(os.path.dirname(out)): + raise UsageError(f"output directory {os.path.dirname(out)} does not " + f"exist") argv = launch_argv(args, state_dir) env = dict(os.environ) record = dict(DEFAULT_ENV) @@ -288,7 +346,6 @@ def cmd_run(args): f"--port so the run cannot query another runtime") if args.seed: copy_overlay_state(args.seed, state_dir) - out = os.path.abspath(args.out) log_path = out + ".log" label = args.label or (args.build if args.launch else args.runtime) with open(log_path, "w") as log: @@ -298,21 +355,42 @@ def cmd_run(args): start_new_session=(os.name != "nt")) except OSError as e: raise RunError(f"cannot launch {argv[0]}: {e}") + failure = None try: fp, stats = drive(proc, args, log_path) - finally: + except RunError as e: + failure = str(e) + except BaseException: stop(proc) + raise + stuck = stop(proc) + if stuck: + failure = f"{failure}; {stuck}" if failure else stuck + if failure: + raise RunError(failure) entries = fp.get("entries", []) - with open(out, "w") as f: - json.dump({"schema": SCHEMA, "build": label, "frames": args.frames, - "launch": argv, "cwd": cwd, "state_dir": state_dir, - "seed": os.path.abspath(args.seed) if args.seed else None, - "env": record, "port": args.port, - "miss_total": stats.get("miss_total"), - "miss_unique": stats.get("miss_unique"), - "ring_total": fp.get("total"), - "ring_available": fp.get("available"), - "entries": entries}, f) + # Write then rename: an interrupted write never leaves a partial dump. + tmp = out + ".tmp" + try: + with open(tmp, "w") as f: + json.dump({"schema": SCHEMA, "build": label, + "frames": args.frames, "launch": argv, "cwd": cwd, + "state_dir": state_dir, + "seed": os.path.abspath(args.seed) if args.seed + else None, + "env": record, "port": args.port, + "miss_total": stats.get("miss_total"), + "miss_unique": stats.get("miss_unique"), + "ring_total": fp.get("total"), + "ring_available": fp.get("available"), + "entries": entries}, f) + os.replace(tmp, out) + except BaseException: + try: + os.remove(tmp) + except OSError: + pass + raise print(f"{label}: {len(entries)} frames fingerprinted to frame " f"{args.frames}, dispatch misses total={stats.get('miss_total')} " f"unique={stats.get('miss_unique')} -> {out}") @@ -490,48 +568,75 @@ def compare(a_path, b_path, limit): f"{runs['A'].get('frames')}, B at " f"{runs['B'].get('frames')}") - for tag, other_tag in (("A", "B"), ("B", "A")): - only = sorted(set(fps[tag]) - set(fps[other_tag])) - other = fps[other_tag] - if not only: - continue - if other: - lo, hi = min(other), max(other) - inside = [f for f in only if lo <= f <= hi] - outside = [f for f in only if not lo <= f <= hi] - where = f"outside its range {lo}..{hi}" - else: - inside, outside, where = [], only, "(it has none)" - if inside: - mismatch.append(f"{len(inside)} frames fingerprinted by {tag} are " - f"missing inside {other_tag}'s range: " - f"{frame_ranges(inside)}") - if outside: - incomplete.append(f"{other_tag} lacks {len(outside)} of {tag}'s " - f"frames {where}: {frame_ranges(outside)}") - judged = [c for c in JUDGE if c in present["A"] and c in present["B"]] common = sorted(set(fa) & set(fb)) - qc_seen = "qc" in judged and any(fps[t][f]["qc"] for t in fps - for f in fps[t]) quiet = any(q is not False for q in quiet_set.values()) effective = {t: quiet_set[t] is not False for t in quiet_set} - if effective["A"] != effective["B"] and (qc_seen or "qc" not in judged): - incomplete.append(f"the runs used different FMV-quiet settings (A " - f"{quiet_text(quiet_set['A'])}, B " - f"{quiet_text(quiet_set['B'])}): quiet frames record " - f"different columns, so wc/ws/qc cannot agree") - if quiet: - why = ", ".join(f"{t} {quiet_text(q)}" for t, q in quiet_set.items()) - notes.append(f"FMV-quiet ({why}): " - f"wc+qc must agree; wc, qc and ws are judged against the " - f"offsets carried from any FMV-quiet shift. Rerun without " - f"--fmv-quiet for the strict rule") - else: - notes.append("FMV-quiet off in both runs: wc, qc and ws judged " - "directly") + # FMV-quiet frames are not fingerprinted and their writes count only in + # qc. With the setting different in the two runs, once a quiet frame has + # happened the frame sets and every write column part for that reason + # alone: a DIVERGE or a missing frame would be an artefact, not a fork. + comparable = True + if effective["A"] != effective["B"]: + settings = (f"A {quiet_text(quiet_set['A'])}, " + f"B {quiet_text(quiet_set['B'])}") + signs = [f"{t} counted quiet writes (qc > 0)" for t in fps + if "qc" in judged and any(fps[t][f]["qc"] for f in fps[t])] + if set(fa) != set(fb): + signs.append(f"the frame sets differ ({len(fa)} and {len(fb)} " + f"frames fingerprinted)") + if "qc" not in judged: + signs.append("qc is not reported, so quiet frames cannot be " + "ruled out") + if signs: + comparable = False + incomplete.append( + f"the runs used different FMV-quiet settings ({settings}) " + f"and {'; '.join(signs)}: quiet frames are not fingerprinted " + f"and their writes count only in qc, so frames and columns " + f"were not judged. Rerun both with the same setting (the " + f"default is off)") + else: + notes.append(f"FMV-quiet settings differ ({settings}), but no " + f"quiet frame occurred (qc = 0, same frame set): " + f"judged as usual") + + if comparable: + for tag, other_tag in (("A", "B"), ("B", "A")): + only = sorted(set(fps[tag]) - set(fps[other_tag])) + other = fps[other_tag] + if not only: + continue + if other: + lo, hi = min(other), max(other) + inside = [f for f in only if lo <= f <= hi] + outside = [f for f in only if not lo <= f <= hi] + where = f"outside its range {lo}..{hi}" + else: + inside, outside, where = [], only, "(it has none)" + if inside: + mismatch.append(f"{len(inside)} frames fingerprinted by {tag} " + f"are missing inside {other_tag}'s range: " + f"{frame_ranges(inside)}") + if outside: + incomplete.append(f"{other_tag} lacks {len(outside)} of " + f"{tag}'s frames {where}: " + f"{frame_ranges(outside)}") + if quiet: + why = ", ".join(f"{t} {quiet_text(q)}" + for t, q in quiet_set.items()) + notes.append(f"FMV-quiet ({why}): " + f"wc+qc must agree; wc, qc and ws are judged against " + f"the offsets carried from any FMV-quiet shift. Rerun " + f"without --fmv-quiet for the strict rule") + else: + notes.append("FMV-quiet off in both runs: wc, qc and ws judged " + "directly") + result = scan(common, fa, fb, judged, quiet, limit) + else: + result = {"straddles": [], "shifts": [], "diverge": None, + "unconfirmed": None} - result = scan(common, fa, fb, judged, quiet, limit) if result["diverge"]: f, cols, note = result["diverge"] text = f"DIVERGE at frame {f}: judge columns {cols}" @@ -565,6 +670,10 @@ def compare(a_path, b_path, limit): notes += [f"tolerated: {t}" for t in tolerated] for loc in LOCATORS: + if not comparable: + notes.append(f"locator {loc}: not compared (FMV-quiet settings " + f"differ)") + continue if loc not in present["A"] or loc not in present["B"]: notes.append(f"locator {loc}: not reported by both runs") continue @@ -593,8 +702,10 @@ def compare(a_path, b_path, limit): if tolerated else "; no tolerance applied") print(f"IDENTICAL: {len(common)} frames ({common[0]}..{common[-1]}) " f"agree on {','.join(judged)}{tol}") - else: + elif comparable: print(f"{verdict}: {shared}, judged on {','.join(judged) or 'nothing'}") + else: + print(f"{verdict}: {shared}, not judged (FMV-quiet settings differ)") for problem in mismatch + incomplete + misses: print(f" {problem}") for note in notes: @@ -681,6 +792,7 @@ def main(argv): i = argv.index("--") argv, extra = argv[:i], argv[i + 1:] top = parser() + args = None try: args = top.parse_args(argv) if not getattr(args, "func", None): @@ -696,6 +808,12 @@ def main(argv): except RunError as e: print(f"fp_identity.py: run failed: {e}", file=sys.stderr) return EXIT_INCOMPLETE + except (OSError, subprocess.SubprocessError) as e: + # Seeding, the log or the dump could not be written: no result, and + # never exit 1, which means MISMATCH. + what = getattr(args, "command", None) or "command" + print(f"fp_identity.py: {what} failed: {e}", file=sys.stderr) + return EXIT_INCOMPLETE if __name__ == "__main__": diff --git a/tools/tests/test_fp_identity.py b/tools/tests/test_fp_identity.py index 9b70e9bf..275ded2d 100644 --- a/tools/tests/test_fp_identity.py +++ b/tools/tests/test_fp_identity.py @@ -10,6 +10,8 @@ environment defaults and a taken port. python3 tools/tests/test_fp_identity.py (or ctest -R fp_identity) """ +import argparse +import importlib.util import json import os import shlex @@ -20,10 +22,18 @@ import sys import tempfile import time import unittest +from unittest import mock HERE = os.path.dirname(os.path.abspath(__file__)) TOOL = os.path.join(os.path.dirname(HERE), "fp_identity.py") + +def load_tool(): + spec = importlib.util.spec_from_file_location("fp_identity", TOOL) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + # Sparse like a real run: the ring holds one entry per guest frame that # snapshotted, not one per vblank. FRAMES = [f for f in range(1, 2001) if f % 4 != 3] @@ -376,6 +386,51 @@ class FmvQuietTest(Base): self.assertVerdict(self.compare(a, b), 3, "INCOMPLETE", "different FMV-quiet settings (A on, B off)") + def test_mixed_settings_with_quiet_frames_are_not_judged(self): + # As on R4: the quiet run skips the fingerprint on quiet frames and + # counts their writes (MMIO too) in qc, so frames and columns part + # from the first quiet frame. That is the setting, not a fork. + a = run_dump() + quiet_frames = set(FRAMES[300:400]) + b = run_dump([f for f in FRAMES if f not in quiet_frames]) + b["env"]["PSX_DEBUG_FMV_QUIET"] = "1" + for e in b["entries"]: + if e["frame"] > FRAMES[300]: + e["qc"], e["mmio"], e["mc"] = 50, "0x1", 0 + out = self.assertVerdict( + self.compare(a, b), 3, "INCOMPLETE", + "not judged (FMV-quiet settings differ)", + "different FMV-quiet settings (A off, B on)", + "B counted quiet writes (qc > 0)", + f"the frame sets differ ({len(FRAMES)} and {len(FRAMES) - 100} " + f"frames fingerprinted)", + "Rerun both with the same setting", + "locator wr: not compared") + for artefact in ("DIVERGE", "record_frame", "missing inside", + "tolerated:"): + self.assertNotIn(artefact, out) + + def test_mixed_settings_without_qc_column_are_not_judged(self): + a, b = run_dump(), run_dump(env=False) + for dump in (a, b): + for e in dump["entries"]: + del e["qc"] + self.assertVerdict(self.compare(a, b), 3, "INCOMPLETE", + "qc is not reported, so quiet frames cannot be " + "ruled out") + + def test_mixed_settings_without_a_quiet_frame_are_judged(self): + # FMV-quiet on in B, but no MDEC decode: nothing was kept quiet. + b = run_dump() + b["env"]["PSX_DEBUG_FMV_QUIET"] = "1" + self.assertVerdict(self.compare(run_dump(), b), 0, "IDENTICAL", + "FMV-quiet settings differ (A off, B on), but no " + "quiet frame occurred") + b["entries"][300]["cyc"] = 1 + self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH", + f"DIVERGE at frame {at(300)}: judge columns " + f"['cyc']") + def test_unrecorded_quiet_setting_uses_the_quiet_rule(self): a, b = run_dump(quiet=True, env=False), self.shifted(300, env=False) self.assertVerdict(self.compare(a, b), 0, "IDENTICAL", @@ -423,6 +478,45 @@ class UsageTest(Base): self.assertIn("--seed needs --state-dir", out) +class LaunchTemplateTest(unittest.TestCase): + """How --launch is split into arguments (no runtime needed).""" + + @classmethod + def setUpClass(cls): + cls.fp = load_tool() + + def argv(self, template, windows, **extra): + args = argparse.Namespace(launch=template, build="build", port=4781, + windowed=False, extra=[], **extra) + return self.fp.launch_argv(args, None, windows=windows) + + def test_windows_paths_keep_their_backslashes(self): + self.assertEqual( + self.argv(r"C:\psx\build\game-runtime.exe --game " + r"build\game.toml --debug-port {port} {headless}", True), + [r"C:\psx\build\game-runtime.exe", "--game", r"build\game.toml", + "--debug-port", "4781", "--headless"]) + + def test_windows_quoted_path_with_spaces(self): + self.assertEqual( + self.argv(r'"C:\Program Files\psx\game.exe" --debug-port {port}', + True), + [r"C:\Program Files\psx\game.exe", "--debug-port", "4781"]) + + def test_posix_keeps_shell_escapes(self): + self.assertEqual( + self.argv(r"tools/run\ game.sh '{build}' --debug-port {port}", + False), + ["tools/run game.sh", "build", "--debug-port", "4781"]) + + def test_unbalanced_quote_is_a_usage_error(self): + for windows in (False, True): + with self.subTest(windows=windows): + with self.assertRaises(self.fp.UsageError): + self.argv('"C:\\psx\\game.exe --debug-port {port}', + windows) + + # ---- fake runtime ------------------------------------------------------------ def fake_runtime(argv): @@ -454,6 +548,12 @@ def fake_runtime(argv): req = json.loads(line or b"{}") frame = int((time.time() - start) * 40000) cmd = req.get("cmd") + if "--crash-on" in argv and cmd == argv[argv.index("--crash-on") + + 1]: + os._exit(9) # dies mid-request, no reply + if "--hang-up-on" in argv and cmd == argv[ + argv.index("--hang-up-on") + 1]: + continue # closes the connection, no reply if cmd == "frame_fingerprint": lo, hi = req.get("frame_lo", 0), req.get("frame_hi", frame) entries = [entry(f) for f in FRAMES if lo <= f <= hi] @@ -581,6 +681,81 @@ class RunTest(Base): self.assertIn("already has a listener", text) self.assertFalse(os.path.exists(os.path.join(self.dir, "rep.json"))) + def test_runtime_that_fails_while_dumping(self): + # Reaches frame N, then crashes or drops the connection on one of + # the final queries: exit 3 with a reason, never 1 (MISMATCH) with a + # traceback, and no dump. + for how, cmd, reason in ( + ("--crash-on", "frame_fingerprint", + "runtime exited (status 9) on frame_fingerprint after " + "reaching frame"), + ("--crash-on", "dispatch_stats", + "runtime exited (status 9) on dispatch_stats"), + ("--hang-up-on", "frame_fingerprint", + "runtime did not answer on frame_fingerprint"), + ("--hang-up-on", "dispatch_stats", + "runtime did not answer on dispatch_stats")): + with self.subTest(how=how, cmd=cmd): + rep = os.path.join(self.dir, "rep.json") + status, text = self.launch( + "a.json", "--launch", self.template(rep, f"{how} {cmd}"), + "--port", str(free_port())) + self.assertEqual(status, 3, text) + self.assertIn(reason, text) + self.assertNotIn("Traceback", text) + for name in ("a.json", "a.json.tmp"): + self.assertFalse(os.path.exists(os.path.join(self.dir, + name))) + + @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, + "root reads a mode-000 file") + def test_io_errors_exit_incomplete(self): + seed = os.path.join(self.dir, "seed") + os.makedirs(os.path.join(seed, "cache")) + locked = os.path.join(seed, "cache", "shard.bin") + with open(locked, "w") as f: + f.write("x") + os.chmod(locked, 0) + state = os.path.join(self.dir, "state") + os.makedirs(state) + rep = os.path.join(self.dir, "rep.json") + try: + status, text = self.launch("a.json", "--launch", + self.template(rep), "--port", + str(free_port()), "--state-dir", state, + "--seed", seed) + self.assertEqual(status, 3, text) + self.assertIn("run failed", text) + self.assertNotIn("Traceback", text) + self.assertFalse(os.path.exists(rep)) # never launched + status, text = self.tool("snapshot", seed, + os.path.join(self.dir, "copy")) + self.assertEqual(status, 3, text) + self.assertIn("snapshot failed", text) + finally: + os.chmod(locked, 0o644) + + def test_missing_output_directory_is_a_usage_error(self): + status, text = self.launch(os.path.join("no", "such", "a.json"), + "--launch", self.template("rep.json"), + "--port", str(free_port())) + self.assertEqual(status, 2, text) + self.assertIn("output directory", text) + + def test_stop_reports_a_process_that_will_not_exit(self): + fp = load_tool() + + class Stuck: + pid = 999999999 + + def wait(self, timeout): + raise subprocess.TimeoutExpired("runtime", timeout) + + with mock.patch.object(fp.os, "killpg") as killpg: + self.assertIn("did not exit after SIGKILL", fp.stop(Stuck())) + self.assertEqual([c.args[1] for c in killpg.call_args_list], + [signal.SIGTERM, signal.SIGKILL]) + def test_runtime_that_exits_early(self): rep = os.path.join(self.dir, "rep.json") status, text = self.launch("a.json", "--launch",