From 6d7f7ef12c5456879aa8b76d81aa4ce453d76978 Mon Sep 17 00:00:00 2001
From: greenthree <1395214327@qq.com>
Date: Wed, 5 Aug 2026 00:30:46 +0800
Subject: [PATCH 1/3] extract special judge runtime
---
CHANGELOG.md | 4 +
probhub/calibration.py | 2 +-
probhub/datagen.py | 36 +-
probhub/linting.py | 70 +--
probhub/package_tools.py | 42 +-
probhub/problem_paths.py | 81 +++
probhub/special_judges.py | 947 +++++++++++++++++++++++++++++++
probhub/stressing.py | 115 ++--
scripts/local_judge.py | 637 ++++-----------------
tests/test_calibration.py | 34 +-
tests/test_core.py | 65 +++
tests/test_datagen.py | 25 +-
tests/test_local_judge_layout.py | 24 +
tests/test_package_tools.py | 37 ++
tests/test_problem_paths.py | 121 ++++
tests/test_special_judges.py | 447 ++++++++++++++-
tests/test_stress.py | 71 +++
17 files changed, 2032 insertions(+), 726 deletions(-)
create mode 100644 probhub/problem_paths.py
create mode 100644 probhub/special_judges.py
create mode 100644 tests/test_problem_paths.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0534de7..07543dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@
## [Unreleased]
+- 统一 Schema v1 特殊 Judge 路径栅栏:lint、正式打包和 local Judge 共同拒绝绝对路径、`..`、符号链接、junction/reparse point、非普通文件和题目目录外目标,并保留 `judge.type: checker` 兼容别名。
+- 新增共享 Checker/Interactor Core 运行层,供 local Judge、stress 与数据生成复用;结果分离 verdict、execution status、failure kind、责任方、终止原因、双方资源/流量证据与清理结果,沙箱缓存 Schema 升至 7。
+- Checker feedback 同时保留有界诊断与简短正式失败原因;Interactor 非普通 feedback、资源异常、启动失败、取消和清理失败均返回一致的结构化语义,清理失败不会再被成功 verdict 或取消状态掩盖。
+
## [0.6.5] - 2026-08-04
- Schema v1 题面—Validator 约束对账新增多组数据累计约束检查:保守识别 LaTeX 求和、中文“所有测试用例之和”表述,以及 Validator 中直接 `+=` / `acc = acc + term` 累加和后续 `ensuref` 上限;统一规范化为 `sum:n`、`sum:len:s`、`sum:n+m` 等主体。
diff --git a/probhub/calibration.py b/probhub/calibration.py
index 8fa2d52..3164681 100644
--- a/probhub/calibration.py
+++ b/probhub/calibration.py
@@ -14,7 +14,7 @@
CALIBRATION_SCHEMA_VERSION = 2
CALIBRATION_STRATEGY_VERSION = 2
-SANDBOX_CACHE_SCHEMA_VERSION = 6
+SANDBOX_CACHE_SCHEMA_VERSION = 7
EVIDENCE_FILENAME = "judge-evidence-v2.json"
EVIDENCE_LOCK_FILENAME = "judge-evidence.lock"
DEFAULT_ACCEPTED_TIME_MULTIPLIER = 3.0
diff --git a/probhub/datagen.py b/probhub/datagen.py
index 64e6a87..696d5db 100644
--- a/probhub/datagen.py
+++ b/probhub/datagen.py
@@ -24,7 +24,8 @@
from .errors import ProbHubError
from .io import normalize_newlines as _normalize_newlines, read_yaml, write_json
from .process_control import DEFAULT_PROCESS_LIMIT
-from .stressing import _compare_custom, _prepare_program, _run
+from .special_judges import run_checker_to_files
+from .stressing import _prepare_program, _run
from .transactions import (
TRANSACTION_PHASE_COMMITTED,
TRANSACTION_PHASE_PREPARED,
@@ -694,26 +695,37 @@ def generator_command(relative):
checker_answer = checker_dir / "answer.ans"
checker_input.write_bytes(input_bytes)
checker_answer.write_bytes(answer_bytes)
- checked = _compare_custom(
+ checker_env = os.environ.copy()
+ checker_env["PYTHONIOENCODING"] = "utf-8"
+ checked = run_checker_to_files(
checker_cmd,
checker_input,
checker_answer,
- answer_bytes,
- tool_timeout,
- checker_dir,
- TOOL_MEMORY_LIMIT_MB,
- output_limit,
- process_limit,
+ checker_answer,
+ timeout=tool_timeout,
+ cwd=checker_dir,
+ memory_limit_mb=TOOL_MEMORY_LIMIT_MB,
+ output_limit_bytes=int(output_limit * 1024 * 1024),
+ process_limit=process_limit,
+ env=checker_env,
)
- if checked["status"] != "AC":
+ if checked.get("verdict") != "AC":
failure = {
"case": case,
"stage": "checker",
- "status": checked["status"],
+ "status": checked.get("verdict") or "FAIL",
"message": checked.get("message") or "checker rejected the generated answer",
}
- if checked.get("execution_status"):
- failure["execution_status"] = checked["execution_status"]
+ legacy_execution_status = {
+ "time_limit": "TLE",
+ "memory_limit": "MLE",
+ "output_limit": "OLE",
+ "process_limit": "RE",
+ "start_error": "RE",
+ "output_control_error": "FAIL",
+ }.get(checked.get("execution_status"))
+ if legacy_execution_status:
+ failure["execution_status"] = legacy_execution_status
failures.append(failure)
continue
diff --git a/probhub/linting.py b/probhub/linting.py
index e85e5d0..28136a4 100644
--- a/probhub/linting.py
+++ b/probhub/linting.py
@@ -2,7 +2,7 @@
import json
import math
import re
-from pathlib import Path, PureWindowsPath
+from pathlib import Path
from .builder_fingerprint import (
BUILD_MANIFEST_SCHEMA_VERSION,
@@ -16,6 +16,7 @@
from .errors import ProbHubError
from .hashing import files_under, hash_file, hash_paths
from .metadata import build_meta, normalize_display_name
+from .problem_paths import ProblemPathError, resolve_problem_regular_file
from .solutions import analyze_solution_verification
from .statement import parse_statement
from .statement_consistency import analyze_constraint_consistency, reconcile_constraints
@@ -70,54 +71,17 @@ def _problem_relative_path(problem_dir, value):
return candidate
-def _is_link_like(path):
- try:
- return path.is_symlink() or (
- hasattr(path, "is_junction") and path.is_junction()
- )
- except OSError:
- return False
-
-
def _problem_regular_file_path(problem_dir, value):
- """Resolve a configured problem-local file without accepting links.
-
- Returns ``(path, reason)`` where reason is one of ``invalid``, ``outside``,
- ``symlink``, or ``missing``. The explicit reason lets lint keep stable,
- field-specific diagnostics without ever opening an escaped path.
- """
-
- if not isinstance(value, str) or not value.strip():
- return None, "invalid"
- problem_dir = Path(problem_dir).resolve()
- value = value.strip()
- relative = Path(value)
- if relative.is_absolute() or PureWindowsPath(value).is_absolute():
- return None, "outside"
- if ".." in relative.parts or ".." in PureWindowsPath(value).parts:
- return None, "outside"
- candidate = problem_dir / relative
- current = problem_dir
try:
- for part in relative.parts:
- if part in {"", "."}:
- continue
- current = current / part
- if _is_link_like(current):
- return None, "symlink"
- resolved = candidate.resolve()
- resolved.relative_to(problem_dir)
- except (OSError, RuntimeError, ValueError):
- return None, "outside"
- if not resolved.is_file():
- return None, "missing"
- return resolved, None
+ return resolve_problem_regular_file(problem_dir, value), None
+ except ProblemPathError as exc:
+ return None, exc.reason
def _configured_file_error(field, value, reason, missing_label):
if reason == "outside":
return f"{field} must stay inside the problem directory: {value}"
- if reason == "symlink":
+ if reason == "link":
return f"{field} must be a regular non-symlink file: {value}"
if reason == "invalid":
return f"{field} must be a non-empty relative path"
@@ -390,15 +354,29 @@ def lint_problem(root, workspace, entry):
if judge_type == "custom":
if not checker:
errors.append("judge.checker is required for custom judging")
- elif not (problem_dir / checker).is_file():
- errors.append(f"checker not found: {checker}")
+ else:
+ _, checker_reason = _problem_regular_file_path(problem_dir, checker)
+ if checker_reason:
+ errors.append(_configured_file_error(
+ "judge.checker",
+ checker,
+ checker_reason,
+ "checker",
+ ))
if interactor:
errors.append("judge.interactor is only valid for interactive judging")
elif judge_type == "interactive":
if not interactor:
errors.append("judge.interactor is required for interactive judging")
- elif not (problem_dir / interactor).is_file():
- errors.append(f"interactor not found: {interactor}")
+ else:
+ _, interactor_reason = _problem_regular_file_path(problem_dir, interactor)
+ if interactor_reason:
+ errors.append(_configured_file_error(
+ "judge.interactor",
+ interactor,
+ interactor_reason,
+ "interactor",
+ ))
if checker:
errors.append("judge.checker is not used for interactive judging")
interactive = judge.get("interactive") or {}
diff --git a/probhub/package_tools.py b/probhub/package_tools.py
index 3403a66..ab2478a 100644
--- a/probhub/package_tools.py
+++ b/probhub/package_tools.py
@@ -17,6 +17,7 @@
from .errors import ProbHubError
from .io import write_yaml
+from .problem_paths import ProblemPathError, resolve_problem_regular_file
from .process_control import DEFAULT_PROCESS_LIMIT, ProcessCancelled, run_managed_to_files
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
@@ -280,37 +281,16 @@ def _expected_validation(config):
def _problem_regular_file(problem_dir, relative, label):
- if not isinstance(relative, str) or not relative:
- raise ProbHubError(f"{label} is required", code="unsafe_package_source")
- problem_dir = Path(problem_dir).resolve()
- relative_path = Path(relative)
- if relative_path.is_absolute() or relative_path.drive or ".." in relative_path.parts:
- raise ProbHubError(
- f"{label} must stay inside the problem directory: {relative}",
- code="unsafe_package_source",
- )
- candidate = problem_dir / relative_path
- current = problem_dir
- has_symlink = False
- for part in relative_path.parts:
- current = current / part
- if current.is_symlink():
- has_symlink = True
- break
- if has_symlink or not candidate.is_file():
- raise ProbHubError(
- f"{label} must be a regular file: {relative}",
- code="unsafe_package_source",
- )
- resolved = candidate.resolve()
try:
- resolved.relative_to(problem_dir)
- except ValueError as exc:
- raise ProbHubError(
- f"{label} must stay inside the problem directory: {relative}",
- code="unsafe_package_source",
- ) from exc
- return resolved
+ return resolve_problem_regular_file(problem_dir, relative)
+ except ProblemPathError as exc:
+ if exc.reason == "invalid":
+ message = f"{label} is required"
+ elif exc.reason == "outside":
+ message = f"{label} must stay inside the problem directory: {relative}"
+ else:
+ message = f"{label} must be a regular file: {relative}"
+ raise ProbHubError(message, code="unsafe_package_source") from exc
def _problem_directory(problem_dir, relative, label):
@@ -358,8 +338,6 @@ def prepare_output_validator(problem_dir, config):
return None
source_value = judge.get(source_key)
- if not source_value:
- raise ProbHubError(f"judge.{source_key} is required for {judge_type} judging")
source = _problem_regular_file(problem_dir, source_value, f"judge.{source_key}")
if not TESTLIB_PATH.is_file():
raise ProbHubError(f"testlib.h not found: {TESTLIB_PATH}")
diff --git a/probhub/problem_paths.py b/probhub/problem_paths.py
new file mode 100644
index 0000000..db8afa1
--- /dev/null
+++ b/probhub/problem_paths.py
@@ -0,0 +1,81 @@
+import os
+import stat
+from pathlib import Path, PurePosixPath, PureWindowsPath
+
+
+_FILE_ATTRIBUTE_REPARSE_POINT = getattr(
+ stat,
+ "FILE_ATTRIBUTE_REPARSE_POINT",
+ 0x400,
+)
+
+
+class ProblemPathError(ValueError):
+ """A configured problem-local path failed a stable safety check."""
+
+ def __init__(self, reason):
+ self.reason = reason
+ super().__init__(reason)
+
+
+def resolve_problem_regular_file(problem_dir, value):
+ """Resolve a problem-local regular file without traversing link-like paths."""
+
+ if not isinstance(value, str) or not value.strip():
+ raise ProblemPathError("invalid")
+
+ normalized = value.strip().replace("\\", "/")
+ posix_path = PurePosixPath(normalized)
+ windows_path = PureWindowsPath(normalized)
+ if posix_path.is_absolute() or windows_path.is_absolute() or windows_path.drive:
+ raise ProblemPathError("outside")
+ if ".." in posix_path.parts:
+ raise ProblemPathError("outside")
+
+ try:
+ problem_root = Path(problem_dir).resolve(strict=True)
+ except (TypeError, ValueError) as exc:
+ raise ProblemPathError("invalid") from exc
+ except (OSError, RuntimeError) as exc:
+ raise ProblemPathError("missing") from exc
+ if not problem_root.is_dir():
+ raise ProblemPathError("non_regular")
+
+ parts = posix_path.parts
+ if not parts:
+ raise ProblemPathError("non_regular")
+
+ current = problem_root
+ for index, part in enumerate(parts):
+ current = current / part
+ try:
+ info = os.lstat(current)
+ except ValueError as exc:
+ raise ProblemPathError("invalid") from exc
+ except NotADirectoryError as exc:
+ raise ProblemPathError("non_regular") from exc
+ except OSError as exc:
+ raise ProblemPathError("missing") from exc
+
+ attributes = getattr(info, "st_file_attributes", 0)
+ if stat.S_ISLNK(info.st_mode) or attributes & _FILE_ATTRIBUTE_REPARSE_POINT:
+ raise ProblemPathError("link")
+ if index < len(parts) - 1:
+ if not stat.S_ISDIR(info.st_mode):
+ raise ProblemPathError("non_regular")
+ elif not stat.S_ISREG(info.st_mode):
+ raise ProblemPathError("non_regular")
+
+ try:
+ resolved = current.resolve(strict=True)
+ except FileNotFoundError as exc:
+ raise ProblemPathError("missing") from exc
+ except RuntimeError as exc:
+ raise ProblemPathError("link") from exc
+ except OSError as exc:
+ raise ProblemPathError("missing") from exc
+ try:
+ resolved.relative_to(problem_root)
+ except ValueError as exc:
+ raise ProblemPathError("outside") from exc
+ return resolved
diff --git a/probhub/special_judges.py b/probhub/special_judges.py
new file mode 100644
index 0000000..ce3307d
--- /dev/null
+++ b/probhub/special_judges.py
@@ -0,0 +1,947 @@
+"""Shared execution primitives for custom and interactive judges."""
+
+import codecs
+import os
+import shutil
+import subprocess
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+from .io import read_bounded_text
+from .process_control import (
+ DEFAULT_PROCESS_LIMIT,
+ OutputBudgetError,
+ ProcessCancelled,
+ cancellation_requested,
+ output_path_size,
+ run_managed_to_files,
+ spawn_managed,
+)
+
+
+MIB = 1024 * 1024
+MAX_CHECKER_DIAGNOSTIC_BYTES = 8 * MIB
+_FEEDBACK_NAMES = ("judgemessage.txt", "teammessage.txt")
+_RESOURCE_LIMIT_REASONS = frozenset(
+ ("time_limit", "memory_limit", "output_limit", "process_limit")
+)
+_CLEANUP_ERROR_BYTES = 4096
+
+
+def _cleanup_error(stage, actor, exc):
+ message = str(exc).encode("utf-8", errors="replace")[:_CLEANUP_ERROR_BYTES]
+ return {
+ "stage": stage,
+ "actor": actor,
+ "message": message.decode("utf-8", errors="replace"),
+ }
+
+
+def _remove_runtime_directory(path):
+ if path is None:
+ return True, 0, None
+ last_error = None
+ for attempt in range(1, 21):
+ try:
+ shutil.rmtree(path)
+ return True, attempt, None
+ except FileNotFoundError:
+ return True, attempt, None
+ except OSError as exc:
+ last_error = exc
+ if attempt < 20:
+ time.sleep(0.05)
+ return False, 20, last_error
+
+
+def _apply_cleanup_failure(result, cleanup):
+ result["cleanup"] = cleanup
+ if cleanup["ok"]:
+ return result
+ result["pre_cleanup_result"] = {
+ key: result.get(key)
+ for key in (
+ "verdict",
+ "execution_status",
+ "failure_kind",
+ "actor",
+ "termination_reason",
+ )
+ }
+ result.update({
+ "verdict": None,
+ "execution_status": "cleanup_error",
+ "failure_kind": "cleanup_failure",
+ "actor": "supervisor",
+ "termination_reason": "cleanup_error",
+ "message": cleanup["errors"][0]["message"] if cleanup["errors"] else "cleanup failed",
+ })
+ if "status" in result:
+ result["status"] = "FAIL"
+ return result
+
+
+def _feedback_message(feedback_dir, fallback="", limit_bytes=MAX_CHECKER_DIAGNOSTIC_BYTES):
+ for name in _FEEDBACK_NAMES:
+ path = Path(feedback_dir) / name
+ try:
+ result = read_bounded_text(path, limit_bytes)
+ except OSError:
+ continue
+ message = result["text"].strip()
+ if message:
+ result["source"] = name
+ return message, result
+ encoded = (fallback or "").encode("utf-8", errors="replace")
+ retained = encoded[: max(int(limit_bytes), 0)]
+ return retained.decode("utf-8", errors="replace").strip(), {
+ "source": "stderr",
+ "observed_bytes": len(encoded),
+ "retained_bytes": len(retained),
+ "truncated": len(retained) < len(encoded),
+ "exists": bool(encoded),
+ }
+
+
+def _failed_checker_result(reason, message, diagnostic_limit_bytes):
+ stderr = str(message).encode("utf-8", errors="replace")
+ retained = stderr[: max(int(diagnostic_limit_bytes), 0)]
+ bounded_message = retained.decode("utf-8", errors="replace").strip()
+ feedback = {
+ "source": "stderr",
+ "observed_bytes": len(stderr),
+ "retained_bytes": len(retained),
+ "truncated": len(retained) < len(stderr),
+ "exists": bool(stderr),
+ }
+ return {
+ "verdict": None,
+ "execution_status": reason,
+ "failure_kind": (
+ "control_failure" if reason == "output_control_error" else "startup_failure"
+ ),
+ "actor": "supervisor" if reason == "output_control_error" else "checker",
+ "termination_reason": reason,
+ "message": bounded_message,
+ "feedback_message": bounded_message,
+ "returncode": None,
+ "time": 0.0,
+ "memory": None,
+ "memory_enforced": False,
+ "process_limit_enforced": False,
+ "output_bytes": 0,
+ "retained_output_bytes": 0,
+ "stdout_retained_bytes": 0,
+ "stderr_retained_bytes": 0,
+ "output_truncated": False,
+ "stdout": b"",
+ "stderr": stderr,
+ "feedback": feedback,
+ }
+
+
+def run_checker_to_files(
+ checker_command,
+ input_path,
+ answer_path,
+ contestant_output_path,
+ *,
+ timeout,
+ cwd=None,
+ memory_limit_mb=256,
+ output_limit_bytes=64 * MIB,
+ process_limit=DEFAULT_PROCESS_LIMIT,
+ diagnostic_limit_bytes=None,
+ env=None,
+):
+ """Run a DOMjudge/testlib Checker and return protocol and execution evidence.
+
+ The Checker receives `` `` as arguments and
+ reads contestant output from stdin. Its stdout, stderr, and both standard
+ feedback files share one bounded output budget.
+ """
+ effective_output_limit = min(
+ max(int(output_limit_bytes), 0),
+ MAX_CHECKER_DIAGNOSTIC_BYTES,
+ )
+ if diagnostic_limit_bytes is None:
+ diagnostic_limit_bytes = effective_output_limit
+ else:
+ diagnostic_limit_bytes = min(
+ max(int(diagnostic_limit_bytes), 0),
+ effective_output_limit,
+ )
+
+ runtime_dir = None
+ result = None
+ cancelled_error = None
+ cleanup = {
+ "ok": True,
+ "checker_tree_termination": "not-started",
+ "runtime_removed": False,
+ "runtime_remove_attempts": 0,
+ "errors": [],
+ }
+ try:
+ runtime_dir = Path(
+ tempfile.mkdtemp(prefix=".probhub-checker-", dir=cwd)
+ )
+ feedback_dir = runtime_dir / "feedback"
+ feedback_dir.mkdir()
+ stdout_path = runtime_dir / "checker.stdout"
+ stderr_path = runtime_dir / "checker.stderr"
+ feedback_paths = tuple(feedback_dir / name for name in _FEEDBACK_NAMES)
+ try:
+ cleanup["checker_tree_termination"] = "pending"
+ execution = run_managed_to_files(
+ [
+ *checker_command,
+ os.fspath(input_path),
+ os.fspath(answer_path),
+ os.fspath(feedback_dir),
+ ],
+ input_path=contestant_output_path,
+ stdout_path=stdout_path,
+ stderr_path=stderr_path,
+ additional_output_paths=feedback_paths,
+ timeout=timeout,
+ memory_limit_mb=memory_limit_mb,
+ output_limit_bytes=effective_output_limit,
+ process_limit=process_limit,
+ cwd=cwd,
+ env=env,
+ )
+ cleanup["checker_tree_termination"] = "completed"
+ except OutputBudgetError as exc:
+ cleanup["checker_tree_termination"] = "completed"
+ result = _failed_checker_result(
+ "output_control_error", str(exc), diagnostic_limit_bytes
+ )
+ return result
+ except OSError as exc:
+ cleanup["checker_tree_termination"] = "not-started"
+ result = _failed_checker_result("start_error", str(exc), diagnostic_limit_bytes)
+ return result
+
+ stdout = stdout_path.read_bytes() if stdout_path.is_file() else b""
+ stderr = stderr_path.read_bytes() if stderr_path.is_file() else b""
+ feedback_message, feedback = _feedback_message(
+ feedback_dir,
+ stderr.decode("utf-8", errors="replace"),
+ diagnostic_limit_bytes,
+ )
+ message = feedback_message
+ termination_reason = execution.get("reason") or "completed"
+ if termination_reason == "cancelled":
+ raise ProcessCancelled(execution.get("message") or "execution cancelled")
+ returncode = execution.get("returncode")
+ verdict = None
+ failure_kind = None
+ actor = "checker"
+ if termination_reason == "completed" and returncode in {0, 42}:
+ verdict = "AC"
+ execution_status = "completed"
+ actor = "session"
+ elif termination_reason == "completed" and returncode in {1, 2, 43}:
+ verdict = "WA"
+ execution_status = "completed"
+ failure_kind = "wrong_answer"
+ actor = "contestant"
+ elif termination_reason == "completed":
+ execution_status = "completed"
+ failure_kind = "judge_failure"
+ message = message or f"checker exited with code {returncode}"
+ elif termination_reason in _RESOURCE_LIMIT_REASONS:
+ execution_status = termination_reason
+ failure_kind = "resource_limit"
+ message = execution.get("message") or termination_reason.replace("_", " ")
+ else:
+ execution_status = "completed"
+ failure_kind = "judge_failure"
+ message = execution.get("message") or termination_reason.replace("_", " ")
+
+ result = {
+ "verdict": verdict,
+ "execution_status": execution_status,
+ "failure_kind": failure_kind,
+ "actor": actor,
+ "termination_reason": termination_reason,
+ "message": message,
+ "feedback_message": feedback_message,
+ "returncode": returncode,
+ "time": execution.get("time", 0.0),
+ "memory": execution.get("memory"),
+ "memory_enforced": execution.get("memory_enforced", False),
+ "process_limit_enforced": execution.get("process_limit_enforced", False),
+ "output_bytes": execution.get("output_bytes", 0),
+ "retained_output_bytes": execution.get("retained_output_bytes", 0),
+ "stdout_retained_bytes": execution.get("stdout_retained_bytes", 0),
+ "stderr_retained_bytes": execution.get("stderr_retained_bytes", 0),
+ "output_truncated": execution.get("output_truncated", False),
+ "stdout": stdout,
+ "stderr": stderr,
+ "feedback": feedback,
+ }
+ return result
+ except ProcessCancelled as exc:
+ cancelled_error = exc
+ result = _failed_checker_result(
+ "cancelled", str(exc), diagnostic_limit_bytes
+ )
+ result.update({
+ "execution_status": "cancelled",
+ "failure_kind": "cancelled",
+ "actor": "supervisor",
+ "termination_reason": "cancelled",
+ })
+ except OutputBudgetError as exc:
+ result = _failed_checker_result(
+ "output_control_error", str(exc), diagnostic_limit_bytes
+ )
+ return result
+ except OSError as exc:
+ result = _failed_checker_result("start_error", str(exc), diagnostic_limit_bytes)
+ result["actor"] = "supervisor"
+ return result
+ finally:
+ removed, attempts, error = _remove_runtime_directory(runtime_dir)
+ cleanup["runtime_removed"] = removed
+ cleanup["runtime_remove_attempts"] = attempts
+ if error is not None:
+ cleanup["errors"].append(
+ _cleanup_error("runtime_remove", "supervisor", error)
+ )
+ cleanup["ok"] = not cleanup["errors"]
+ if result is not None:
+ _apply_cleanup_failure(result, cleanup)
+ if cancelled_error is not None:
+ if result is not None and result.get("failure_kind") == "cleanup_failure":
+ return result
+ raise cancelled_error
+ return result
+
+
+def _command_list(command):
+ if isinstance(command, (str, bytes, os.PathLike)):
+ return [os.fspath(command)]
+ return [os.fspath(item) for item in command]
+
+
+def _protocol_outcome(returncode, message, actor):
+ message = (message or "").strip()
+ if returncode in {0, 42}:
+ return {
+ "status": "AC",
+ "verdict": "AC",
+ "execution_status": "completed",
+ "failure_kind": None,
+ "actor": "session",
+ "termination_reason": "completed",
+ "message": message,
+ }
+ if returncode in {1, 2, 43}:
+ return {
+ "status": "WA",
+ "verdict": "WA",
+ "execution_status": "completed",
+ "failure_kind": "wrong_answer",
+ "actor": "contestant",
+ "termination_reason": "completed",
+ "message": message,
+ }
+ return {
+ "status": "FAIL",
+ "verdict": None,
+ "execution_status": "completed",
+ "failure_kind": "judge_failure",
+ "actor": actor,
+ "termination_reason": "completed",
+ "message": message or f"{actor} exited with code {returncode}",
+ }
+
+
+def _record_interactive_transcript(transcript, direction, payload, decoder, *, final=False):
+ """Reserve the shared byte budget and decode one direction incrementally."""
+ with transcript["_lock"]:
+ remaining = max(transcript["limit"] - transcript["bytes"], 0)
+ saved = payload[:remaining]
+ transcript["bytes"] += len(saved)
+ if len(saved) < len(payload):
+ transcript["truncated"] = True
+ text = decoder.decode(saved, final=final)
+ if text:
+ transcript["entries"].append({"direction": direction, "data": text})
+
+
+def _pump_interactive_stream(source, destination, direction, transcript, activity, traffic):
+ """Forward bytes while recording traffic and a bounded transcript."""
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
+ try:
+ while True:
+ chunk = source.read(4096)
+ if not chunk:
+ break
+ now = time.monotonic()
+ with transcript["_lock"]:
+ activity["last"] = max(activity["last"], now)
+ traffic[direction] = traffic.get(direction, 0) + len(chunk)
+ limit_exceeded = traffic[direction] > traffic["limit"]
+ if limit_exceeded:
+ break
+ _record_interactive_transcript(transcript, direction, chunk, decoder)
+ destination.write(chunk)
+ destination.flush()
+ except (BrokenPipeError, OSError, ValueError):
+ pass
+ finally:
+ _record_interactive_transcript(transcript, direction, b"", decoder, final=True)
+ for stream in (destination, source):
+ try:
+ stream.close()
+ except (OSError, ValueError):
+ pass
+
+
+def _interactive_evidence(
+ traffic,
+ solution_stderr,
+ interactor_stderr,
+ feedback_dir=None,
+):
+ solution_stderr_bytes = output_path_size(solution_stderr, required=True)[0]
+ interactor_stderr_bytes = output_path_size(interactor_stderr, required=True)[0]
+ feedback_bytes = 0
+ if feedback_dir:
+ for name in _FEEDBACK_NAMES:
+ feedback_bytes += output_path_size(Path(feedback_dir) / name)[0]
+ lock = traffic.get("_lock")
+ if lock is None:
+ solution_protocol_bytes = traffic.get("solution_to_interactor", 0)
+ interactor_protocol_bytes = traffic.get("interactor_to_solution", 0)
+ limit = int(traffic["limit"])
+ else:
+ with lock:
+ solution_protocol_bytes = traffic.get("solution_to_interactor", 0)
+ interactor_protocol_bytes = traffic.get("interactor_to_solution", 0)
+ limit = int(traffic["limit"])
+ return {
+ "limit_bytes": limit,
+ "solution_to_interactor": int(solution_protocol_bytes),
+ "interactor_to_solution": int(interactor_protocol_bytes),
+ "solution_stderr": int(solution_stderr_bytes),
+ "interactor_stderr": int(interactor_stderr_bytes),
+ "feedback": int(feedback_bytes),
+ }
+
+
+def _interactive_output_classification(evidence, diagnostic_limit_bytes):
+ limit = evidence["limit_bytes"]
+ if evidence["interactor_to_solution"] > limit:
+ return {
+ "status": "FAIL",
+ "verdict": None,
+ "execution_status": "output_limit",
+ "failure_kind": "resource_limit",
+ "actor": "interactor",
+ "termination_reason": "output_limit",
+ "message": "interactor output limit exceeded",
+ }
+ if evidence["interactor_stderr"] + evidence["feedback"] > int(diagnostic_limit_bytes):
+ return {
+ "status": "FAIL",
+ "verdict": None,
+ "execution_status": "output_limit",
+ "failure_kind": "resource_limit",
+ "actor": "interactor",
+ "termination_reason": "output_limit",
+ "message": "interactor diagnostic output limit exceeded",
+ }
+ if evidence["solution_to_interactor"] + evidence["solution_stderr"] > limit:
+ return {
+ "status": "OLE",
+ "verdict": "OLE",
+ "execution_status": "output_limit",
+ "failure_kind": "resource_limit",
+ "actor": "contestant",
+ "termination_reason": "output_limit",
+ "message": "interactive output limit exceeded",
+ }
+ return None
+
+
+def _interactive_result(
+ outcome,
+ *,
+ elapsed,
+ memory,
+ memory_enforced,
+ transcript,
+ traffic_evidence,
+ cleanup,
+ exit_codes,
+ timeout_kind=None,
+ resources=None,
+):
+ lock = transcript.get("_lock")
+ if lock is None:
+ entries = [dict(entry) for entry in transcript["entries"]]
+ transcript_truncated = bool(transcript["truncated"])
+ transcript_bytes = int(transcript.get("bytes", 0))
+ else:
+ with lock:
+ entries = [dict(entry) for entry in transcript["entries"]]
+ transcript_truncated = bool(transcript["truncated"])
+ transcript_bytes = int(transcript.get("bytes", 0))
+ result = dict(outcome)
+ result.update({
+ "time": float(elapsed),
+ "memory": memory,
+ "memory_enforced": bool(memory_enforced),
+ "exit_codes": dict(exit_codes),
+ "traffic": dict(traffic_evidence or {}),
+ "transcript": entries,
+ "transcript_bytes": transcript_bytes,
+ "transcript_truncated": transcript_truncated,
+ "output_bytes": (
+ int(traffic_evidence.get("solution_to_interactor", 0))
+ + int(traffic_evidence.get("solution_stderr", 0))
+ if traffic_evidence else 0
+ ),
+ "cleanup": cleanup,
+ })
+ if resources is not None:
+ result["resources"] = resources
+ if timeout_kind:
+ result["timeout_kind"] = timeout_kind
+ return result
+
+
+def _terminate_interactive_actor(managed, actor, cleanup):
+ if managed is None:
+ return
+ key = f"{actor}_tree_termination"
+ try:
+ managed.terminate()
+ cleanup[key] = "completed"
+ except BaseException as exc:
+ cleanup[key] = "failed"
+ cleanup["errors"].append(_cleanup_error("tree_termination", actor, exc))
+
+
+def _join_interactive_pumps(threads, cleanup):
+ for thread in threads:
+ thread.join(timeout=1)
+ cleanup["pumps_joined"] = all(not thread.is_alive() for thread in threads)
+ if not cleanup["pumps_joined"] and not any(
+ item["stage"] == "pump_join" for item in cleanup["errors"]
+ ):
+ cleanup["errors"].append({
+ "stage": "pump_join",
+ "actor": "supervisor",
+ "message": "interactive pump thread did not stop",
+ })
+
+
+def execute_interactive_session(
+ contestant_command,
+ interactor_command,
+ input_path,
+ answer_path,
+ *,
+ work_dir=None,
+ time_limit=1.0,
+ memory_limit_mb=256,
+ idle_limit=None,
+ transcript_limit=65536,
+ output_limit_bytes=64 * MIB,
+ process_limit=DEFAULT_PROCESS_LIMIT,
+ env=None,
+):
+ """Run one contestant/Interactor session with explicit responsibility evidence."""
+ monotonic_start = time.monotonic()
+ wall_start = time.time()
+ idle_limit = max(
+ float(idle_limit if idle_limit is not None else min(time_limit, 2.0)),
+ 0.1,
+ )
+ state_lock = threading.RLock()
+ transcript = {
+ "entries": [],
+ "bytes": 0,
+ "limit": max(int(transcript_limit), 0),
+ "truncated": False,
+ "_lock": state_lock,
+ }
+ activity = {"last": monotonic_start}
+ traffic = {"limit": max(int(output_limit_bytes), 0), "_lock": state_lock}
+ cleanup = {
+ "ok": True,
+ "contestant_tree_termination": "not-started",
+ "interactor_tree_termination": "not-started",
+ "pumps_joined": True,
+ "runtime_removed": False,
+ "runtime_remove_attempts": 0,
+ "errors": [],
+ }
+ runtime_dir = None
+ solution_stderr = None
+ interactor_stderr = None
+ feedback_dir = None
+ solution_managed = None
+ interactor_managed = None
+ solution_proc = None
+ interactor_proc = None
+ memory_enforced = False
+ peak_memory_mb = None
+ solution_process_limit_enforced = False
+ interactor_memory_enforced = False
+ interactor_peak_memory_mb = None
+ interactor_process_limit_enforced = False
+ threads = []
+ result = None
+ cancelled_error = None
+ spawn_actor = "supervisor"
+ diagnostic_limit_bytes = min(max(int(output_limit_bytes), 0), MAX_CHECKER_DIAGNOSTIC_BYTES)
+
+ def current_exit_codes():
+ return {
+ "contestant": getattr(solution_proc, "returncode", None),
+ "interactor": getattr(interactor_proc, "returncode", None),
+ }
+
+ def current_evidence():
+ if solution_stderr is None or interactor_stderr is None:
+ return {}
+ return _interactive_evidence(
+ traffic,
+ solution_stderr,
+ interactor_stderr,
+ feedback_dir,
+ )
+
+ def finish(outcome, *, timeout_kind=None, traffic_evidence=None):
+ nonlocal result
+ actor = outcome.get("actor")
+ reported_memory = (
+ interactor_peak_memory_mb if actor == "interactor" else peak_memory_mb
+ )
+ reported_memory_enforced = (
+ interactor_memory_enforced if actor == "interactor" else memory_enforced
+ )
+ result = _interactive_result(
+ outcome,
+ elapsed=time.time() - wall_start,
+ memory=reported_memory,
+ memory_enforced=reported_memory_enforced,
+ transcript=transcript,
+ traffic_evidence=(
+ current_evidence() if traffic_evidence is None else traffic_evidence
+ ),
+ cleanup=cleanup,
+ exit_codes=current_exit_codes(),
+ timeout_kind=timeout_kind,
+ resources={
+ "contestant": {
+ "memory": peak_memory_mb,
+ "memory_enforced": bool(memory_enforced),
+ "process_limit_enforced": bool(solution_process_limit_enforced),
+ },
+ "interactor": {
+ "memory": interactor_peak_memory_mb,
+ "memory_enforced": bool(interactor_memory_enforced),
+ "process_limit_enforced": bool(interactor_process_limit_enforced),
+ },
+ },
+ )
+ return result
+
+ try:
+ runtime_dir = Path(tempfile.mkdtemp(prefix=".probhub-interactive-", dir=work_dir))
+ solution_stderr = runtime_dir / "solution.stderr"
+ interactor_stderr = runtime_dir / "interactor.stderr"
+ feedback_dir = runtime_dir / "feedback"
+ feedback_dir.mkdir()
+ with solution_stderr.open("wb") as solution_err, interactor_stderr.open("wb") as interactor_err:
+ spawn_actor = "contestant"
+ cleanup["contestant_tree_termination"] = "pending"
+ solution_managed = spawn_managed(
+ _command_list(contestant_command),
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=solution_err,
+ bufsize=0,
+ memory_limit_mb=memory_limit_mb,
+ process_limit=process_limit,
+ cwd=work_dir,
+ env=env,
+ )
+ solution_proc = solution_managed.proc
+ memory_enforced = solution_managed.memory_enforced
+ solution_process_limit_enforced = bool(
+ getattr(solution_managed, "process_limit_enforced", False)
+ )
+ spawn_actor = "interactor"
+ cleanup["interactor_tree_termination"] = "pending"
+ interactor_managed = spawn_managed(
+ [
+ *_command_list(interactor_command),
+ os.fspath(input_path),
+ os.fspath(answer_path),
+ os.fspath(feedback_dir),
+ ],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=interactor_err,
+ bufsize=0,
+ memory_limit_mb=memory_limit_mb,
+ process_limit=process_limit,
+ cwd=work_dir,
+ env=env,
+ )
+ interactor_proc = interactor_managed.proc
+ interactor_memory_enforced = interactor_managed.memory_enforced
+ interactor_process_limit_enforced = bool(
+ getattr(interactor_managed, "process_limit_enforced", False)
+ )
+ spawn_actor = "supervisor"
+ threads = [
+ threading.Thread(
+ target=_pump_interactive_stream,
+ args=(
+ solution_proc.stdout,
+ interactor_proc.stdin,
+ "solution_to_interactor",
+ transcript,
+ activity,
+ traffic,
+ ),
+ daemon=True,
+ ),
+ threading.Thread(
+ target=_pump_interactive_stream,
+ args=(
+ interactor_proc.stdout,
+ solution_proc.stdin,
+ "interactor_to_solution",
+ transcript,
+ activity,
+ traffic,
+ ),
+ daemon=True,
+ ),
+ ]
+ cleanup["pumps_joined"] = False
+ for thread in threads:
+ thread.start()
+
+ monotonic_start = time.monotonic()
+ wall_start = time.time()
+ activity["last"] = max(activity["last"], monotonic_start)
+ last_resource_sample = monotonic_start
+ deadline = monotonic_start + float(time_limit)
+ while solution_proc.poll() is None or interactor_proc.poll() is None:
+ if cancellation_requested():
+ raise ProcessCancelled("execution cancelled")
+ now = time.monotonic()
+ evidence = current_evidence()
+ resource_outcome = _interactive_output_classification(
+ evidence, diagnostic_limit_bytes
+ )
+ if now - last_resource_sample >= 0.05:
+ last_resource_sample = now
+ solution_count, solution_memory = solution_managed.sample()
+ peak_memory_mb = solution_managed.peak_memory_mb
+ if solution_memory is not None and solution_memory >= memory_limit_mb:
+ resource_outcome = {
+ "status": "MLE", "verdict": "MLE",
+ "execution_status": "memory_limit", "failure_kind": "resource_limit",
+ "actor": "contestant", "termination_reason": "memory_limit",
+ "message": "memory limit exceeded",
+ }
+ elif solution_count is not None and solution_count > process_limit:
+ resource_outcome = {
+ "status": "RE", "verdict": "RE",
+ "execution_status": "process_limit", "failure_kind": "resource_limit",
+ "actor": "contestant", "termination_reason": "process_limit",
+ "message": "process limit exceeded",
+ }
+ interactor_count, interactor_memory = interactor_managed.sample()
+ interactor_peak_memory_mb = interactor_managed.peak_memory_mb
+ if interactor_memory is not None and interactor_memory >= memory_limit_mb:
+ resource_outcome = {
+ "status": "FAIL", "verdict": None,
+ "execution_status": "memory_limit", "failure_kind": "resource_limit",
+ "actor": "interactor", "termination_reason": "memory_limit",
+ "message": "interactor memory limit exceeded",
+ }
+ elif interactor_count is not None and interactor_count > process_limit:
+ resource_outcome = {
+ "status": "FAIL", "verdict": None,
+ "execution_status": "process_limit", "failure_kind": "resource_limit",
+ "actor": "interactor", "termination_reason": "process_limit",
+ "message": "interactor process limit exceeded",
+ }
+ elif interactor_proc.poll() is not None:
+ protocol = _protocol_outcome(interactor_proc.returncode, "", "interactor")
+ if protocol["status"] == "FAIL":
+ resource_outcome = protocol
+ if resource_outcome:
+ _terminate_interactive_actor(solution_managed, "contestant", cleanup)
+ solution_managed = None
+ _terminate_interactive_actor(interactor_managed, "interactor", cleanup)
+ interactor_managed = None
+ _join_interactive_pumps(threads, cleanup)
+ return finish(resource_outcome)
+
+ timeout_kind = None
+ if now >= deadline:
+ timeout_kind = "total"
+ else:
+ with state_lock:
+ idle_elapsed = now - activity["last"]
+ if idle_elapsed >= idle_limit:
+ timeout_kind = "idle"
+ if timeout_kind:
+ solution_managed.sample()
+ peak_memory_mb = solution_managed.peak_memory_mb
+ _terminate_interactive_actor(solution_managed, "contestant", cleanup)
+ solution_managed = None
+ _terminate_interactive_actor(interactor_managed, "interactor", cleanup)
+ interactor_managed = None
+ _join_interactive_pumps(threads, cleanup)
+ return finish({
+ "status": "TLE",
+ "verdict": "TLE",
+ "execution_status": "time_limit",
+ "failure_kind": "resource_limit",
+ "actor": "session",
+ "termination_reason": "time_limit",
+ "message": (
+ "interactive idle timeout exceeded"
+ if timeout_kind == "idle"
+ else "interactive time limit exceeded"
+ ),
+ }, timeout_kind=timeout_kind)
+ time.sleep(0.005)
+
+ solution_managed.sample()
+ peak_memory_mb = solution_managed.peak_memory_mb
+ interactor_managed.sample()
+ interactor_peak_memory_mb = interactor_managed.peak_memory_mb
+ _terminate_interactive_actor(solution_managed, "contestant", cleanup)
+ solution_managed = None
+ _terminate_interactive_actor(interactor_managed, "interactor", cleanup)
+ interactor_managed = None
+ _join_interactive_pumps(threads, cleanup)
+ evidence = current_evidence()
+ resource_outcome = _interactive_output_classification(
+ evidence, diagnostic_limit_bytes
+ )
+ if (
+ interactor_proc.returncode != 0
+ and interactor_memory_enforced
+ and interactor_peak_memory_mb is not None
+ and interactor_peak_memory_mb >= memory_limit_mb * 0.98
+ ):
+ resource_outcome = {
+ "status": "FAIL", "verdict": None,
+ "execution_status": "memory_limit", "failure_kind": "resource_limit",
+ "actor": "interactor", "termination_reason": "memory_limit",
+ "message": "interactor memory limit exceeded",
+ }
+
+ solution_message = ""
+ interactor_message = ""
+ try:
+ solution_message = read_bounded_text(
+ solution_stderr, max(int(output_limit_bytes), 0)
+ )["text"].strip()
+ except OSError:
+ pass
+ try:
+ interactor_message = read_bounded_text(
+ interactor_stderr, diagnostic_limit_bytes
+ )["text"].strip()
+ except OSError:
+ pass
+ interactor_message = _feedback_message(
+ feedback_dir, interactor_message, diagnostic_limit_bytes
+ )[0]
+ if resource_outcome:
+ return finish(resource_outcome)
+ if solution_proc.returncode != 0:
+ inferred_memory = (
+ memory_enforced
+ and peak_memory_mb is not None
+ and peak_memory_mb >= memory_limit_mb * 0.98
+ )
+ if inferred_memory:
+ outcome = {
+ "status": "MLE", "verdict": "MLE",
+ "execution_status": "memory_limit", "failure_kind": "resource_limit",
+ "actor": "contestant", "termination_reason": "inferred_memory_limit",
+ "message": solution_message,
+ }
+ else:
+ outcome = {
+ "status": "RE", "verdict": "RE",
+ "execution_status": "completed", "failure_kind": "runtime_error",
+ "actor": "contestant", "termination_reason": "completed",
+ "message": solution_message,
+ }
+ return finish(outcome)
+ return finish(_protocol_outcome(
+ interactor_proc.returncode, interactor_message, "interactor"
+ ))
+ except ProcessCancelled as exc:
+ cancelled_error = exc
+ finish({
+ "status": "cancelled",
+ "verdict": None,
+ "execution_status": "cancelled",
+ "failure_kind": "cancelled",
+ "actor": "supervisor",
+ "termination_reason": "cancelled",
+ "message": str(exc),
+ }, traffic_evidence={})
+ except OutputBudgetError as exc:
+ return finish({
+ "status": "FAIL",
+ "verdict": None,
+ "execution_status": "output_control_error",
+ "failure_kind": "control_failure",
+ "actor": "supervisor",
+ "termination_reason": "output_control_error",
+ "message": str(exc),
+ }, traffic_evidence={})
+ except OSError as exc:
+ return finish({
+ "status": "FAIL",
+ "verdict": None,
+ "execution_status": "start_error",
+ "failure_kind": "startup_failure",
+ "actor": spawn_actor,
+ "termination_reason": "start_error",
+ "message": str(exc),
+ }, traffic_evidence={})
+ finally:
+ _terminate_interactive_actor(solution_managed, "contestant", cleanup)
+ _terminate_interactive_actor(interactor_managed, "interactor", cleanup)
+ _join_interactive_pumps(threads, cleanup)
+ removed, attempts, error = _remove_runtime_directory(runtime_dir)
+ cleanup["runtime_removed"] = removed
+ cleanup["runtime_remove_attempts"] = attempts
+ if error is not None:
+ cleanup["errors"].append(
+ _cleanup_error("runtime_remove", "supervisor", error)
+ )
+ cleanup["ok"] = not cleanup["errors"]
+ if result is not None:
+ _apply_cleanup_failure(result, cleanup)
+ if cancelled_error is not None:
+ if result is not None and result.get("failure_kind") == "cleanup_failure":
+ return result
+ raise cancelled_error
+ return result
diff --git a/probhub/stressing.py b/probhub/stressing.py
index b697acc..029ac06 100644
--- a/probhub/stressing.py
+++ b/probhub/stressing.py
@@ -15,7 +15,7 @@
from .build_lock import workspace_build_lock
from .errors import ProbHubError
-from .io import normalize_newlines, read_bounded_text, read_yaml, write_json, write_yaml
+from .io import normalize_newlines, read_yaml, write_json, write_yaml
from .output_compare import compare_standard_output
from .process_control import (
DEFAULT_PROCESS_LIMIT,
@@ -23,6 +23,7 @@
allocate_shared_prefix_bytes,
run_managed_to_files,
)
+from .special_judges import MAX_CHECKER_DIAGNOSTIC_BYTES, run_checker_to_files
from .transactions import (
TRANSACTION_PHASE_COMMITTED,
TRANSACTION_PHASE_PREPARED,
@@ -40,7 +41,6 @@
_CASE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
DEFAULT_TOOL_TIMEOUT = 5.0
MIB = 1024 * 1024
-MAX_CHECKER_DIAGNOSTIC_BYTES = 8 * MIB
STRESS_METADATA_BUDGET_BYTES = 64 * 1024
@@ -349,28 +349,6 @@ def _run(
}
-def _feedback_message(feedback_dir, fallback="", limit_bytes=MAX_CHECKER_DIAGNOSTIC_BYTES):
- for name in ("judgemessage.txt", "teammessage.txt"):
- path = Path(feedback_dir) / name
- try:
- result = read_bounded_text(path, limit_bytes)
- except OSError:
- continue
- message = result["text"].strip()
- if message:
- result["source"] = name
- return message, result
- encoded = (fallback or "").encode("utf-8", errors="replace")
- retained = encoded[: max(int(limit_bytes), 0)]
- return retained.decode("utf-8", errors="replace").strip(), {
- "source": "stderr",
- "observed_bytes": len(encoded),
- "retained_bytes": len(retained),
- "truncated": len(retained) < len(encoded),
- "exists": bool(encoded),
- }
-
-
def _compare_custom(
checker_command,
input_path,
@@ -382,28 +360,27 @@ def _compare_custom(
output_limit,
process_limit,
):
- feedback_dir = Path(tempfile.mkdtemp(prefix="feedback-", dir=cwd))
+ contestant_dir = Path(tempfile.mkdtemp(prefix="checker-input-", dir=cwd))
try:
+ contestant_output_path = contestant_dir / "contestant.out"
+ contestant_output_path.write_bytes(brute_output)
diagnostic_limit_bytes = min(int(output_limit * MIB), MAX_CHECKER_DIAGNOSTIC_BYTES)
- feedback_paths = tuple(
- feedback_dir / name for name in ("judgemessage.txt", "teammessage.txt")
- )
- result = _run(
- [*checker_command, str(input_path), str(answer_path), str(feedback_dir)],
- brute_output,
- timeout,
- cwd,
- memory_limit,
- min(output_limit, 8),
- process_limit,
- additional_output_paths=feedback_paths,
+ env = os.environ.copy()
+ env["PYTHONIOENCODING"] = "utf-8"
+ result = run_checker_to_files(
+ checker_command,
+ input_path,
+ answer_path,
+ contestant_output_path,
+ timeout=timeout,
+ cwd=cwd,
+ memory_limit_mb=memory_limit,
+ output_limit_bytes=int(output_limit * MIB),
+ process_limit=process_limit,
+ diagnostic_limit_bytes=diagnostic_limit_bytes,
+ env=env,
)
stderr = result.get("stderr") or b""
- message, feedback = _feedback_message(
- feedback_dir,
- stderr.decode("utf-8", errors="replace"),
- diagnostic_limit_bytes,
- )
output_details = {
key: result.get(key)
for key in (
@@ -414,44 +391,30 @@ def _compare_custom(
"output_truncated",
)
}
- if result.get("reason") != "completed":
- return {
- "status": "FAIL",
- "execution_status": result.get("status"),
- "match": False,
- "message": message or f"checker {result.get('message') or result.get('reason')}",
- "stderr": stderr,
- "feedback": feedback,
- **output_details,
- }
- if result["returncode"] in {0, 42}:
- return {
- "status": "AC",
- "match": True,
- "message": message,
- "stderr": stderr,
- "feedback": feedback,
- **output_details,
- }
- if result["returncode"] in {1, 2, 43}:
- return {
- "status": "WA",
- "match": False,
- "message": message,
- "stderr": stderr,
- "feedback": feedback,
- **output_details,
- }
- return {
- "status": "FAIL",
- "match": False,
- "message": message or f"checker exited with code {result['returncode']}",
+ verdict = result.get("verdict")
+ diagnostic_message = result.get("message") or ""
+ if result.get("failure_kind") != "cleanup_failure":
+ diagnostic_message = result.get("feedback_message") or diagnostic_message
+ comparison = {
+ "status": verdict or "FAIL",
+ "match": verdict == "AC",
+ "message": diagnostic_message,
"stderr": stderr,
- "feedback": feedback,
+ "feedback": result.get("feedback"),
**output_details,
}
+ if verdict is None and result.get("termination_reason") != "completed":
+ comparison["execution_status"] = {
+ "time_limit": "TLE",
+ "memory_limit": "MLE",
+ "output_limit": "OLE",
+ "process_limit": "RE",
+ "start_error": "RE",
+ "output_control_error": "FAIL",
+ }.get(result.get("execution_status"), "RE")
+ return comparison
finally:
- shutil.rmtree(feedback_dir, ignore_errors=True)
+ shutil.rmtree(contestant_dir, ignore_errors=True)
def _comparison(configured, commands, round_dir, input_data, accepted_output, brute_output):
diff --git a/scripts/local_judge.py b/scripts/local_judge.py
index 5282c4d..5a2b599 100644
--- a/scripts/local_judge.py
+++ b/scripts/local_judge.py
@@ -1,4 +1,3 @@
-import codecs
import hashlib
import json
import os
@@ -6,10 +5,8 @@
import re
import shutil
import signal
-import subprocess
import sys
import tempfile
-import threading
import time
import yaml
@@ -29,14 +26,14 @@
from probhub.errors import ProbHubError
from probhub.io import read_bounded_text
from probhub.output_compare import compare_standard_output
+from probhub.problem_paths import ProblemPathError, resolve_problem_regular_file
+from probhub.special_judges import execute_interactive_session, run_checker_to_files
from probhub.process_control import (
DEFAULT_PROCESS_LIMIT,
OutputBudgetError,
ProcessCancelled,
cancellation_requested,
- output_path_size,
run_managed_to_files,
- spawn_managed,
)
from probhub.solutions import (
analyze_solution_verification,
@@ -569,7 +566,10 @@ def resolve_problem_path(prob_dir, entry):
relative = _entry_file(entry)
if not relative:
return None
- return os.path.normpath(os.path.join(prob_dir, str(relative)))
+ try:
+ return os.fspath(resolve_problem_regular_file(prob_dir, str(relative)))
+ except ProblemPathError:
+ return None
def display_problem_path(prob_dir, path):
@@ -984,29 +984,6 @@ def run_sample_answer_testcase(
_remove_file_with_retries(output_file)
-def _checker_result(returncode, message):
- message = (message or "").strip()
- if returncode in {0, 42}:
- return "AC", message
- if returncode in {1, 2, 43}:
- return "WA", message
- return "FAIL", message or f"checker exited with code {returncode}"
-
-
-def _feedback_message(feedback_dir, fallback="", limit_bytes=MAX_TOOL_DIAGNOSTIC_BYTES):
- for name in ("judgemessage.txt", "teammessage.txt"):
- path = os.path.join(feedback_dir, name)
- try:
- result = read_bounded_text(path, limit_bytes)
- except OSError:
- continue
- message = result["text"].strip()
- if message:
- return message
- encoded = (fallback or "").encode("utf-8", errors="replace")
- return encoded[: max(int(limit_bytes), 0)].decode("utf-8", errors="replace").strip()
-
-
def run_custom_testcase(
bin_path,
checker_bin,
@@ -1018,13 +995,8 @@ def run_custom_testcase(
process_limit=DEFAULT_PROCESS_LIMIT,
capture_sample_answer=False,
):
- """Run a DOMjudge/testlib-style output validator.
-
- The validator receives as argv and reads
- contestant output from stdin, matching DOMjudge's custom validation protocol.
- """
+ """Run a contestant and adapt the shared Checker result to the legacy tuple."""
output_file = _temporary_output_path(bin_path)
- feedback_dir = tempfile.mkdtemp(prefix=".probhub-feedback-", dir=os.path.dirname(bin_path))
try:
status, elapsed, memory, memory_enforced, message, details = run_program_to_file(
bin_path, in_file, output_file, time_limit, memory_limit, output_limit, process_limit
@@ -1032,214 +1004,79 @@ def run_custom_testcase(
if capture_sample_answer:
details["sample_answer"] = compare_sample_answer(output_file, ans_file)
if status != "AC":
+ termination_reason = details.get("termination_reason")
+ if not termination_reason:
+ termination_reason = "start_error"
+ details["termination_reason"] = termination_reason
+ details.update({
+ "verdict": status if status != "FAIL" else None,
+ "execution_status": (
+ "memory_limit" if termination_reason == "inferred_memory_limit"
+ else termination_reason
+ if termination_reason in {
+ "time_limit", "memory_limit", "output_limit", "process_limit",
+ "output_control_error", "start_error",
+ }
+ else "completed"
+ ),
+ "failure_kind": (
+ "control_failure" if status == "FAIL"
+ else "startup_failure" if termination_reason == "start_error"
+ else "resource_limit" if status in {"TLE", "MLE", "OLE"}
+ or termination_reason == "process_limit"
+ else "runtime_error"
+ ),
+ "actor": "supervisor" if status == "FAIL" else "contestant",
+ })
return status, elapsed, memory, memory_enforced, message, details
- checker_stdout = output_file + ".checker.out"
- checker_stderr = output_file + ".checker.stderr"
- try:
- diagnostic_limit_bytes = min(
+ checker = run_checker_to_files(
+ [checker_bin],
+ in_file,
+ ans_file,
+ output_file,
+ timeout=max(5.0, float(time_limit)),
+ cwd=os.path.dirname(bin_path),
+ memory_limit_mb=memory_limit,
+ output_limit_bytes=min(
int(output_limit * 1024 * 1024), MAX_TOOL_DIAGNOSTIC_BYTES
- )
- feedback_paths = tuple(
- os.path.join(feedback_dir, name)
- for name in ("judgemessage.txt", "teammessage.txt")
- )
- checker = run_managed_to_files(
- [checker_bin, in_file, ans_file, feedback_dir],
- input_path=output_file,
- stdout_path=checker_stdout,
- stderr_path=checker_stderr,
- additional_output_paths=feedback_paths,
- timeout=max(5.0, float(time_limit)),
- memory_limit_mb=memory_limit,
- output_limit_bytes=min(int(output_limit * 1024 * 1024), 8 * 1024 * 1024),
- process_limit=process_limit,
- )
- try:
- stderr = open(checker_stderr, "r", encoding="utf-8", errors="replace").read()
- except OSError:
- stderr = ""
- checker_message = _feedback_message(
- feedback_dir, stderr, diagnostic_limit_bytes
- )
- if checker["reason"] != "completed":
- detail = checker["message"] or checker["reason"].replace("_", " ")
- return "FAIL", elapsed, memory, memory_enforced, f"checker {detail}", details
- checker_status, checker_message = _checker_result(
- checker["returncode"], checker_message
- )
- return checker_status, elapsed, memory, memory_enforced, checker_message, details
- except OutputBudgetError as exc:
- details = dict(details)
- details["checker_termination_reason"] = "output_control_error"
- return (
- "FAIL",
- elapsed,
- memory,
- memory_enforced,
- f"checker output control failed: {exc}",
- details,
- )
- except OSError as exc:
- return "FAIL", elapsed, memory, memory_enforced, f"checker failed to start: {exc}", details
- finally:
- for file_name in (checker_stdout, checker_stderr):
- _remove_file_with_retries(file_name)
- finally:
- _remove_file_with_retries(output_file)
- shutil.rmtree(feedback_dir, ignore_errors=True)
-
-
-def _record_interactive_transcript(transcript, direction, payload, decoder, *, final=False):
- """Reserve the shared byte budget and decode one direction incrementally."""
- with transcript["_lock"]:
- remaining = max(transcript["limit"] - transcript["bytes"], 0)
- saved = payload[:remaining]
- transcript["bytes"] += len(saved)
- if len(saved) < len(payload):
- transcript["truncated"] = True
- text = decoder.decode(saved, final=final)
- if text:
- transcript["entries"].append({
- "direction": direction,
- "data": text,
- })
-
-
-def _pump_interactive_stream(source, destination, direction, transcript, activity, traffic):
- """Forward bytes while recording a bounded transcript and last activity time."""
- decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
- try:
- while True:
- chunk = source.read(4096)
- if not chunk:
- break
- now = time.monotonic()
- with transcript["_lock"]:
- activity["last"] = max(activity["last"], now)
- traffic[direction] = traffic.get(direction, 0) + len(chunk)
- limit_exceeded = traffic[direction] > traffic["limit"]
- if limit_exceeded:
- break
- _record_interactive_transcript(transcript, direction, chunk, decoder)
- destination.write(chunk)
- destination.flush()
- except (BrokenPipeError, OSError, ValueError):
- pass
- finally:
- _record_interactive_transcript(
- transcript, direction, b"", decoder, final=True
+ ),
+ process_limit=process_limit,
)
- try:
- destination.close()
- except (OSError, ValueError):
- pass
- try:
- source.close()
- except (OSError, ValueError):
- pass
-
-
-def _interactive_solution_output_bytes(traffic, solution_stderr):
- try:
- stderr_size = os.path.getsize(solution_stderr)
- except OSError:
- stderr_size = 0
- lock = traffic.get("_lock")
- if lock is None:
- protocol_bytes = traffic.get("solution_to_interactor", 0)
- else:
- with lock:
- protocol_bytes = traffic.get("solution_to_interactor", 0)
- return int(protocol_bytes + stderr_size)
-
-
-def _interactive_result(
- status,
- elapsed,
- memory,
- memory_enforced,
- message,
- transcript,
- timeout_kind=None,
- traffic=None,
- solution_stderr=None,
- termination_reason=None,
-):
- lock = transcript.get("_lock")
- if lock is None:
- entries = [dict(entry) for entry in transcript["entries"]]
- transcript_truncated = transcript["truncated"]
- else:
- with lock:
- entries = [dict(entry) for entry in transcript["entries"]]
- transcript_truncated = transcript["truncated"]
- details = {
- "transcript": entries,
- "transcript_truncated": transcript_truncated,
- }
- if traffic is not None and solution_stderr is not None:
- details["output_bytes"] = _interactive_solution_output_bytes(
- traffic, solution_stderr
+ checker_status = checker.get("verdict") or "FAIL"
+ actor = checker.get("actor")
+ failure_kind = checker.get("failure_kind")
+ if checker_status == "AC":
+ actor = "session"
+ elif checker_status == "WA":
+ actor = "contestant"
+ failure_kind = "wrong_answer"
+ details.update({
+ "verdict": checker.get("verdict"),
+ "execution_status": checker.get("execution_status"),
+ "failure_kind": failure_kind,
+ "actor": actor,
+ "termination_reason": checker.get("termination_reason"),
+ "checker_termination_reason": checker.get("termination_reason"),
+ "cleanup": checker.get("cleanup"),
+ })
+ checker_message = checker.get("message") or ""
+ if checker_status == "FAIL":
+ if checker.get("execution_status") == "output_control_error":
+ checker_message = f"checker output control failed: {checker_message}"
+ elif checker.get("execution_status") == "start_error":
+ checker_message = f"checker failed to start: {checker_message}"
+ elif checker.get("termination_reason") != "completed":
+ checker_message = f"checker {checker_message}"
+ return (
+ checker_status,
+ elapsed,
+ memory,
+ memory_enforced,
+ checker_message,
+ details,
)
- if termination_reason:
- details["termination_reason"] = termination_reason
- if timeout_kind:
- details["timeout_kind"] = timeout_kind
- return status, elapsed, memory, memory_enforced, message, details
-
-
-def _interactive_output_status(
- traffic,
- solution_stderr,
- interactor_stderr,
- feedback_dir=None,
- diagnostic_limit_bytes=MAX_TOOL_DIAGNOSTIC_BYTES,
-):
- """Classify combined protocol/stderr output after every lifecycle edge."""
- solution_stderr_size = output_path_size(solution_stderr, required=True)[0]
- interactor_stderr_size = output_path_size(interactor_stderr, required=True)[0]
- feedback_size = 0
- if feedback_dir:
- for name in ("judgemessage.txt", "teammessage.txt"):
- feedback_size += output_path_size(
- os.path.join(feedback_dir, name)
- )[0]
-
- lock = traffic.get("_lock")
- if lock is None:
- limit = int(traffic["limit"])
- interactor_protocol_bytes = traffic.get("interactor_to_solution", 0)
- solution_protocol_bytes = traffic.get("solution_to_interactor", 0)
- else:
- with lock:
- limit = int(traffic["limit"])
- interactor_protocol_bytes = traffic.get("interactor_to_solution", 0)
- solution_protocol_bytes = traffic.get("solution_to_interactor", 0)
- interactor_diagnostic_bytes = interactor_stderr_size + feedback_size
- solution_bytes = solution_protocol_bytes + solution_stderr_size
- if interactor_protocol_bytes > limit:
- return "FAIL", "interactor output limit exceeded"
- if interactor_diagnostic_bytes > int(diagnostic_limit_bytes):
- return "FAIL", "interactor diagnostic output limit exceeded"
- if solution_bytes > limit:
- return "OLE", "interactive output limit exceeded"
- return None, ""
-
-
-def _remove_interactive_temp(path):
- """Best-effort cleanup for Windows handles that may close asynchronously."""
- if not path:
- return
- for _ in range(20):
- try:
- shutil.rmtree(path)
- return
- except FileNotFoundError:
- return
- except PermissionError:
- time.sleep(0.05)
- except OSError:
- return
+ finally:
+ _remove_file_with_retries(output_file)
def run_interactive_testcase(
@@ -1255,285 +1092,43 @@ def run_interactive_testcase(
process_limit=DEFAULT_PROCESS_LIMIT,
):
"""Connect contestant and interactor with transcript, idle and total deadlines."""
- start = time.time()
- monotonic_start = time.monotonic()
- idle_limit = max(float(idle_limit if idle_limit is not None else min(time_limit, 2.0)), 0.1)
- state_lock = threading.RLock()
- transcript = {
- "entries": [],
- "bytes": 0,
- "limit": max(int(transcript_limit), 0),
- "truncated": False,
- "_lock": state_lock,
+ result = execute_interactive_session(
+ [bin_path],
+ [interactor_bin],
+ in_file,
+ ans_file,
+ work_dir=os.path.dirname(bin_path),
+ time_limit=time_limit,
+ memory_limit_mb=memory_limit,
+ idle_limit=idle_limit,
+ transcript_limit=transcript_limit,
+ output_limit_bytes=int(output_limit * 1024 * 1024),
+ process_limit=process_limit,
+ )
+ details = {
+ "transcript": result.get("transcript", []),
+ "transcript_truncated": result.get("transcript_truncated", False),
+ "output_bytes": result.get("output_bytes"),
+ "termination_reason": result.get("termination_reason"),
+ "verdict": result.get("verdict"),
+ "execution_status": result.get("execution_status"),
+ "failure_kind": result.get("failure_kind"),
+ "actor": result.get("actor"),
+ "cleanup": result.get("cleanup"),
+ "exit_codes": result.get("exit_codes"),
+ "traffic": result.get("traffic"),
+ "resources": result.get("resources"),
}
- activity = {"last": monotonic_start}
- traffic = {"limit": int(output_limit * 1024 * 1024), "_lock": state_lock}
- last_resource_sample = monotonic_start
- solution_managed = None
- interactor_managed = None
- memory_enforced = False
- peak_memory_mb = None
- threads = []
- runtime_dir = tempfile.mkdtemp(prefix=".probhub-interactive-", dir=os.path.dirname(bin_path))
- solution_stderr = os.path.join(runtime_dir, "solution.stderr")
- interactor_stderr = os.path.join(runtime_dir, "interactor.stderr")
- feedback_dir = os.path.join(runtime_dir, "feedback")
- os.mkdir(feedback_dir)
- diagnostic_limit_bytes = min(
- int(output_limit * 1024 * 1024), MAX_TOOL_DIAGNOSTIC_BYTES
+ if result.get("timeout_kind"):
+ details["timeout_kind"] = result["timeout_kind"]
+ return (
+ result.get("status") or "FAIL",
+ float(result.get("time") or 0.0),
+ result.get("memory"),
+ bool(result.get("memory_enforced")),
+ result.get("message") or "",
+ details,
)
- try:
- with open(solution_stderr, "wb") as solution_err, open(interactor_stderr, "wb") as interactor_err:
- solution_managed = spawn_managed(
- [bin_path],
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=solution_err,
- bufsize=0,
- memory_limit_mb=memory_limit,
- process_limit=process_limit,
- )
- memory_enforced = solution_managed.memory_enforced
- solution = solution_managed.proc
- interactor_managed = spawn_managed(
- [interactor_bin, in_file, ans_file, feedback_dir],
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=interactor_err,
- bufsize=0,
- memory_limit_mb=memory_limit,
- process_limit=process_limit,
- )
- interactor = interactor_managed.proc
-
- threads = [
- threading.Thread(
- target=_pump_interactive_stream,
- args=(solution.stdout, interactor.stdin, "solution_to_interactor", transcript, activity, traffic),
- daemon=True,
- ),
- threading.Thread(
- target=_pump_interactive_stream,
- args=(interactor.stdout, solution.stdin, "interactor_to_solution", transcript, activity, traffic),
- daemon=True,
- ),
- ]
- for thread in threads:
- thread.start()
- # Process creation and Windows Job setup are infrastructure time, not
- # contestant execution or protocol idleness. Start both clocks once
- # both pumps can observe traffic so a slow runner cannot consume the
- # formal TL before the interactive session is ready.
- monotonic_start = time.monotonic()
- start = time.time()
- activity["last"] = max(activity["last"], monotonic_start)
- last_resource_sample = monotonic_start
-
- deadline = monotonic_start + float(time_limit)
- while solution.poll() is None or interactor.poll() is None:
- if cancellation_requested():
- raise ProcessCancelled("execution cancelled")
- now = time.monotonic()
- resource_status, resource_message = _interactive_output_status(
- traffic,
- solution_stderr,
- interactor_stderr,
- feedback_dir,
- diagnostic_limit_bytes,
- )
- if now - last_resource_sample >= 0.05:
- last_resource_sample = now
- solution_count, solution_memory = solution_managed.sample()
- peak_memory_mb = solution_managed.peak_memory_mb
- if solution_memory is not None and solution_memory >= memory_limit:
- resource_status, resource_message = "MLE", "memory limit exceeded"
- elif solution_count is not None and solution_count > process_limit:
- resource_status, resource_message = "RE", "process limit exceeded"
- interactor_count, interactor_memory = interactor_managed.sample()
- if interactor_memory is not None and interactor_memory >= memory_limit:
- resource_status, resource_message = "FAIL", "interactor memory limit exceeded"
- elif interactor_count is not None and interactor_count > process_limit:
- resource_status, resource_message = "FAIL", "interactor process limit exceeded"
- elif interactor.poll() is not None:
- interactor_status, interactor_message = _checker_result(
- interactor.returncode, ""
- )
- if interactor_status == "FAIL":
- resource_status = "FAIL"
- resource_message = interactor_message
- if resource_status:
- solution_managed.terminate()
- solution_managed = None
- interactor_managed.terminate()
- interactor_managed = None
- for thread in threads:
- thread.join(timeout=1)
- return _interactive_result(
- resource_status,
- time.time() - start,
- peak_memory_mb,
- memory_enforced,
- resource_message,
- transcript,
- traffic=traffic,
- solution_stderr=solution_stderr,
- termination_reason=(
- "memory_limit" if resource_status == "MLE"
- else "output_limit" if resource_status == "OLE"
- else None
- ),
- )
- timeout_kind = None
- if now >= deadline:
- timeout_kind = "total"
- else:
- with state_lock:
- idle_elapsed = now - activity["last"]
- if idle_elapsed >= idle_limit:
- timeout_kind = "idle"
- if timeout_kind:
- solution_managed.sample()
- peak_memory_mb = solution_managed.peak_memory_mb
- solution_managed.terminate()
- solution_managed = None
- interactor_managed.terminate()
- interactor_managed = None
- for thread in threads:
- thread.join(timeout=1)
- message = (
- "interactive idle timeout exceeded"
- if timeout_kind == "idle"
- else "interactive time limit exceeded"
- )
- elapsed = min(time.time() - start, float(time_limit))
- return _interactive_result(
- "TLE",
- elapsed,
- peak_memory_mb,
- memory_enforced,
- message,
- transcript,
- timeout_kind=timeout_kind,
- traffic=traffic,
- solution_stderr=solution_stderr,
- termination_reason="time_limit",
- )
- time.sleep(0.005)
-
- # Both direct processes may exit while descendants keep their pipes
- # or diagnostic files open. Preserve telemetry, terminate both full
- # trees, then classify the stable final byte counts.
- solution_managed.sample()
- peak_memory_mb = solution_managed.peak_memory_mb
- interactor_managed.sample()
- interactor_peak_memory = interactor_managed.peak_memory_mb
- solution_managed.terminate()
- interactor_managed.terminate()
- for thread in threads:
- thread.join(timeout=1)
- resource_status, resource_message = _interactive_output_status(
- traffic,
- solution_stderr,
- interactor_stderr,
- feedback_dir,
- diagnostic_limit_bytes,
- )
- if (
- interactor.returncode != 0
- and interactor_managed.memory_enforced
- and interactor_peak_memory is not None
- and interactor_peak_memory >= memory_limit * 0.98
- ):
- resource_status = "FAIL"
- resource_message = "interactor memory limit exceeded"
-
- elapsed = time.time() - start
- solution_message = ""
- interactor_message = ""
- try:
- solution_message = read_bounded_text(
- solution_stderr, int(output_limit * 1024 * 1024)
- )["text"].strip()
- except OSError:
- pass
- try:
- interactor_message = read_bounded_text(
- interactor_stderr, diagnostic_limit_bytes
- )["text"].strip()
- except OSError:
- pass
- interactor_message = _feedback_message(
- feedback_dir, interactor_message, diagnostic_limit_bytes
- )
-
- if resource_status:
- return _interactive_result(
- resource_status,
- elapsed,
- peak_memory_mb,
- memory_enforced,
- resource_message,
- transcript,
- traffic=traffic,
- solution_stderr=solution_stderr,
- termination_reason=(
- "memory_limit" if resource_status == "MLE"
- else "output_limit" if resource_status == "OLE"
- else None
- ),
- )
-
- if solution.returncode != 0:
- status = _failed_status(
- solution.returncode,
- solution_message,
- memory_enforced,
- peak_memory_mb,
- memory_limit,
- )
- return _interactive_result(
- status,
- elapsed,
- peak_memory_mb,
- memory_enforced,
- solution_message,
- transcript,
- traffic=traffic,
- solution_stderr=solution_stderr,
- termination_reason=(
- "inferred_memory_limit" if status == "MLE" else None
- ),
- )
- interactor_status, message = _checker_result(interactor.returncode, interactor_message)
- return _interactive_result(
- interactor_status,
- elapsed,
- peak_memory_mb,
- memory_enforced,
- message,
- transcript,
- traffic=traffic,
- solution_stderr=solution_stderr,
- )
- except OSError as exc:
- return _interactive_result(
- "FAIL",
- time.time() - start,
- peak_memory_mb,
- memory_enforced,
- str(exc),
- transcript,
- traffic=traffic,
- solution_stderr=solution_stderr,
- )
- finally:
- if solution_managed is not None:
- solution_managed.terminate()
- if interactor_managed is not None:
- interactor_managed.terminate()
- for thread in threads:
- thread.join(timeout=1)
- _remove_interactive_temp(runtime_dir)
def run_testcase(
bin_path,
@@ -2683,6 +2278,11 @@ def _main_unlocked():
transcript_truncated=judge_details.get("transcript_truncated", False),
output_bytes=judge_details.get("output_bytes"),
termination_reason=judge_details.get("termination_reason"),
+ verdict=judge_details.get("verdict"),
+ execution_status=judge_details.get("execution_status"),
+ failure_kind=judge_details.get("failure_kind"),
+ actor=judge_details.get("actor"),
+ cleanup=judge_details.get("cleanup"),
cached=cached,
)
case_results.append({
@@ -2695,6 +2295,11 @@ def _main_unlocked():
"memory_enforced": memory_enforced,
"output_bytes": judge_details.get("output_bytes"),
"termination_reason": judge_details.get("termination_reason"),
+ "verdict": judge_details.get("verdict"),
+ "execution_status": judge_details.get("execution_status"),
+ "failure_kind": judge_details.get("failure_kind"),
+ "actor": judge_details.get("actor"),
+ "cleanup": judge_details.get("cleanup"),
"cached": cached,
})
if requires_sample_answer:
diff --git a/tests/test_calibration.py b/tests/test_calibration.py
index 5015c30..93d1df8 100644
--- a/tests/test_calibration.py
+++ b/tests/test_calibration.py
@@ -21,6 +21,7 @@
from probhub.cli import _ensure_local_gitignore
from probhub.io import write_yaml
from probhub.linting import lint_problem, problem_status
+from probhub.special_judges import _interactive_result
ROOT = Path(__file__).resolve().parents[1]
@@ -512,20 +513,27 @@ def test_interactive_result_records_solution_output_bytes(self):
with tempfile.TemporaryDirectory() as temp:
stderr = Path(temp) / "solution.stderr"
stderr.write_bytes(b"12345")
- transcript = {"entries": [], "truncated": False}
- result = LOCAL_JUDGE._interactive_result(
- "OLE",
- 0.1,
- 1,
- True,
- "output limit exceeded",
- transcript,
- traffic={"solution_to_interactor": 7},
- solution_stderr=str(stderr),
- termination_reason="output_limit",
+ transcript = {"entries": [], "bytes": 0, "truncated": False}
+ result = _interactive_result(
+ {
+ "status": "OLE",
+ "verdict": "OLE",
+ "execution_status": "output_limit",
+ "failure_kind": "resource_limit",
+ "actor": "contestant",
+ "termination_reason": "output_limit",
+ "message": "output limit exceeded",
+ },
+ elapsed=0.1,
+ memory=1,
+ memory_enforced=True,
+ transcript=transcript,
+ traffic_evidence={"solution_to_interactor": 7, "solution_stderr": 5},
+ cleanup={"ok": True},
+ exit_codes={},
)
- self.assertEqual(result[-1]["output_bytes"], 12)
- self.assertEqual(result[-1]["termination_reason"], "output_limit")
+ self.assertEqual(result["output_bytes"], 12)
+ self.assertEqual(result["termination_reason"], "output_limit")
def test_tle_probe_distinguishes_exact_and_censored_measurements(self):
with tempfile.TemporaryDirectory() as temp:
diff --git a/tests/test_core.py b/tests/test_core.py
index 69e3542..da79ed2 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -502,6 +502,19 @@ def test_lint_validates_custom_checker_and_interactor_configuration(self):
(problem / "code/checker.cpp").write_text("int main(){}\n", encoding="utf-8")
self.assertTrue(lint_workspace(root, workspace)["ok"])
+ config["judge"] = {
+ "type": "checker",
+ "validator": "code/validator.cpp",
+ "checker": "../outside-checker.cpp",
+ }
+ write_yaml(config_path, config)
+ result = lint_workspace(root, workspace)
+ self.assertFalse(result["ok"])
+ self.assertIn(
+ "judge.checker must stay inside the problem directory: ../outside-checker.cpp",
+ result["problems"][0]["errors"],
+ )
+
config["judge"] = {
"type": "interactive",
"validator": "code/validator.cpp",
@@ -515,6 +528,58 @@ def test_lint_validates_custom_checker_and_interactor_configuration(self):
(problem / "code/interactor.cpp").write_text("int main(){}\n", encoding="utf-8")
self.assertTrue(lint_workspace(root, workspace)["ok"])
+ config["judge"]["interactor"] = r"..\outside-interactor.cpp"
+ write_yaml(config_path, config)
+ result = lint_workspace(root, workspace)
+ self.assertFalse(result["ok"])
+ self.assertIn(
+ r"judge.interactor must stay inside the problem directory: ..\outside-interactor.cpp",
+ result["problems"][0]["errors"],
+ )
+
+ checker_link = problem / "code/checker-link.cpp"
+ try:
+ checker_link.symlink_to(problem / "code/checker.cpp")
+ except (NotImplementedError, OSError):
+ checker_link = None
+ if checker_link is not None:
+ config["judge"] = {
+ "type": "checker",
+ "validator": "code/validator.cpp",
+ "checker": "code/checker-link.cpp",
+ }
+ write_yaml(config_path, config)
+ result = lint_workspace(root, workspace)
+ self.assertFalse(result["ok"])
+ self.assertIn(
+ "judge.checker must be a regular non-symlink file: code/checker-link.cpp",
+ result["problems"][0]["errors"],
+ )
+
+ interactor_link = problem / "code/interactor-link.cpp"
+ try:
+ interactor_link.symlink_to(problem / "code/interactor.cpp")
+ except (NotImplementedError, OSError):
+ interactor_link = None
+ if interactor_link is not None:
+ config["judge"] = {
+ "type": "interactive",
+ "validator": "code/validator.cpp",
+ "interactor": "code/interactor-link.cpp",
+ }
+ write_yaml(config_path, config)
+ result = lint_workspace(root, workspace)
+ self.assertFalse(result["ok"])
+ self.assertIn(
+ "judge.interactor must be a regular non-symlink file: code/interactor-link.cpp",
+ result["problems"][0]["errors"],
+ )
+
+ config["judge"] = {
+ "type": "interactive",
+ "validator": "code/validator.cpp",
+ "interactor": "code/interactor.cpp",
+ }
config["judge"]["interactive"] = {"idle_limit": 0, "transcript_limit": -1}
write_yaml(config_path, config)
result = lint_workspace(root, workspace)
diff --git a/tests/test_datagen.py b/tests/test_datagen.py
index 17b234a..3c7bf72 100644
--- a/tests/test_datagen.py
+++ b/tests/test_datagen.py
@@ -500,14 +500,12 @@ def run(command, _input, *_args, **_kwargs):
return {"status": "AC", "stdout": stdout, "stderr": b"", "message": ""}
outcomes = (
- ({"status": "WA", "match": False, "message": "rejected", "stderr": b""}, "WA", None),
- ({"status": "FAIL", "match": False, "message": "checker failed", "stderr": b""}, "FAIL", None),
+ ({"verdict": "WA", "execution_status": "completed", "message": "rejected"}, "WA", None),
+ ({"verdict": None, "execution_status": "completed", "message": "checker failed"}, "FAIL", None),
({
- "status": "FAIL",
- "execution_status": "TLE",
- "match": False,
+ "verdict": None,
+ "execution_status": "time_limit",
"message": "checker timed out",
- "stderr": b"",
}, "FAIL", "TLE"),
)
for comparison, expected_status, execution_status in outcomes:
@@ -516,7 +514,7 @@ def run(command, _input, *_args, **_kwargs):
with (
mock.patch("probhub.datagen._prepare_program", side_effect=prepare),
mock.patch("probhub.datagen._run", side_effect=run),
- mock.patch("probhub.datagen._compare_custom", return_value=comparison),
+ mock.patch("probhub.datagen.run_checker_to_files", return_value=comparison),
):
result = generate_problem_data(problem, config, apply_changes=True)
self.assertFalse(result["ok"])
@@ -529,18 +527,25 @@ def run(command, _input, *_args, **_kwargs):
with tempfile.TemporaryDirectory() as temp:
problem, config = fixture(Path(temp))
- accepted = {"status": "AC", "match": True, "message": "", "stderr": b""}
+ accepted = {"verdict": "AC", "execution_status": "completed", "message": ""}
+
+ def accept_checker(*args, **_kwargs):
+ self.assertEqual(Path(args[3]).read_bytes(), b"2\n")
+ return accepted
+
with (
mock.patch("probhub.datagen._prepare_program", side_effect=prepare),
mock.patch("probhub.datagen._run", side_effect=run),
- mock.patch("probhub.datagen._compare_custom", return_value=accepted) as checker,
+ mock.patch(
+ "probhub.datagen.run_checker_to_files", side_effect=accept_checker
+ ) as checker,
):
result = generate_problem_data(problem, config, apply_changes=True)
self.assertTrue(result["ok"])
self.assertTrue(result["applied"])
self.assertEqual((problem / "data/secret/gen01.ans").read_bytes(), b"2\n")
self.assertTrue((problem / GEN_MANIFEST_PATH).is_file())
- self.assertEqual(checker.call_args.args[3], b"2\n")
+ self.assertEqual(checker.call_args.args[2], checker.call_args.args[3])
def test_lint_reports_recipe_errors_and_coverage_warnings(self):
with tempfile.TemporaryDirectory() as temp:
diff --git a/tests/test_local_judge_layout.py b/tests/test_local_judge_layout.py
index e55766b..cdc5685 100644
--- a/tests/test_local_judge_layout.py
+++ b/tests/test_local_judge_layout.py
@@ -88,6 +88,30 @@ def test_schema_paths_resolve_inside_code_directory(self):
cases = MODULE.collect_testcases(problem, config)
self.assertEqual([case["case"] for case in cases], ["sample/1"])
+ def test_schema_execution_paths_use_the_shared_problem_fence(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ problem = root / "A"
+ code = problem / "code"
+ code.mkdir(parents=True)
+ outside = root / "outside.cpp"
+ outside.write_text("int main(){}\n", encoding="utf-8")
+ (code / "checker.cpp").write_text("int main(){}\n", encoding="utf-8")
+
+ self.assertIsNone(MODULE.resolve_problem_path(problem, "../outside.cpp"))
+ self.assertIsNone(MODULE.resolve_problem_path(problem, str(outside)))
+ self.assertEqual(
+ Path(MODULE.resolve_problem_path(problem, "code/checker.cpp")),
+ (code / "checker.cpp").resolve(),
+ )
+
+ link = code / "linked-checker.cpp"
+ try:
+ link.symlink_to(outside)
+ except OSError:
+ return
+ self.assertIsNone(MODULE.resolve_problem_path(problem, "code/linked-checker.cpp"))
+
def test_legacy_workspace_prefers_code_directory_when_present(self):
with tempfile.TemporaryDirectory() as temp:
diff --git a/tests/test_package_tools.py b/tests/test_package_tools.py
index ce9ffe2..e219ccf 100644
--- a/tests/test_package_tools.py
+++ b/tests/test_package_tools.py
@@ -587,6 +587,43 @@ def create_checker_problem(self, root):
"judge": {"type": "checker", "checker": "code/checker.cpp"},
}
+ def test_output_validator_rejects_unsafe_checker_sources(self):
+ with tempfile.TemporaryDirectory() as temp:
+ problem = Path(temp) / "A"
+ config = self.create_checker_problem(problem)
+ for source in (None, "", " "):
+ with self.subTest(source=source):
+ config["judge"]["checker"] = source
+ with self.assertRaises(ProbHubError) as raised:
+ validate_output_validator_source(problem, config)
+ self.assertEqual(raised.exception.code, "unsafe_package_source")
+ self.assertEqual(str(raised.exception), "judge.checker is required")
+
+ for source in ("../outside.cpp", r"..\outside.cpp", r"C:relative.cpp"):
+ with self.subTest(source=source):
+ config["judge"]["checker"] = source
+ with self.assertRaises(ProbHubError) as raised:
+ validate_output_validator_source(problem, config)
+ self.assertEqual(raised.exception.code, "unsafe_package_source")
+ self.assertIn(
+ "judge.checker must stay inside the problem directory",
+ str(raised.exception),
+ )
+
+ checker_link = problem / "code/checker-link.cpp"
+ try:
+ checker_link.symlink_to(problem / "code/checker.cpp")
+ except (NotImplementedError, OSError):
+ return
+ config["judge"]["checker"] = "code/checker-link.cpp"
+ with self.assertRaises(ProbHubError) as raised:
+ validate_output_validator_source(problem, config)
+ self.assertEqual(raised.exception.code, "unsafe_package_source")
+ self.assertEqual(
+ str(raised.exception),
+ "judge.checker must be a regular file: code/checker-link.cpp",
+ )
+
def test_output_validator_compile_uses_managed_limits(self):
with tempfile.TemporaryDirectory() as temp:
problem = Path(temp) / "A"
diff --git a/tests/test_problem_paths.py b/tests/test_problem_paths.py
new file mode 100644
index 0000000..b3e6d48
--- /dev/null
+++ b/tests/test_problem_paths.py
@@ -0,0 +1,121 @@
+import os
+import stat
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest import mock
+
+from probhub.problem_paths import ProblemPathError, resolve_problem_regular_file
+
+
+class ProblemPathTests(unittest.TestCase):
+ def assert_reason(self, problem_dir, value, reason):
+ with self.assertRaises(ProblemPathError) as raised:
+ resolve_problem_regular_file(problem_dir, value)
+ self.assertEqual(raised.exception.reason, reason)
+
+ def test_resolves_nested_file_with_either_separator(self):
+ with tempfile.TemporaryDirectory() as temp:
+ problem = Path(temp) / "problem"
+ source = problem / "code" / "checker.cpp"
+ source.parent.mkdir(parents=True)
+ source.write_text("int main() {}\n", encoding="utf-8")
+
+ expected = source.resolve()
+ self.assertEqual(
+ resolve_problem_regular_file(problem, "code/checker.cpp"),
+ expected,
+ )
+ self.assertEqual(
+ resolve_problem_regular_file(problem, r"code\checker.cpp"),
+ expected,
+ )
+
+ def test_rejects_invalid_values(self):
+ with tempfile.TemporaryDirectory() as temp:
+ problem = Path(temp)
+ for value in (None, "", " ", Path("code/checker.cpp")):
+ with self.subTest(value=value):
+ self.assert_reason(problem, value, "invalid")
+
+ def test_rejects_host_and_cross_platform_absolute_or_driven_paths(self):
+ with tempfile.TemporaryDirectory() as temp:
+ problem = Path(temp) / "problem"
+ problem.mkdir()
+ outside = str((problem.parent / "outside.cpp").resolve())
+ for value in (
+ outside,
+ "/absolute/checker.cpp",
+ r"C:\absolute\checker.cpp",
+ r"\\server\share\checker.cpp",
+ r"C:relative.cpp",
+ ):
+ with self.subTest(value=value):
+ self.assert_reason(problem, value, "outside")
+
+ def test_rejects_parent_components_with_either_separator(self):
+ with tempfile.TemporaryDirectory() as temp:
+ problem = Path(temp) / "problem"
+ problem.mkdir()
+ for value in ("../outside.cpp", r"..\outside.cpp", "code/../checker.cpp"):
+ with self.subTest(value=value):
+ self.assert_reason(problem, value, "outside")
+
+ def test_distinguishes_missing_and_non_regular_targets(self):
+ with tempfile.TemporaryDirectory() as temp:
+ problem = Path(temp) / "problem"
+ code = problem / "code"
+ code.mkdir(parents=True)
+
+ self.assert_reason(problem, "code/missing.cpp", "missing")
+ self.assert_reason(problem, "code", "non_regular")
+ blocker = problem / "blocker"
+ blocker.write_text("not a directory\n", encoding="utf-8")
+ self.assert_reason(problem, "blocker/checker.cpp", "non_regular")
+
+ def test_rejects_symlinks_to_inside_and_outside(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ problem = root / "problem"
+ code = problem / "code"
+ code.mkdir(parents=True)
+ inside = code / "checker.cpp"
+ inside.write_text("int main() {}\n", encoding="utf-8")
+ outside = root / "outside.cpp"
+ outside.write_text("int main() {}\n", encoding="utf-8")
+ inside_link = code / "inside-link.cpp"
+ outside_link = code / "outside-link.cpp"
+ try:
+ inside_link.symlink_to(inside)
+ outside_link.symlink_to(outside)
+ except (NotImplementedError, OSError) as exc:
+ self.skipTest(f"symlink creation is unavailable: {exc}")
+
+ self.assert_reason(problem, "code/inside-link.cpp", "link")
+ self.assert_reason(problem, "code/outside-link.cpp", "link")
+
+ def test_rejects_mocked_reparse_point_component(self):
+ with tempfile.TemporaryDirectory() as temp:
+ problem = Path(temp) / "problem"
+ code = problem / "code"
+ code.mkdir(parents=True)
+ (code / "checker.cpp").write_text("int main() {}\n", encoding="utf-8")
+ real_lstat = os.lstat
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+
+ def mocked_lstat(path):
+ info = real_lstat(path)
+ if Path(path) == code:
+ return SimpleNamespace(
+ st_mode=info.st_mode,
+ st_file_attributes=reparse_flag,
+ )
+ return info
+
+ with mock.patch("probhub.problem_paths.os.lstat", side_effect=mocked_lstat):
+ self.assert_reason(problem, "code/checker.cpp", "link")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_special_judges.py b/tests/test_special_judges.py
index d33c68f..51f80cf 100644
--- a/tests/test_special_judges.py
+++ b/tests/test_special_judges.py
@@ -16,6 +16,7 @@
import yaml
+import probhub.special_judges as SPECIAL_JUDGES
from probhub.process_control import (
process_alive,
spawn_managed,
@@ -65,7 +66,7 @@ def test_custom_checker_output_control_failure_has_precise_reason(self):
"run_program_to_file",
return_value=("AC", 0.01, 1, True, "", {"output_bytes": 0}),
), mock.patch.object(
- JUDGE_MODULE,
+ SPECIAL_JUDGES,
"run_managed_to_files",
side_effect=JUDGE_MODULE.OutputBudgetError("feedback is not regular"),
):
@@ -81,6 +82,45 @@ def test_custom_checker_output_control_failure_has_precise_reason(self):
self.assertNotIn("failed to start", message)
self.assertEqual(details["checker_termination_reason"], "output_control_error")
+ def test_custom_contestant_failure_fields_are_normalized_before_checker(self):
+ root = Path(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, root, True)
+ cases = (
+ (
+ ("MLE", 0.01, 255, True, "", {
+ "output_bytes": 0,
+ "termination_reason": "inferred_memory_limit",
+ }),
+ "memory_limit",
+ "resource_limit",
+ "inferred_memory_limit",
+ ),
+ (
+ ("RE", 0.0, None, False, "missing", {"output_bytes": 0}),
+ "start_error",
+ "startup_failure",
+ "start_error",
+ ),
+ )
+ for program_result, execution_status, failure_kind, termination_reason in cases:
+ with self.subTest(execution_status=execution_status), mock.patch.object(
+ JUDGE_MODULE, "run_program_to_file", return_value=program_result
+ ), mock.patch.object(
+ JUDGE_MODULE, "run_checker_to_files"
+ ) as checker:
+ result = JUDGE_MODULE.run_custom_testcase(
+ str(root / "solution"),
+ str(root / "checker"),
+ str(root / "case.in"),
+ str(root / "case.ans"),
+ )
+ details = result[5]
+ self.assertEqual(details["execution_status"], execution_status)
+ self.assertEqual(details["failure_kind"], failure_kind)
+ self.assertEqual(details["termination_reason"], termination_reason)
+ self.assertEqual(details["actor"], "contestant")
+ checker.assert_not_called()
+
def test_interactive_final_classification_follows_tree_termination(self):
class FakeProcess:
def __init__(self):
@@ -118,7 +158,7 @@ def fake_spawn(*_args, **kwargs):
return managed
with tempfile.TemporaryDirectory() as temp, mock.patch.object(
- JUDGE_MODULE, "spawn_managed", side_effect=fake_spawn
+ SPECIAL_JUDGES, "spawn_managed", side_effect=fake_spawn
):
root = Path(temp)
result = JUDGE_MODULE.run_interactive_testcase(
@@ -145,7 +185,7 @@ def test_interactive_transcript_decodes_utf8_across_chunks(self):
traffic = {"limit": 1024, "_lock": lock}
destination = self._NonClosingBytesIO()
- JUDGE_MODULE._pump_interactive_stream(
+ SPECIAL_JUDGES._pump_interactive_stream(
self._ChunkedSource([b"\xe4\xbd", b"\xa0\n"]),
destination,
"interactor_to_solution",
@@ -190,7 +230,7 @@ def __getitem__(self, key):
destinations = [self._NonClosingBytesIO(), self._NonClosingBytesIO()]
threads = [
threading.Thread(
- target=JUDGE_MODULE._pump_interactive_stream,
+ target=SPECIAL_JUDGES._pump_interactive_stream,
args=(
self._ChunkedSource([b"abcdefgh"]),
destinations[index],
@@ -234,7 +274,7 @@ def spawn(command, **kwargs):
raise OSError("interactor containment failed")
with tempfile.TemporaryDirectory() as temp, mock.patch.object(
- JUDGE_MODULE, "spawn_managed", side_effect=spawn
+ SPECIAL_JUDGES, "spawn_managed", side_effect=spawn
):
root = Path(temp)
result = JUDGE_MODULE.run_interactive_testcase(
@@ -262,11 +302,14 @@ def test_interactive_output_status_combines_protocol_and_stderr(self):
"solution_to_interactor": 700,
"interactor_to_solution": 0,
}
- status, message = JUDGE_MODULE._interactive_output_status(
+ evidence = SPECIAL_JUDGES._interactive_evidence(
traffic, solution_stderr, interactor_stderr
)
- self.assertEqual(status, "OLE")
- self.assertIn("output limit", message)
+ outcome = SPECIAL_JUDGES._interactive_output_classification(
+ evidence, SPECIAL_JUDGES.MAX_CHECKER_DIAGNOSTIC_BYTES
+ )
+ self.assertEqual(outcome["status"], "OLE")
+ self.assertIn("output limit", outcome["message"])
def test_interactive_feedback_uses_bounded_diagnostic_budget(self):
with tempfile.TemporaryDirectory() as temp:
@@ -283,38 +326,347 @@ def test_interactive_feedback_uses_bounded_diagnostic_budget(self):
"solution_to_interactor": 0,
"interactor_to_solution": 0,
}
- status, message = JUDGE_MODULE._interactive_output_status(
+ evidence = SPECIAL_JUDGES._interactive_evidence(
traffic,
solution_stderr,
interactor_stderr,
feedback,
- diagnostic_limit_bytes=1000,
)
- self.assertEqual(status, "FAIL")
- self.assertIn("diagnostic output limit", message)
+ outcome = SPECIAL_JUDGES._interactive_output_classification(evidence, 1000)
+ self.assertEqual(outcome["status"], "FAIL")
+ self.assertIn("diagnostic output limit", outcome["message"])
(feedback / "judgemessage.txt").unlink()
(feedback / "judgemessage.txt").mkdir()
with self.assertRaisesRegex(
JUDGE_MODULE.OutputBudgetError, "not a regular file"
):
- JUDGE_MODULE._interactive_output_status(
+ SPECIAL_JUDGES._interactive_evidence(
traffic,
solution_stderr,
interactor_stderr,
feedback,
- diagnostic_limit_bytes=1000,
)
+ def test_interactive_non_regular_feedback_is_structured_control_failure(self):
+ class FakeProcess:
+ def __init__(self):
+ self.stdin = io.BytesIO()
+ self.stdout = io.BytesIO()
+ self.returncode = 0
+
+ def poll(self):
+ return self.returncode
+
+ class FakeManaged:
+ memory_enforced = True
+ process_limit_enforced = True
+ peak_memory_mb = 1
+
+ def __init__(self):
+ self.proc = FakeProcess()
+
+ def sample(self):
+ return 1, 1
+
+ def terminate(self):
+ pass
+
+ spawned = []
+
+ def fake_spawn(command, **_kwargs):
+ managed = FakeManaged()
+ if spawned:
+ Path(command[-1], "judgemessage.txt").mkdir()
+ spawned.append(managed)
+ return managed
+
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(
+ SPECIAL_JUDGES, "spawn_managed", side_effect=fake_spawn
+ ):
+ root = Path(temp)
+ result = SPECIAL_JUDGES.execute_interactive_session(
+ [str(root / "solution")],
+ [str(root / "interactor")],
+ root / "case.in",
+ root / "case.ans",
+ work_dir=root,
+ )
+ self.assertEqual(result["status"], "FAIL")
+ self.assertEqual(result["execution_status"], "output_control_error")
+ self.assertEqual(result["failure_kind"], "control_failure")
+ self.assertEqual(result["actor"], "supervisor")
+ self.assertTrue(result["cleanup"]["ok"])
+
def test_interactive_temp_cleanup_retries_permission_error(self):
with mock.patch.object(
- JUDGE_MODULE.shutil,
+ SPECIAL_JUDGES.shutil,
"rmtree",
side_effect=[PermissionError("busy"), None],
- ) as rmtree, mock.patch.object(JUDGE_MODULE.time, "sleep"):
- JUDGE_MODULE._remove_interactive_temp("temporary")
+ ) as rmtree, mock.patch.object(SPECIAL_JUDGES.time, "sleep"):
+ removed, attempts, error = SPECIAL_JUDGES._remove_runtime_directory("temporary")
+ self.assertTrue(removed)
+ self.assertEqual(attempts, 2)
+ self.assertIsNone(error)
self.assertEqual(rmtree.call_count, 2)
+ def test_checker_cleanup_failure_is_structured_infrastructure_failure(self):
+ execution = {
+ "reason": "completed",
+ "returncode": 0,
+ "time": 0.01,
+ "memory": 1,
+ "memory_enforced": True,
+ "process_limit_enforced": True,
+ "output_bytes": 0,
+ "retained_output_bytes": 0,
+ "stdout_retained_bytes": 0,
+ "stderr_retained_bytes": 0,
+ "output_truncated": False,
+ }
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ for name in ("case.in", "case.ans", "contestant.out"):
+ (root / name).write_bytes(b"")
+ with mock.patch.object(
+ SPECIAL_JUDGES, "run_managed_to_files", return_value=execution
+ ), mock.patch.object(
+ SPECIAL_JUDGES.shutil,
+ "rmtree",
+ side_effect=PermissionError("runtime busy"),
+ ) as rmtree, mock.patch.object(SPECIAL_JUDGES.time, "sleep"):
+ result = SPECIAL_JUDGES.run_checker_to_files(
+ ["checker"],
+ root / "case.in",
+ root / "case.ans",
+ root / "contestant.out",
+ timeout=1,
+ cwd=root,
+ )
+ self.assertIsNone(result["verdict"])
+ self.assertEqual(result["actor"], "supervisor")
+ self.assertEqual(result["execution_status"], "cleanup_error")
+ self.assertEqual(result["failure_kind"], "cleanup_failure")
+ self.assertFalse(result["cleanup"]["ok"])
+ self.assertEqual(result["cleanup"]["runtime_remove_attempts"], 20)
+ self.assertEqual(rmtree.call_count, 20)
+
+ def test_checker_cancel_cleanup_failure_overrides_cancellation(self):
+ execution = {
+ "reason": "cancelled",
+ "message": "execution cancelled",
+ "returncode": None,
+ }
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ for name in ("case.in", "case.ans", "contestant.out"):
+ (root / name).write_bytes(b"")
+ with mock.patch.object(
+ SPECIAL_JUDGES, "run_managed_to_files", return_value=execution
+ ), mock.patch.object(
+ SPECIAL_JUDGES.shutil,
+ "rmtree",
+ side_effect=PermissionError("runtime busy"),
+ ), mock.patch.object(SPECIAL_JUDGES.time, "sleep"):
+ result = SPECIAL_JUDGES.run_checker_to_files(
+ ["checker"],
+ root / "case.in",
+ root / "case.ans",
+ root / "contestant.out",
+ timeout=1,
+ cwd=root,
+ )
+ self.assertEqual(result["execution_status"], "cleanup_error")
+ self.assertEqual(result["failure_kind"], "cleanup_failure")
+ self.assertEqual(
+ result["pre_cleanup_result"]["execution_status"], "cancelled"
+ )
+
+ def test_checker_cancel_with_successful_cleanup_propagates(self):
+ execution = {
+ "reason": "cancelled",
+ "message": "execution cancelled",
+ "returncode": None,
+ }
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ for name in ("case.in", "case.ans", "contestant.out"):
+ (root / name).write_bytes(b"")
+ with mock.patch.object(
+ SPECIAL_JUDGES, "run_managed_to_files", return_value=execution
+ ):
+ with self.assertRaisesRegex(
+ JUDGE_MODULE.ProcessCancelled, "execution cancelled"
+ ):
+ SPECIAL_JUDGES.run_checker_to_files(
+ ["checker"],
+ root / "case.in",
+ root / "case.ans",
+ root / "contestant.out",
+ timeout=1,
+ cwd=root,
+ )
+
+ def test_interactive_cleanup_failure_does_not_skip_other_cleanup(self):
+ class FakeProcess:
+ def __init__(self):
+ self.stdin = io.BytesIO()
+ self.stdout = io.BytesIO()
+ self.returncode = 0
+
+ def poll(self):
+ return self.returncode
+
+ class FakeManaged:
+ memory_enforced = True
+ process_limit_enforced = True
+ peak_memory_mb = 1
+
+ def __init__(self, fail=False):
+ self.proc = FakeProcess()
+ self.fail = fail
+ self.terminate_calls = 0
+
+ def sample(self):
+ return 1, 1
+
+ def terminate(self):
+ self.terminate_calls += 1
+ if self.fail:
+ raise OSError("tree cleanup failed")
+
+ managed = [FakeManaged(fail=True), FakeManaged()]
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(
+ SPECIAL_JUDGES, "spawn_managed", side_effect=managed
+ ):
+ root = Path(temp)
+ result = SPECIAL_JUDGES.execute_interactive_session(
+ [str(root / "solution")],
+ [str(root / "interactor")],
+ root / "case.in",
+ root / "case.ans",
+ work_dir=root,
+ time_limit=1,
+ )
+ self.assertEqual(result["status"], "FAIL")
+ self.assertEqual(result["actor"], "supervisor")
+ self.assertEqual(result["execution_status"], "cleanup_error")
+ self.assertEqual(result["failure_kind"], "cleanup_failure")
+ self.assertFalse(result["cleanup"]["ok"])
+ self.assertEqual(managed[0].terminate_calls, 1)
+ self.assertEqual(managed[1].terminate_calls, 1)
+ self.assertTrue(result["cleanup"]["runtime_removed"])
+
+ def test_interactive_cancel_cleanup_failure_overrides_cancellation(self):
+ class FakeProcess:
+ def __init__(self):
+ self.stdin = io.BytesIO()
+ self.stdout = io.BytesIO()
+ self.returncode = None
+
+ def poll(self):
+ return self.returncode
+
+ class FakeManaged:
+ memory_enforced = True
+ process_limit_enforced = True
+ peak_memory_mb = 1
+
+ def __init__(self):
+ self.proc = FakeProcess()
+
+ def sample(self):
+ return 1, 1
+
+ def terminate(self):
+ self.proc.returncode = -1
+
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(
+ SPECIAL_JUDGES, "spawn_managed", side_effect=[FakeManaged(), FakeManaged()]
+ ), mock.patch.object(
+ SPECIAL_JUDGES, "cancellation_requested", return_value=True
+ ), mock.patch.object(
+ SPECIAL_JUDGES.shutil,
+ "rmtree",
+ side_effect=PermissionError("runtime busy"),
+ ), mock.patch.object(SPECIAL_JUDGES.time, "sleep"):
+ root = Path(temp)
+ result = SPECIAL_JUDGES.execute_interactive_session(
+ [str(root / "solution")],
+ [str(root / "interactor")],
+ root / "case.in",
+ root / "case.ans",
+ work_dir=root,
+ )
+ self.assertEqual(result["execution_status"], "cleanup_error")
+ self.assertEqual(result["failure_kind"], "cleanup_failure")
+ self.assertEqual(
+ result["pre_cleanup_result"]["execution_status"], "cancelled"
+ )
+
+ def test_interactive_preparation_failure_is_structured_start_error(self):
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(
+ SPECIAL_JUDGES.Path,
+ "mkdir",
+ side_effect=OSError("feedback directory unavailable"),
+ ):
+ root = Path(temp)
+ result = SPECIAL_JUDGES.execute_interactive_session(
+ [str(root / "solution")],
+ [str(root / "interactor")],
+ root / "case.in",
+ root / "case.ans",
+ work_dir=root,
+ )
+ self.assertEqual(result["status"], "FAIL")
+ self.assertEqual(result["execution_status"], "start_error")
+ self.assertEqual(result["failure_kind"], "startup_failure")
+ self.assertEqual(result["actor"], "supervisor")
+ self.assertTrue(result["cleanup"]["ok"])
+
+ def test_interactor_memory_failure_reports_interactor_memory(self):
+ class FakeProcess:
+ def __init__(self, returncode):
+ self.stdin = io.BytesIO()
+ self.stdout = io.BytesIO()
+ self.returncode = returncode
+
+ def poll(self):
+ return self.returncode
+
+ class FakeManaged:
+ memory_enforced = True
+ process_limit_enforced = True
+
+ def __init__(self, memory, returncode):
+ self.peak_memory_mb = memory
+ self.proc = FakeProcess(returncode)
+
+ def sample(self):
+ return 1, self.peak_memory_mb
+
+ def terminate(self):
+ pass
+
+ managed = [FakeManaged(5, 0), FakeManaged(100, 1)]
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(
+ SPECIAL_JUDGES, "spawn_managed", side_effect=managed
+ ):
+ root = Path(temp)
+ result = SPECIAL_JUDGES.execute_interactive_session(
+ [str(root / "solution")],
+ [str(root / "interactor")],
+ root / "case.in",
+ root / "case.ans",
+ work_dir=root,
+ memory_limit_mb=50,
+ )
+ self.assertEqual(result["actor"], "interactor")
+ self.assertEqual(result["execution_status"], "memory_limit")
+ self.assertEqual(result["memory"], 100)
+ self.assertEqual(result["resources"]["contestant"]["memory"], 5)
+ self.assertEqual(result["resources"]["interactor"]["memory"], 100)
+
def test_interactive_deadlines_exclude_process_setup(self):
class FakeProcess:
def __init__(self):
@@ -343,7 +695,7 @@ def slow_spawn(*_args, **_kwargs):
return FakeManaged()
with tempfile.TemporaryDirectory() as temp, mock.patch.object(
- JUDGE_MODULE, "spawn_managed", side_effect=slow_spawn
+ SPECIAL_JUDGES, "spawn_managed", side_effect=slow_spawn
):
root = Path(temp)
result = JUDGE_MODULE.run_interactive_testcase(
@@ -395,11 +747,14 @@ def write_problem(self, root, judge, accepted_source, wrong_source, extra_source
)
return problem
- def run_judge(self, problem):
+ def run_judge(self, problem, *, no_cache=True):
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
+ command = [sys.executable, str(LOCAL_JUDGE), str(problem), "--jsonl"]
+ if no_cache:
+ command.append("--no-cache")
result = subprocess.run(
- [sys.executable, str(LOCAL_JUDGE), str(problem), "--jsonl", "--no-cache"],
+ command,
cwd=ROOT,
capture_output=True,
text=True,
@@ -481,11 +836,38 @@ def test_custom_checker_accepts_non_unique_output_and_kills_wrong_solution(self)
self.assertTrue(std_cases and all(event["status"] == "AC" for event in std_cases))
self.assertTrue(wrong_cases and all(event["status"] == "WA" for event in wrong_cases))
self.assertTrue(all(event["judge_type"] == "custom" for event in std_cases + wrong_cases))
+ self.assertTrue(all(event["actor"] == "session" for event in std_cases))
+ self.assertTrue(all(event["execution_status"] == "completed" for event in std_cases))
+ self.assertTrue(all(event["failure_kind"] is None for event in std_cases))
+ self.assertTrue(all(event["actor"] == "contestant" for event in wrong_cases))
+ self.assertTrue(all(event["failure_kind"] == "wrong_answer" for event in wrong_cases))
+ self.assertTrue(all(event["cleanup"]["ok"] for event in std_cases + wrong_cases))
sample_check = next(
event for event in events if event.get("type") == "sample_check"
)
self.assertTrue(sample_check["matches"], sample_check)
+ cached_result, cached_events = self.run_judge(problem, no_cache=False)
+ self.assertEqual(cached_result.returncode, 0, cached_result.stderr + cached_result.stdout)
+ cached_cases = [
+ event for event in cached_events if event.get("type") == "case"
+ ]
+ self.assertTrue(cached_cases and all(event["cached"] for event in cached_cases))
+ fields = (
+ "status", "judge_type", "verdict", "actor", "execution_status",
+ "failure_kind", "termination_reason", "cleanup",
+ )
+ first_by_key = {
+ (event["kind"], event["case"]): event
+ for event in std_cases + wrong_cases
+ }
+ for event in cached_cases:
+ original = first_by_key[(event["kind"], event["case"])]
+ self.assertEqual(
+ {field: event.get(field) for field in fields},
+ {field: original.get(field) for field in fields},
+ )
+
def test_custom_checker_feedback_flood_is_infrastructure_fail(self):
with tempfile.TemporaryDirectory() as temp:
problem = self.write_problem(
@@ -515,6 +897,10 @@ def test_custom_checker_feedback_flood_is_infrastructure_fail(self):
)
self.assertEqual(case["status"], "FAIL", case)
self.assertIn("output limit", case["message"])
+ self.assertEqual(case["actor"], "checker", case)
+ self.assertEqual(case["execution_status"], "output_limit", case)
+ self.assertEqual(case["failure_kind"], "resource_limit", case)
+ self.assertEqual(case["termination_reason"], "output_limit", case)
def test_custom_checker_ac_cannot_hide_sample_answer_mismatch(self):
with tempfile.TemporaryDirectory() as temp:
@@ -598,6 +984,11 @@ def test_interactor_runs_bidirectional_protocol_and_kills_wrong_solution(self):
self.assertTrue(std_cases and all(event["status"] == "AC" for event in std_cases))
self.assertTrue(wrong_cases and all(event["status"] == "WA" for event in wrong_cases))
self.assertTrue(all(event["judge_type"] == "interactive" for event in std_cases + wrong_cases))
+ self.assertTrue(all(event["actor"] == "session" for event in std_cases))
+ self.assertTrue(all(event["failure_kind"] is None for event in std_cases))
+ self.assertTrue(all(event["actor"] == "contestant" for event in wrong_cases))
+ self.assertTrue(all(event["failure_kind"] == "wrong_answer" for event in wrong_cases))
+ self.assertTrue(all(event["cleanup"]["ok"] for event in std_cases + wrong_cases))
transcripts = [event for event in events if event.get("type") == "transcript"]
self.assertTrue(transcripts)
directions = {
@@ -635,6 +1026,10 @@ def test_interactive_fast_exit_is_submission_re_not_infrastructure_fail(self):
if event.get("type") == "case" and event.get("kind") == "std"
)
self.assertEqual(case["status"], "RE", case)
+ self.assertEqual(case["actor"], "contestant", case)
+ self.assertEqual(case["execution_status"], "completed", case)
+ self.assertEqual(case["failure_kind"], "runtime_error", case)
+ self.assertEqual(case["termination_reason"], "completed", case)
def test_interactive_fast_output_flood_is_ole_after_process_exit(self):
with tempfile.TemporaryDirectory() as temp:
@@ -666,6 +1061,10 @@ def test_interactive_fast_output_flood_is_ole_after_process_exit(self):
if event.get("type") == "case" and event.get("kind") == "std"
)
self.assertEqual(case["status"], "OLE", case)
+ self.assertEqual(case["actor"], "contestant", case)
+ self.assertEqual(case["execution_status"], "output_limit", case)
+ self.assertEqual(case["failure_kind"], "resource_limit", case)
+ self.assertEqual(case["termination_reason"], "output_limit", case)
def test_interactor_memory_limit_is_infrastructure_fail(self):
with tempfile.TemporaryDirectory() as temp:
@@ -709,6 +1108,10 @@ def test_interactor_memory_limit_is_infrastructure_fail(self):
if event.get("type") == "case" and event.get("kind") == "std"
)
self.assertEqual(case["status"], "FAIL", case)
+ self.assertEqual(case["actor"], "interactor", case)
+ self.assertEqual(case["execution_status"], "memory_limit", case)
+ self.assertEqual(case["failure_kind"], "resource_limit", case)
+ self.assertEqual(case["termination_reason"], "memory_limit", case)
@unittest.skipUnless(platform.system() == "Linux", "Linux /proc peak-memory regression")
def test_interactive_linux_peak_memory_survives_process_exit(self):
@@ -792,6 +1195,10 @@ def test_interactor_idle_timeout_is_structured_and_preserves_transcript(self):
case = next(event for event in events if event.get("type") == "case")
self.assertEqual(case["status"], "TLE")
self.assertEqual(case["timeout_kind"], "idle")
+ self.assertEqual(case["actor"], "session", case)
+ self.assertEqual(case["execution_status"], "time_limit", case)
+ self.assertEqual(case["failure_kind"], "resource_limit", case)
+ self.assertEqual(case["termination_reason"], "time_limit", case)
transcript = next(event for event in events if event.get("type") == "transcript")
self.assertTrue(transcript["entries"])
self.assertEqual(transcript["entries"][0]["direction"], "interactor_to_solution")
diff --git a/tests/test_stress.py b/tests/test_stress.py
index a2778a7..512f9d5 100644
--- a/tests/test_stress.py
+++ b/tests/test_stress.py
@@ -1,4 +1,5 @@
import json
+import sys
import tempfile
import time
import unittest
@@ -11,6 +12,7 @@
from probhub.io import write_yaml
from probhub.linting import compute_source_hash, lint_workspace
from probhub.process_control import process_alive
+from probhub.special_judges import run_checker_to_files
from probhub.stressing import expand_generator_args, stress_problem
from probhub.workspace import load_problem, load_workspace, problem_entries
@@ -32,6 +34,75 @@ def test_output_budget_enforcement_failure_is_infrastructure(self):
self.assertEqual(result["reason"], "output_control_error")
self.assertIn("cannot enforce", result["message"])
+ def test_checker_core_separates_protocol_verdict_and_execution(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ input_path = root / "case.in"
+ answer_path = root / "case.ans"
+ output_path = root / "contestant.out"
+ checker_path = root / "checker.py"
+ input_path.write_bytes(b"1\n")
+ answer_path.write_bytes(b"2\n")
+ output_path.write_bytes(b"contestant\n")
+ checker_path.write_text(
+ "import pathlib,sys\n"
+ "assert sys.stdin.buffer.read() == b'contestant\\n'\n"
+ "pathlib.Path(sys.argv[-1], 'judgemessage.txt').write_text("
+ "'checker feedback', encoding='utf-8')\n"
+ "raise SystemExit(int(sys.argv[1]))\n",
+ encoding="utf-8",
+ )
+
+ for returncode, verdict, execution_status, failure_kind, actor in (
+ (0, "AC", "completed", None, "session"),
+ (42, "AC", "completed", None, "session"),
+ (1, "WA", "completed", "wrong_answer", "contestant"),
+ (2, "WA", "completed", "wrong_answer", "contestant"),
+ (43, "WA", "completed", "wrong_answer", "contestant"),
+ (7, None, "completed", "judge_failure", "checker"),
+ ):
+ with self.subTest(returncode=returncode):
+ result = run_checker_to_files(
+ [sys.executable, str(checker_path), str(returncode)],
+ input_path,
+ answer_path,
+ output_path,
+ timeout=2,
+ cwd=root,
+ output_limit_bytes=1024 * 1024,
+ )
+ self.assertEqual(result["verdict"], verdict)
+ self.assertEqual(result["execution_status"], execution_status)
+ self.assertEqual(result["failure_kind"], failure_kind)
+ self.assertEqual(result["actor"], actor)
+ self.assertEqual(result["termination_reason"], "completed")
+ self.assertEqual(result["message"], "checker feedback")
+ self.assertEqual(result["feedback_message"], "checker feedback")
+
+ def test_checker_core_output_control_failure_is_structured(self):
+ with tempfile.TemporaryDirectory() as temp, mock.patch(
+ "probhub.special_judges.run_managed_to_files",
+ side_effect=stressing.OutputBudgetError("feedback is not regular"),
+ ):
+ root = Path(temp)
+ for name in ("case.in", "case.ans", "contestant.out"):
+ (root / name).write_bytes(b"")
+ result = run_checker_to_files(
+ ["checker"],
+ root / "case.in",
+ root / "case.ans",
+ root / "contestant.out",
+ timeout=1,
+ cwd=root,
+ output_limit_bytes=1024,
+ )
+ self.assertIsNone(result["verdict"])
+ self.assertEqual(result["execution_status"], "output_control_error")
+ self.assertEqual(result["failure_kind"], "control_failure")
+ self.assertEqual(result["actor"], "supervisor")
+ self.assertEqual(result["termination_reason"], "output_control_error")
+ self.assertIn("not regular", result["message"])
+
def create_workspace(self, root, judge_type="standard"):
write_yaml(root / ".probhub/workspace.yaml", {
"schema_version": 1,
From 79c4e0a8a2dbf274edb6b7a92553f77c0b711f4d Mon Sep 17 00:00:00 2001
From: greenthree <1395214327@qq.com>
Date: Wed, 5 Aug 2026 10:25:39 +0800
Subject: [PATCH 2/3] fix cross-platform special judge tests
---
probhub/problem_paths.py | 9 ++++++---
tests/test_problem_paths.py | 34 ++++++++++++++++++++++++++++++++++
tests/test_special_judges.py | 15 ++++++++++++---
3 files changed, 52 insertions(+), 6 deletions(-)
diff --git a/probhub/problem_paths.py b/probhub/problem_paths.py
index db8afa1..21a2b6a 100644
--- a/probhub/problem_paths.py
+++ b/probhub/problem_paths.py
@@ -33,7 +33,8 @@ def resolve_problem_regular_file(problem_dir, value):
raise ProblemPathError("outside")
try:
- problem_root = Path(problem_dir).resolve(strict=True)
+ access_root = Path(os.path.abspath(os.fspath(problem_dir)))
+ problem_root = access_root.resolve(strict=True)
except (TypeError, ValueError) as exc:
raise ProblemPathError("invalid") from exc
except (OSError, RuntimeError) as exc:
@@ -45,7 +46,7 @@ def resolve_problem_regular_file(problem_dir, value):
if not parts:
raise ProblemPathError("non_regular")
- current = problem_root
+ current = access_root
for index, part in enumerate(parts):
current = current / part
try:
@@ -78,4 +79,6 @@ def resolve_problem_regular_file(problem_dir, value):
resolved.relative_to(problem_root)
except ValueError as exc:
raise ProblemPathError("outside") from exc
- return resolved
+ # Keep the caller's absolute spelling (for example Windows 8.3 or SUBST
+ # aliases) after validating the canonical target against problem_root.
+ return current
diff --git a/tests/test_problem_paths.py b/tests/test_problem_paths.py
index b3e6d48..a4e6599 100644
--- a/tests/test_problem_paths.py
+++ b/tests/test_problem_paths.py
@@ -32,6 +32,40 @@ def test_resolves_nested_file_with_either_separator(self):
expected,
)
+ def test_preserves_access_spelling_after_canonical_containment_check(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ access_problem = root / "access" / "problem"
+ canonical_problem = root / "canonical" / "problem"
+ access_source = access_problem / "code" / "checker.cpp"
+ canonical_source = canonical_problem / "code" / "checker.cpp"
+ for source in (access_source, canonical_source):
+ source.parent.mkdir(parents=True)
+ source.write_text("int main() {}\n", encoding="utf-8")
+
+ path_type = type(access_problem)
+ real_resolve = path_type.resolve
+
+ def canonicalized(path, strict=False):
+ if path == access_problem:
+ return canonical_problem
+ if path == access_source:
+ return canonical_source
+ return real_resolve(path, strict=strict)
+
+ with mock.patch.object(
+ path_type,
+ "resolve",
+ autospec=True,
+ side_effect=canonicalized,
+ ):
+ self.assertEqual(
+ resolve_problem_regular_file(
+ access_problem, "code/checker.cpp"
+ ),
+ access_source,
+ )
+
def test_rejects_invalid_values(self):
with tempfile.TemporaryDirectory() as temp:
problem = Path(temp)
diff --git a/tests/test_special_judges.py b/tests/test_special_judges.py
index 51f80cf..1196a99 100644
--- a/tests/test_special_judges.py
+++ b/tests/test_special_judges.py
@@ -1109,9 +1109,18 @@ def test_interactor_memory_limit_is_infrastructure_fail(self):
)
self.assertEqual(case["status"], "FAIL", case)
self.assertEqual(case["actor"], "interactor", case)
- self.assertEqual(case["execution_status"], "memory_limit", case)
- self.assertEqual(case["failure_kind"], "resource_limit", case)
- self.assertEqual(case["termination_reason"], "memory_limit", case)
+ if case["execution_status"] == "memory_limit":
+ self.assertEqual(case["failure_kind"], "resource_limit", case)
+ self.assertEqual(case["termination_reason"], "memory_limit", case)
+ else:
+ # RLIMIT_AS can reject a large allocation before it becomes
+ # resident. Without measured memory evidence, stderr alone must
+ # not turn an Interactor failure into a claimed MLE.
+ self.assertNotEqual(platform.system(), "Windows", case)
+ self.assertEqual(case["execution_status"], "completed", case)
+ self.assertEqual(case["failure_kind"], "judge_failure", case)
+ self.assertEqual(case["termination_reason"], "completed", case)
+ self.assertTrue(case["memory_enforced"], case)
@unittest.skipUnless(platform.system() == "Linux", "Linux /proc peak-memory regression")
def test_interactive_linux_peak_memory_survives_process_exit(self):
From abf1524b7bc5268ec2a6f988b51f01c444273c58 Mon Sep 17 00:00:00 2001
From: greenthree <1395214327@qq.com>
Date: Wed, 5 Aug 2026 10:44:15 +0800
Subject: [PATCH 3/3] align path tests with access aliases
---
tests/test_local_judge_layout.py | 2 +-
tests/test_problem_paths.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/test_local_judge_layout.py b/tests/test_local_judge_layout.py
index cdc5685..20a4acd 100644
--- a/tests/test_local_judge_layout.py
+++ b/tests/test_local_judge_layout.py
@@ -102,7 +102,7 @@ def test_schema_execution_paths_use_the_shared_problem_fence(self):
self.assertIsNone(MODULE.resolve_problem_path(problem, str(outside)))
self.assertEqual(
Path(MODULE.resolve_problem_path(problem, "code/checker.cpp")),
- (code / "checker.cpp").resolve(),
+ (code / "checker.cpp").absolute(),
)
link = code / "linked-checker.cpp"
diff --git a/tests/test_problem_paths.py b/tests/test_problem_paths.py
index a4e6599..469c02e 100644
--- a/tests/test_problem_paths.py
+++ b/tests/test_problem_paths.py
@@ -22,7 +22,7 @@ def test_resolves_nested_file_with_either_separator(self):
source.parent.mkdir(parents=True)
source.write_text("int main() {}\n", encoding="utf-8")
- expected = source.resolve()
+ expected = source.absolute()
self.assertEqual(
resolve_problem_regular_file(problem, "code/checker.cpp"),
expected,