Files
turnstone/tests/test_deadline.py
T
Patrick Buckley 6560f1ec4f fix(judge): daemon-thread call deadlines; raise local-model timeouts
The judges and the regex ReDoS probe ran a blocking call on a
ThreadPoolExecutor and abandoned the worker with shutdown(wait=False) on
timeout or cancel. concurrent.futures joins every executor worker from an
atexit hook regardless of wait=False, so a wedged call could pin
interpreter exit — and hang the test suite at shutdown.

Add turnstone/core/deadline.py::run_with_deadline: run a blocking callable
on a daemon thread bounded by a wall-clock timeout and an optional cancel
event. A daemon worker is never joined at exit, so abandoning one is safe.

Migrate three sites onto it:
- OutputGuardJudge.evaluate()
- IntentJudge._evaluate_single / _run_judge — this also removes
  _ExecutorPoisonedError and the executor-restart dance: per-call daemon
  threads can't poison a shared single-slot pool, so a timeout now returns
  None and the caller delivers one fallback verdict.
- console/server.py _validate_regex_pattern (regex ReDoS probe)

Also:
- Double the default judge LLM timeouts for slower local models:
  judge.timeout 60->120s and judge.output_guard_llm_timeout 30->60s
  (settings registry, JudgeConfig dataclass, --judge-timeout CLI default,
  class docstring, docs). Correct a stale doc that described the per-turn
  timeout as a total budget across turns.
- Raise the regex probe bound 0.5->3.0s so a legitimately complex pattern
  isn't false-flagged as catastrophic backtracking.
- CI: run pytest with -v instead of -q so a hang names the offending test
  instead of riding the job timeout.
- Tests: cover deadline.py and the regex validator; move test_judge.py off
  fixed sleeps onto the existing _wait_for helper.
2026-06-17 01:23:51 -07:00

67 lines
2.2 KiB
Python

"""Tests for turnstone.core.deadline.run_with_deadline.
The load-bearing property is the daemon worker: on timeout or cancel the call
is abandoned, and the abandoned thread must be a daemon so it can never block
interpreter exit (the bug that motivated the helper — a non-daemon
ThreadPoolExecutor worker is joined by concurrent.futures' atexit hook).
"""
from __future__ import annotations
import threading
import time
import pytest
from turnstone.core.deadline import (
DeadlineCancelledError,
DeadlineExceededError,
run_with_deadline,
)
def test_returns_result_on_success() -> None:
assert run_with_deadline(lambda: 42, timeout=1.0) == 42
def test_reraises_callable_exception() -> None:
def boom() -> None:
raise ValueError("upstream failed")
with pytest.raises(ValueError, match="upstream failed"):
run_with_deadline(boom, timeout=1.0)
def test_timeout_returns_promptly_and_abandons_a_daemon_worker() -> None:
# The worker sleeps far past the deadline; the call must return promptly
# via DeadlineExceededError, and the abandoned worker must be a daemon so
# it cannot pin interpreter exit.
start = time.monotonic()
with pytest.raises(DeadlineExceededError):
run_with_deadline(lambda: time.sleep(2.0), timeout=0.2, poll=0.05, thread_name="dl-timeout")
assert time.monotonic() - start < 1.0
stragglers = [t for t in threading.enumerate() if t.name == "dl-timeout" and not t.daemon]
assert stragglers == [], f"non-daemon worker survived: {stragglers}"
def test_cancel_returns_promptly() -> None:
cancel = threading.Event()
def _fire() -> None:
time.sleep(0.1)
cancel.set()
threading.Thread(target=_fire, daemon=True).start()
start = time.monotonic()
with pytest.raises(DeadlineCancelledError):
run_with_deadline(
lambda: time.sleep(2.0),
timeout=10.0,
cancel_event=cancel,
poll=0.05,
thread_name="dl-cancel",
)
assert time.monotonic() - start < 1.0
stragglers = [t for t in threading.enumerate() if t.name == "dl-cancel" and not t.daemon]
assert stragglers == [], f"non-daemon worker survived: {stragglers}"