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 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-09-29 00:48:36 -07:00
parent d36701ba7b
commit c4e50d92e0
3 changed files with 367 additions and 71 deletions
+5 -2
View File
@@ -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}'
+187 -69
View File
@@ -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__":
+175
View File
@@ -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",