emrekuruu's picture
fix: prevent stalled SpreadsheetBench sessions
81a00cc verified
Raw History Blame Contribute Delete
9.88 kB
"""Bounded subprocess execution for one SpreadsheetBench session."""
from __future__ import annotations
from dataclasses import dataclass, replace
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import sys
import textwrap
import threading
_PATH_ASSIGNMENT = re.compile(
r"^\s*(INPUT_PATH|OUTPUT_PATH)\s*=\s*.+$",
re.MULTILINE,
)
_SAFE_CASE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
_MAX_CAPTURE_CHARS = 50_000
_UID_LEASE_LOCK = threading.Lock()
_LEASED_UIDS: set[int] = set()
def _lease_uid(base: int) -> int:
with _UID_LEASE_LOCK:
candidate = base
while candidate in _LEASED_UIDS:
candidate += 1
_LEASED_UIDS.add(candidate)
return candidate
def _release_uid(uid: int) -> None:
with _UID_LEASE_LOCK:
_LEASED_UIDS.discard(uid)
@dataclass(frozen=True, slots=True)
class ExecutorLimits:
"""Wall-clock and POSIX resource limits for generated code."""
timeout_s: float = 120.0
cpu_seconds: int = 120
memory_bytes: int = 2 * 1024 * 1024 * 1024
file_bytes: int = 256 * 1024 * 1024
process_count: int = 64
open_files: int = 128
run_uid: int | None = None
run_uid_base: int | None = None
run_gid: int | None = None
@dataclass(frozen=True, slots=True)
class ExecutionResult:
"""One generated-script execution result."""
ok: bool
output_path: Path | None
stdout: str
stderr: str
error: str
timed_out: bool
returncode: int | None
def _bounded_text(value: str) -> str:
if len(value) <= _MAX_CAPTURE_CHARS:
return value
half = _MAX_CAPTURE_CHARS // 2
return value[:half] + "\n...[subprocess output truncated]...\n" + value[-half:]
def _runner_source(code: str, input_path: Path, output_path: Path) -> str:
cleaned = _PATH_ASSIGNMENT.sub("", code)
indented = textwrap.indent(cleaned, " ") or " pass"
return (
"import traceback\n"
f"INPUT_PATH = {str(input_path)!r}\n"
f"OUTPUT_PATH = {str(output_path)!r}\n"
"try:\n"
f"{indented}\n"
"except Exception:\n"
" traceback.print_exc()\n"
" raise SystemExit(2)\n"
)
def _safe_environment(case_dir: Path, scratch_dir: Path) -> dict[str, str]:
environment = {
"PATH": os.environ.get("PATH") or os.defpath,
"HOME": str(case_dir),
"TMPDIR": str(scratch_dir),
"PYTHONUNBUFFERED": "1",
"OPENBLAS_NUM_THREADS": "1",
"OMP_NUM_THREADS": "1",
"MKL_NUM_THREADS": "1",
"NUMEXPR_NUM_THREADS": "1",
}
for key in ("LANG", "LC_ALL", "LC_CTYPE", "PYTHONIOENCODING", "PYTHONUTF8"):
value = os.environ.get(key)
if value:
environment[key] = value
return environment
def _launcher_source(limits: ExecutorLimits, runner_path: Path) -> str:
"""Apply limits after exec, avoiding unsafe preexec_fn work in the server."""
return (
"import os\n"
"import resource\n"
"import sys\n"
"resource.setrlimit(resource.RLIMIT_CORE, (0, 0))\n"
f"resource.setrlimit(resource.RLIMIT_CPU, ({limits.cpu_seconds}, {limits.cpu_seconds}))\n"
f"memory_bytes = {limits.memory_bytes}\n"
"if sys.platform != 'darwin':\n"
" resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))\n"
f"resource.setrlimit(resource.RLIMIT_FSIZE, ({limits.file_bytes}, {limits.file_bytes}))\n"
f"resource.setrlimit(resource.RLIMIT_NOFILE, ({limits.open_files}, {limits.open_files}))\n"
f"process_count = {limits.process_count}\n"
"if sys.platform != 'darwin' and hasattr(resource, 'RLIMIT_NPROC'):\n"
" resource.setrlimit(resource.RLIMIT_NPROC, (process_count, process_count))\n"
f"run_gid = {limits.run_gid!r}\n"
"if run_gid is not None:\n"
" os.setgroups([])\n"
" os.setgid(run_gid)\n"
f"run_uid = {limits.run_uid!r}\n"
"if run_uid is not None:\n"
" os.setuid(run_uid)\n"
f"runner_path = {str(runner_path)!r}\n"
"os.execv(sys.executable, [sys.executable, runner_path])\n"
)
class ScriptExecutor:
"""Own the private working tree for one connection-owned episode."""
def __init__(self, root: str | Path, *, limits: ExecutorLimits | None = None) -> None:
self._root = Path(root).resolve()
configured = limits or ExecutorLimits()
if configured.run_uid is not None and configured.run_uid_base is not None:
raise ValueError("configure either run_uid or run_uid_base, not both")
self._leased_uid = (
None
if configured.run_uid_base is None
else _lease_uid(configured.run_uid_base)
)
self._limits = replace(configured, run_uid=self._leased_uid or configured.run_uid)
self._closed = False
try:
self._root.mkdir(parents=True, exist_ok=False, mode=0o700)
except Exception:
if self._leased_uid is not None:
_release_uid(self._leased_uid)
raise
@property
def root(self) -> Path:
return self._root
@property
def leased_uid(self) -> int | None:
return self._leased_uid or self._limits.run_uid
def run(
self,
code: str,
input_path: str | Path,
*,
case_name: str,
) -> ExecutionResult:
if self._closed:
raise RuntimeError("script executor is closed")
if (
not _SAFE_CASE_NAME.fullmatch(case_name)
or case_name in {".", ".."}
or ".." in case_name
):
raise ValueError(f"unsafe case name: {case_name!r}")
source = Path(input_path).resolve(strict=True)
case_dir = self._root / case_name
case_dir.mkdir(mode=0o700)
scratch_dir = case_dir / "tmp"
scratch_dir.mkdir(mode=0o700)
private_input = case_dir / "input.xlsx"
output_path = case_dir / "output.xlsx"
runner_path = case_dir / "runner.py"
launcher_path = case_dir / "launcher.py"
stdout_path = case_dir / "stdout.log"
stderr_path = case_dir / "stderr.log"
shutil.copy2(source, private_input)
runner_path.write_text(
_runner_source(code, private_input, output_path),
encoding="utf-8",
)
launcher_path.write_text(
_launcher_source(self._limits, runner_path),
encoding="utf-8",
)
if self._limits.run_uid is not None or self._limits.run_gid is not None:
uid = -1 if self._limits.run_uid is None else self._limits.run_uid
gid = -1 if self._limits.run_gid is None else self._limits.run_gid
os.chmod(self._root, 0o711)
for path in (case_dir, scratch_dir, private_input, runner_path, launcher_path):
os.chown(path, uid, gid)
with stdout_path.open("w", encoding="utf-8") as stdout_file, stderr_path.open(
"w", encoding="utf-8"
) as stderr_file:
process = subprocess.Popen(
[sys.executable, str(launcher_path)],
cwd=case_dir,
env=_safe_environment(case_dir, scratch_dir),
stdout=stdout_file,
stderr=stderr_file,
text=True,
start_new_session=True,
)
timed_out = False
try:
process.wait(timeout=self._limits.timeout_s)
except subprocess.TimeoutExpired:
timed_out = True
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
stdout = stdout_path.read_text(encoding="utf-8", errors="replace")
stderr = stderr_path.read_text(encoding="utf-8", errors="replace")
stdout = _bounded_text(stdout)
stderr = _bounded_text(stderr)
if timed_out:
return ExecutionResult(
ok=False,
output_path=None,
stdout=stdout,
stderr=stderr,
error=f"timeout after {self._limits.timeout_s:g}s",
timed_out=True,
returncode=process.returncode,
)
if process.returncode != 0:
error = (stdout + "\n" + stderr).strip() or (
f"generated script exited with status {process.returncode}"
)
return ExecutionResult(
ok=False,
output_path=None,
stdout=stdout,
stderr=stderr,
error=error,
timed_out=False,
returncode=process.returncode,
)
if not output_path.is_file():
return ExecutionResult(
ok=False,
output_path=None,
stdout=stdout,
stderr=stderr,
error="output file was not created",
timed_out=False,
returncode=process.returncode,
)
return ExecutionResult(
ok=True,
output_path=output_path,
stdout=stdout,
stderr=stderr,
error="",
timed_out=False,
returncode=process.returncode,
)
def remove_case(self, case_name: str) -> None:
if _SAFE_CASE_NAME.fullmatch(case_name) and ".." not in case_name:
shutil.rmtree(self._root / case_name, ignore_errors=True)
def close(self) -> None:
if self._closed:
return
shutil.rmtree(self._root, ignore_errors=True)
if self._leased_uid is not None:
_release_uid(self._leased_uid)
self._closed = True