testspace / space_app /verification.py
Mayug Maniparambil
Isolate flow_free verification in a timeout-bounded subprocess
30865cf
Raw
History Blame Contribute Delete
6.83 kB
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
ROOT_DIR = Path(__file__).resolve().parents[1]
EVALS_SRC_DIR = ROOT_DIR / "evals" / "src"
if str(EVALS_SRC_DIR) not in sys.path:
sys.path.insert(0, str(EVALS_SRC_DIR))
from evals_verifier import ( # type: ignore
check_bridges_structural_validity,
check_galaxies_structural_validity,
check_undead_structural_validity,
verify_with_rlp,
)
from puzzles import PUZZLES # type: ignore
# Puzzle families whose "solved" check runs in the native RLP C library (loaded
# in-process via ctypes). That library can segfault or hang when its puzzle
# objects are reused across many requests in a long-lived server process, which
# crashes the web worker and surfaces as an opaque HTTP 500 (and, sometimes, a
# hung request). We run every native check in a short-lived subprocess so a
# native crash/hang kills only the child and we return a clean "not solved"
# result instead of taking down the worker. (flow_free already shells out to its
# own compiled solver binary, so it is naturally isolated.)
SUBPROCESS_RLP_PUZZLES = {"undead", "loopy", "bridges", "galaxies", "pattern"}
RLP_VERIFIER_TIMEOUT_SECONDS = 20
# flow_free shells out to a compiled C solver with no internal timeout and can
# raise SystemExit on a missing compiler/binary; isolate it too so it can never
# hang or crash the worker. Enumerating all solutions can be slower than the
# RLP solved-check, so it gets a more generous budget.
FLOW_FREE_TIMEOUT_SECONDS = 30
class VerificationService:
def _structural_validity(
self, *, puzzle_type: str, board_ascii: str, problem_ascii: str
) -> bool:
# Pure-Python structural pre-checks are safe to run in-process and also
# cover the "start board was modified" case for bridges/galaxies. Puzzle
# families without such a check are treated as structurally valid here;
# the subprocess solved-check is the source of truth for correctness.
if puzzle_type == "bridges":
return bool(
check_bridges_structural_validity(board_ascii, problem_ascii=problem_ascii)
)
if puzzle_type == "galaxies":
return bool(
check_galaxies_structural_validity(board_ascii, problem_ascii=problem_ascii)
)
if puzzle_type == "undead":
return bool(check_undead_structural_validity(board_ascii))
return True
def _verify_rlp_with_subprocess(
self,
*,
puzzle_type: str,
problem_ascii: str,
board_ascii: str,
args: str,
) -> dict[str, Any]:
result = {
"board_exists": bool(board_ascii),
"board_valid": False,
"board_modified": False,
"correct": False,
}
if not board_ascii:
return result
result["board_valid"] = self._structural_validity(
puzzle_type=puzzle_type, board_ascii=board_ascii, problem_ascii=problem_ascii
)
if not result["board_valid"]:
result["board_modified"] = True
return result
verifier_path = ROOT_DIR / "submodules" / "rlp" / "verifier.py"
command = [sys.executable, str(verifier_path), puzzle_type, "--arg", args]
# The RLP libraries pull in pygame, which noisily probes audio/video on
# import; force the dummy drivers (and silence warnings) so a headless
# verifier run does not pollute the captured stderr.
env = {
**os.environ,
"SDL_AUDIODRIVER": "dummy",
"SDL_VIDEODRIVER": "dummy",
"PYTHONWARNINGS": "ignore",
}
try:
completed = subprocess.run(
command,
input=board_ascii,
text=True,
capture_output=True,
cwd=ROOT_DIR,
timeout=RLP_VERIFIER_TIMEOUT_SECONDS,
check=False,
env=env,
)
except subprocess.TimeoutExpired:
result["error"] = f"{puzzle_type.capitalize()} verification timed out."
return result
result["correct"] = completed.returncode == 0 and "SOLVED" in completed.stdout
if completed.stderr.strip():
result["stderr"] = completed.stderr.strip()[:500]
return result
def _verify_flow_free_with_subprocess(
self, *, problem_ascii: str, board_ascii: str
) -> dict[str, Any]:
result = {
"board_exists": bool(board_ascii),
"board_valid": False,
"board_modified": False,
"correct": False,
}
if not board_ascii:
return result
runner_path = Path(__file__).resolve().parent / "flow_free_runner.py"
payload = json.dumps({"problem_ascii": problem_ascii, "board_ascii": board_ascii})
env = {**os.environ, "PYTHONWARNINGS": "ignore"}
try:
completed = subprocess.run(
[sys.executable, str(runner_path)],
input=payload,
text=True,
capture_output=True,
cwd=ROOT_DIR,
timeout=FLOW_FREE_TIMEOUT_SECONDS,
check=False,
env=env,
)
except subprocess.TimeoutExpired:
result["error"] = "Flow_free verification timed out."
return result
if completed.returncode == 0 and completed.stdout.strip():
try:
parsed = json.loads(completed.stdout)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
result["error"] = "Flow_free verification failed."
if completed.stderr.strip():
result["stderr"] = completed.stderr.strip()[:500]
return result
def verify(
self,
*,
puzzle_type: str,
problem_ascii: str,
board_ascii: str,
args: str,
) -> dict[str, Any]:
spec = PUZZLES[puzzle_type]
if spec.verifier_type == "flow_free":
return self._verify_flow_free_with_subprocess(
problem_ascii=problem_ascii, board_ascii=board_ascii
)
if spec.verifier_type in SUBPROCESS_RLP_PUZZLES:
return self._verify_rlp_with_subprocess(
puzzle_type=spec.verifier_type,
problem_ascii=problem_ascii,
board_ascii=board_ascii,
args=args,
)
return verify_with_rlp(
puzzle_type=spec.verifier_type,
problem_ascii=problem_ascii,
extracted_board=board_ascii,
args=args,
)