mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
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:
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user