quantdiff 0.1.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantdiff/__init__.py +53 -0
- quantdiff/__main__.py +5 -0
- quantdiff/_http.py +151 -0
- quantdiff/_text.py +13 -0
- quantdiff/_version.py +1 -0
- quantdiff/api.py +340 -0
- quantdiff/backends/__init__.py +28 -0
- quantdiff/backends/_common.py +342 -0
- quantdiff/backends/base.py +91 -0
- quantdiff/backends/llamacpp.py +428 -0
- quantdiff/backends/ollama.py +359 -0
- quantdiff/backends/openai_compat.py +338 -0
- quantdiff/cache.py +240 -0
- quantdiff/card.py +1664 -0
- quantdiff/cli.py +377 -0
- quantdiff/discover.py +488 -0
- quantdiff/errors.py +45 -0
- quantdiff/metrics/__init__.py +36 -0
- quantdiff/metrics/codeexec.py +428 -0
- quantdiff/metrics/jsonschema.py +610 -0
- quantdiff/metrics/logit.py +214 -0
- quantdiff/metrics/tasks.py +114 -0
- quantdiff/metrics/textsim.py +66 -0
- quantdiff/metrics/toolcheck.py +99 -0
- quantdiff/png.py +360 -0
- quantdiff/preflight.py +365 -0
- quantdiff/progress.py +283 -0
- quantdiff/py.typed +0 -0
- quantdiff/report.py +780 -0
- quantdiff/runner.py +492 -0
- quantdiff/spec.py +154 -0
- quantdiff/stats.py +226 -0
- quantdiff/suites/__init__.py +462 -0
- quantdiff/suites/data/chat.jsonl +22 -0
- quantdiff/suites/data/code.jsonl +32 -0
- quantdiff/suites/data/json.jsonl +34 -0
- quantdiff/suites/data/scoring.jsonl +41 -0
- quantdiff/suites/data/tools.jsonl +32 -0
- quantdiff/types.py +322 -0
- quantdiff/verdict.py +1513 -0
- quantdiff-0.1.0rc1.dist-info/METADATA +514 -0
- quantdiff-0.1.0rc1.dist-info/RECORD +45 -0
- quantdiff-0.1.0rc1.dist-info/WHEEL +4 -0
- quantdiff-0.1.0rc1.dist-info/entry_points.txt +2 -0
- quantdiff-0.1.0rc1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Run model-written code against a case's assert-based tests in a child process.
|
|
2
|
+
|
|
3
|
+
This is isolation for honest mistakes, not a security sandbox. The code runs in a fresh
|
|
4
|
+
interpreter inside a throwaway directory, with an empty environment (so API keys in the
|
|
5
|
+
parent's environment are not visible), a timeout, and on POSIX some resource limits. On
|
|
6
|
+
timeout every process the code started is killed, not only the interpreter. A deliberately
|
|
7
|
+
hostile program can still read your files or reach the network. quantdiff therefore never
|
|
8
|
+
calls this unless the user passes --allow-code-exec.
|
|
9
|
+
|
|
10
|
+
The verdict is guarded against solutions that try to report a pass for themselves. The
|
|
11
|
+
parent sends the harness a random nonce on stdin before the solution is imported, and only
|
|
12
|
+
accepts a verdict that carries it. The harness writes that verdict to a private duplicate
|
|
13
|
+
of its original stdout, after pointing stdout at the output log, then ends the process
|
|
14
|
+
with os._exit so no exit handler, finally block or finalizer from the solution runs after
|
|
15
|
+
it. Files the solution writes and anything it prints are never read as a verdict. The
|
|
16
|
+
solution shares the harness's interpreter, so code written to dig the nonce out of the
|
|
17
|
+
harness's stack frames can still forge a pass; that takes deliberate effort aimed at
|
|
18
|
+
quantdiff, and SECURITY.md lists it as a known limit.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import secrets
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
from collections.abc import Callable, Iterator
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Final
|
|
37
|
+
|
|
38
|
+
from quantdiff.errors import SuiteError
|
|
39
|
+
from quantdiff.metrics.textsim import strip_reasoning
|
|
40
|
+
from quantdiff.types import CaseOutcome, TaskCase
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
DEFAULT_TIMEOUT_SECONDS: Final = 10.0
|
|
45
|
+
OUTPUT_PREVIEW_CHARS: Final = 2000
|
|
46
|
+
|
|
47
|
+
_FENCE: Final = re.compile(r"```[ \t]*([A-Za-z0-9_+-]*)[^\n]*\n(.*?)```", re.DOTALL)
|
|
48
|
+
_PYTHON_TAGS: Final = frozenset({"python", "python3", "py"})
|
|
49
|
+
_SOLUTION_FILE: Final = "solution.py"
|
|
50
|
+
_TESTS_FILE: Final = "case_tests.py"
|
|
51
|
+
_HARNESS_FILE: Final = "harness.py"
|
|
52
|
+
_VERDICT_FILE: Final = "verdict.jsonl"
|
|
53
|
+
_OUTPUT_FILE: Final = "output.txt"
|
|
54
|
+
_MAX_VERDICT_BYTES: Final = 64 * 1024
|
|
55
|
+
_NONCE_BYTES: Final = 16
|
|
56
|
+
|
|
57
|
+
# The harness runs in the child. Everything that can produce a verdict lives in main()'s
|
|
58
|
+
# locals, and the functions it calls are bound before the solution can replace them.
|
|
59
|
+
_HARNESS: Final = """\
|
|
60
|
+
import json
|
|
61
|
+
import os
|
|
62
|
+
import sys
|
|
63
|
+
import traceback
|
|
64
|
+
|
|
65
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
66
|
+
TESTS = os.path.join(HERE, {tests_file!r})
|
|
67
|
+
ENTRY_POINT = {entry_point!r}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def one_line(text, limit=200):
|
|
71
|
+
flat = " ".join(str(text).split())
|
|
72
|
+
return flat if len(flat) <= limit else flat[: limit - 3] + "..."
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def describe(exc):
|
|
76
|
+
if isinstance(exc, AssertionError):
|
|
77
|
+
line = ""
|
|
78
|
+
for frame in reversed(traceback.extract_tb(exc.__traceback__)):
|
|
79
|
+
if frame.filename == TESTS:
|
|
80
|
+
line = (frame.line or "").strip()
|
|
81
|
+
break
|
|
82
|
+
detail = ": " + one_line(exc) if str(exc) else ""
|
|
83
|
+
return one_line("assertion failed: " + (line or "assert") + detail)
|
|
84
|
+
return one_line(type(exc).__name__ + (": " + str(exc) if str(exc) else ""))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main():
|
|
88
|
+
nonce = sys.stdin.readline().strip()
|
|
89
|
+
write, exit_now, dumps = os.write, os._exit, json.dumps
|
|
90
|
+
verdict_fd = os.dup(1)
|
|
91
|
+
os.dup2(2, 1)
|
|
92
|
+
|
|
93
|
+
def report(status, reason):
|
|
94
|
+
for stream in (sys.stdout, sys.stderr):
|
|
95
|
+
try:
|
|
96
|
+
stream.flush()
|
|
97
|
+
except BaseException:
|
|
98
|
+
pass
|
|
99
|
+
line = dumps({{"nonce": nonce, "status": status, "reason": reason}}) + "\\n"
|
|
100
|
+
write(verdict_fd, line.encode("utf-8"))
|
|
101
|
+
exit_now(0)
|
|
102
|
+
|
|
103
|
+
sys.path.insert(0, HERE)
|
|
104
|
+
try:
|
|
105
|
+
import solution
|
|
106
|
+
except BaseException as exc:
|
|
107
|
+
report("fail", "solution failed to import: " + describe(exc))
|
|
108
|
+
if not callable(getattr(solution, ENTRY_POINT, None)):
|
|
109
|
+
report("fail", "solution does not define " + ENTRY_POINT)
|
|
110
|
+
namespace = dict(vars(solution))
|
|
111
|
+
try:
|
|
112
|
+
with open(TESTS, encoding="utf-8") as handle:
|
|
113
|
+
source = handle.read()
|
|
114
|
+
exec(compile(source, TESTS, "exec"), namespace)
|
|
115
|
+
except BaseException as exc:
|
|
116
|
+
report("fail", describe(exc))
|
|
117
|
+
report("pass", "")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
main()
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def extract_code(answer: str) -> str:
|
|
125
|
+
"""Return the code in a model answer.
|
|
126
|
+
|
|
127
|
+
Prefers the first ```python fenced block, then the first fenced block of any kind,
|
|
128
|
+
then the whole answer. A leading <think> block is ignored.
|
|
129
|
+
"""
|
|
130
|
+
text = strip_reasoning(answer)
|
|
131
|
+
blocks = [(match.group(1).lower(), match.group(2)) for match in _FENCE.finditer(text)]
|
|
132
|
+
for tag, body in blocks:
|
|
133
|
+
if tag in _PYTHON_TAGS:
|
|
134
|
+
return body
|
|
135
|
+
if blocks:
|
|
136
|
+
return blocks[0][1]
|
|
137
|
+
return text.strip()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def run_code_case(
|
|
141
|
+
case: TaskCase, answer: str, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
|
142
|
+
) -> CaseOutcome:
|
|
143
|
+
"""Run the code in `answer` against `case.tests` and report pass or fail.
|
|
144
|
+
|
|
145
|
+
Raises SuiteError if the case lacks a valid entry point or tests. Any other problem,
|
|
146
|
+
including one in quantdiff's own process handling, becomes a failed outcome with a
|
|
147
|
+
reason, so one misbehaving answer cannot abort a run.
|
|
148
|
+
"""
|
|
149
|
+
entry_point, tests = _require_code_fields(case)
|
|
150
|
+
code = extract_code(answer)
|
|
151
|
+
if not code.strip():
|
|
152
|
+
return _outcome(case, passed=False, reason="answer contains no code")
|
|
153
|
+
try:
|
|
154
|
+
passed, reason = _run_isolated(
|
|
155
|
+
code=code, tests=tests, entry_point=entry_point, timeout_seconds=timeout_seconds
|
|
156
|
+
)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
logger.exception("could not run code case %s", case.id)
|
|
159
|
+
return _outcome(case, passed=False, reason=f"could not run the tests: {exc!r}")
|
|
160
|
+
return _outcome(case, passed=passed, reason=reason)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _require_code_fields(case: TaskCase) -> tuple[str, str]:
|
|
164
|
+
if not case.entry_point or not case.entry_point.isidentifier():
|
|
165
|
+
raise SuiteError(f"code case {case.id!r} needs an entry_point that is a Python name")
|
|
166
|
+
if not case.tests or not case.tests.strip():
|
|
167
|
+
raise SuiteError(f"code case {case.id!r} has no tests")
|
|
168
|
+
return case.entry_point, case.tests
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _run_isolated(
|
|
172
|
+
*, code: str, tests: str, entry_point: str, timeout_seconds: float
|
|
173
|
+
) -> tuple[bool, str]:
|
|
174
|
+
# Not TemporaryDirectory: on CPython 3.10 and 3.11 its ignore_cleanup_errors path
|
|
175
|
+
# recurses without bound when Windows refuses to delete a directory still in use.
|
|
176
|
+
workdir = Path(tempfile.mkdtemp(prefix="quantdiff-code-"))
|
|
177
|
+
try:
|
|
178
|
+
_write_files(workdir, code=code, tests=tests, entry_point=entry_point)
|
|
179
|
+
return _run_harness(workdir, timeout_seconds)
|
|
180
|
+
finally:
|
|
181
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _write_files(workdir: Path, *, code: str, tests: str, entry_point: str) -> None:
|
|
185
|
+
harness = _HARNESS.format(tests_file=_TESTS_FILE, entry_point=entry_point)
|
|
186
|
+
(workdir / _SOLUTION_FILE).write_text(code, encoding="utf-8")
|
|
187
|
+
(workdir / _TESTS_FILE).write_text(tests, encoding="utf-8")
|
|
188
|
+
(workdir / _HARNESS_FILE).write_text(harness, encoding="utf-8")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _run_harness(workdir: Path, timeout_seconds: float) -> tuple[bool, str]:
|
|
192
|
+
# -I would make Python ignore the PYTHON* variables below. The environment is built
|
|
193
|
+
# from scratch instead, so -E adds nothing; -s and -S keep site-packages out.
|
|
194
|
+
command = [sys.executable, "-s", "-S", str(workdir / _HARNESS_FILE)]
|
|
195
|
+
nonce = secrets.token_hex(_NONCE_BYTES)
|
|
196
|
+
verdict_path = workdir / _VERDICT_FILE
|
|
197
|
+
output_path = workdir / _OUTPUT_FILE
|
|
198
|
+
with verdict_path.open("wb") as verdict, output_path.open("wb") as output:
|
|
199
|
+
process = subprocess.Popen( # noqa: S603 - fixed argv, no shell; running the code is the point
|
|
200
|
+
command,
|
|
201
|
+
cwd=workdir,
|
|
202
|
+
env=_child_environment(),
|
|
203
|
+
stdin=subprocess.PIPE,
|
|
204
|
+
stdout=verdict,
|
|
205
|
+
stderr=output,
|
|
206
|
+
preexec_fn=_resource_limiter(timeout_seconds), # noqa: PLW1509 - only sets rlimits, no locks or threads
|
|
207
|
+
start_new_session=_NEW_SESSION,
|
|
208
|
+
)
|
|
209
|
+
with _contained(process):
|
|
210
|
+
# The harness waits for this line before importing the solution, so the
|
|
211
|
+
# process is already contained by the time any model code runs.
|
|
212
|
+
_send_nonce(process, nonce)
|
|
213
|
+
try:
|
|
214
|
+
returncode = process.wait(timeout=timeout_seconds)
|
|
215
|
+
except subprocess.TimeoutExpired:
|
|
216
|
+
return False, f"timed out after {timeout_seconds:g}s"
|
|
217
|
+
|
|
218
|
+
logger.debug("code case output: %s", _read_preview(output_path))
|
|
219
|
+
return _read_verdict(verdict_path, nonce, returncode)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _send_nonce(process: subprocess.Popen[bytes], nonce: str) -> None:
|
|
223
|
+
if process.stdin is None:
|
|
224
|
+
raise RuntimeError("harness stdin is not a pipe")
|
|
225
|
+
try:
|
|
226
|
+
process.stdin.write(f"{nonce}\n".encode("ascii"))
|
|
227
|
+
process.stdin.close()
|
|
228
|
+
except OSError:
|
|
229
|
+
# The interpreter died before reading; the missing verdict reports it.
|
|
230
|
+
logger.debug("code harness exited before it read its nonce")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _child_environment() -> dict[str, str]:
|
|
234
|
+
environment = {
|
|
235
|
+
"PATH": "",
|
|
236
|
+
"PYTHONHASHSEED": "0",
|
|
237
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
238
|
+
"PYTHONIOENCODING": "utf-8",
|
|
239
|
+
}
|
|
240
|
+
if sys.platform == "win32":
|
|
241
|
+
# The Windows C runtime and the interpreter fail to start without it.
|
|
242
|
+
environment["SYSTEMROOT"] = os.environ.get("SYSTEMROOT", r"C:\Windows")
|
|
243
|
+
return environment
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _read_verdict(verdict_path: Path, nonce: str, returncode: int) -> tuple[bool, str]:
|
|
247
|
+
"""Return the harness's verdict, ignoring any line that lacks this run's nonce."""
|
|
248
|
+
with verdict_path.open("rb") as handle:
|
|
249
|
+
raw = handle.read(_MAX_VERDICT_BYTES)
|
|
250
|
+
for line in raw.decode("utf-8", errors="replace").splitlines():
|
|
251
|
+
try:
|
|
252
|
+
verdict = json.loads(line)
|
|
253
|
+
except ValueError:
|
|
254
|
+
continue
|
|
255
|
+
if not isinstance(verdict, dict) or verdict.get("nonce") != nonce:
|
|
256
|
+
continue
|
|
257
|
+
if verdict.get("status") not in {"pass", "fail"}:
|
|
258
|
+
return False, "harness reported a malformed result"
|
|
259
|
+
return verdict["status"] == "pass", str(verdict.get("reason", ""))
|
|
260
|
+
return False, f"process exited with code {returncode} before reporting a result"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _read_preview(path: Path) -> str:
|
|
264
|
+
with path.open("rb") as handle:
|
|
265
|
+
raw = handle.read(OUTPUT_PREVIEW_CHARS * 4)
|
|
266
|
+
return raw.decode("utf-8", errors="replace")[:OUTPUT_PREVIEW_CHARS]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _outcome(case: TaskCase, *, passed: bool, reason: str) -> CaseOutcome:
|
|
270
|
+
return CaseOutcome(case_id=case.id, kind=case.kind, passed=passed, reason=reason)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if sys.platform == "win32":
|
|
274
|
+
import ctypes
|
|
275
|
+
from ctypes import wintypes
|
|
276
|
+
|
|
277
|
+
_NEW_SESSION: Final = False
|
|
278
|
+
_EXTENDED_LIMIT_INFORMATION: Final = 9
|
|
279
|
+
_KILL_ON_JOB_CLOSE: Final = 0x2000
|
|
280
|
+
_PROCESS_TERMINATE_AND_SET_QUOTA: Final = 0x0001 | 0x0100
|
|
281
|
+
_NOT_INHERITED: Final = 0
|
|
282
|
+
|
|
283
|
+
class _BasicLimits(ctypes.Structure):
|
|
284
|
+
_fields_ = (
|
|
285
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
286
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
287
|
+
("LimitFlags", wintypes.DWORD),
|
|
288
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
289
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
290
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
291
|
+
("Affinity", ctypes.c_size_t),
|
|
292
|
+
("PriorityClass", wintypes.DWORD),
|
|
293
|
+
("SchedulingClass", wintypes.DWORD),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
class _IoCounters(ctypes.Structure):
|
|
297
|
+
_fields_ = (
|
|
298
|
+
("ReadOperationCount", ctypes.c_uint64),
|
|
299
|
+
("WriteOperationCount", ctypes.c_uint64),
|
|
300
|
+
("OtherOperationCount", ctypes.c_uint64),
|
|
301
|
+
("ReadTransferCount", ctypes.c_uint64),
|
|
302
|
+
("WriteTransferCount", ctypes.c_uint64),
|
|
303
|
+
("OtherTransferCount", ctypes.c_uint64),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
class _ExtendedLimits(ctypes.Structure):
|
|
307
|
+
_fields_ = (
|
|
308
|
+
("BasicLimitInformation", _BasicLimits),
|
|
309
|
+
("IoInfo", _IoCounters),
|
|
310
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
311
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
312
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
313
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
317
|
+
_kernel32.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
|
|
318
|
+
_kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
319
|
+
_kernel32.SetInformationJobObject.argtypes = (
|
|
320
|
+
wintypes.HANDLE,
|
|
321
|
+
ctypes.c_int,
|
|
322
|
+
ctypes.c_void_p,
|
|
323
|
+
wintypes.DWORD,
|
|
324
|
+
)
|
|
325
|
+
_kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
326
|
+
_kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
327
|
+
_kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
328
|
+
_kernel32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
329
|
+
_kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
330
|
+
_kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
331
|
+
_kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
332
|
+
_kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
333
|
+
_kernel32.CloseHandle.restype = wintypes.BOOL
|
|
334
|
+
|
|
335
|
+
def _resource_limiter(timeout_seconds: float) -> Callable[[], None] | None:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
@contextlib.contextmanager
|
|
339
|
+
def _contained(process: subprocess.Popen[bytes]) -> Iterator[None]:
|
|
340
|
+
"""Kill `process` and every process it started when the block exits.
|
|
341
|
+
|
|
342
|
+
A job object follows the whole process tree, which a plain kill does not: a
|
|
343
|
+
grandchild would survive it and keep the working directory in use.
|
|
344
|
+
"""
|
|
345
|
+
job = _job_for(process)
|
|
346
|
+
try:
|
|
347
|
+
yield
|
|
348
|
+
finally:
|
|
349
|
+
if job is None:
|
|
350
|
+
process.kill()
|
|
351
|
+
else:
|
|
352
|
+
_kernel32.TerminateJobObject(job, 1)
|
|
353
|
+
_kernel32.CloseHandle(job)
|
|
354
|
+
process.wait()
|
|
355
|
+
|
|
356
|
+
def _job_for(process: subprocess.Popen[bytes]) -> int | None:
|
|
357
|
+
job: int | None = _kernel32.CreateJobObjectW(None, None)
|
|
358
|
+
if not job:
|
|
359
|
+
_warn_uncontained()
|
|
360
|
+
return None
|
|
361
|
+
limits = _ExtendedLimits()
|
|
362
|
+
limits.BasicLimitInformation.LimitFlags = _KILL_ON_JOB_CLOSE
|
|
363
|
+
handle: int | None = _kernel32.OpenProcess(
|
|
364
|
+
_PROCESS_TERMINATE_AND_SET_QUOTA, _NOT_INHERITED, process.pid
|
|
365
|
+
)
|
|
366
|
+
assigned = bool(
|
|
367
|
+
handle
|
|
368
|
+
and _kernel32.SetInformationJobObject(
|
|
369
|
+
job, _EXTENDED_LIMIT_INFORMATION, ctypes.byref(limits), ctypes.sizeof(limits)
|
|
370
|
+
)
|
|
371
|
+
and _kernel32.AssignProcessToJobObject(job, handle)
|
|
372
|
+
)
|
|
373
|
+
if handle:
|
|
374
|
+
_kernel32.CloseHandle(handle)
|
|
375
|
+
if not assigned:
|
|
376
|
+
_warn_uncontained()
|
|
377
|
+
_kernel32.CloseHandle(job)
|
|
378
|
+
return None
|
|
379
|
+
return job
|
|
380
|
+
|
|
381
|
+
def _warn_uncontained() -> None:
|
|
382
|
+
logger.warning(
|
|
383
|
+
"could not put the code harness in a Windows job object (error %d); "
|
|
384
|
+
"processes started by model code may outlive a timeout",
|
|
385
|
+
ctypes.get_last_error(),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
else:
|
|
389
|
+
import resource
|
|
390
|
+
import signal
|
|
391
|
+
|
|
392
|
+
_NEW_SESSION: Final = True
|
|
393
|
+
_MEMORY_BYTES: Final = 1024**3
|
|
394
|
+
_FILE_BYTES: Final = 16 * 1024**2
|
|
395
|
+
_OPEN_FILES: Final = 64
|
|
396
|
+
|
|
397
|
+
def _resource_limiter(timeout_seconds: float) -> Callable[[], None] | None:
|
|
398
|
+
limits = (
|
|
399
|
+
(resource.RLIMIT_CPU, math.ceil(timeout_seconds) + 1),
|
|
400
|
+
(resource.RLIMIT_AS, _MEMORY_BYTES),
|
|
401
|
+
(resource.RLIMIT_FSIZE, _FILE_BYTES),
|
|
402
|
+
(resource.RLIMIT_NOFILE, _OPEN_FILES),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def apply_limits() -> None:
|
|
406
|
+
for which, wanted in limits:
|
|
407
|
+
_, hard = resource.getrlimit(which)
|
|
408
|
+
value = wanted if hard == resource.RLIM_INFINITY else min(wanted, hard)
|
|
409
|
+
try:
|
|
410
|
+
resource.setrlimit(which, (value, value))
|
|
411
|
+
except (ValueError, OSError):
|
|
412
|
+
# Some kernels (macOS for RLIMIT_AS) refuse a limit; the timeout still applies.
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
return apply_limits
|
|
416
|
+
|
|
417
|
+
@contextlib.contextmanager
|
|
418
|
+
def _contained(process: subprocess.Popen[bytes]) -> Iterator[None]:
|
|
419
|
+
"""Kill `process` and every process in its session's group when the block exits."""
|
|
420
|
+
try:
|
|
421
|
+
yield
|
|
422
|
+
finally:
|
|
423
|
+
try:
|
|
424
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
425
|
+
except OSError:
|
|
426
|
+
# The group is already empty (ESRCH), or only zombies remain (EPERM on macOS).
|
|
427
|
+
logger.debug("code harness process group already gone")
|
|
428
|
+
process.wait()
|