"""One-shot cancellation for an in-flight adapter call. Lease loss, run timeout, and process signals all need the same adapter-side effect: stop the current subprocess or HTTP client without retaining provider output. The claim loop owns the reason; adapters only observe and stop. """ from __future__ import annotations import contextvars import threading from collections.abc import Callable, Iterator from contextlib import contextmanager from typing import Any _ALLOWED_REASONS = frozenset({"lease-loss", "timeout", "signal"}) _REASON_LIMIT = 32 _active: contextvars.ContextVar["ExecutionCancel | None"] = contextvars.ContextVar( "rein_aharness_execution_cancel", default=None, ) class ExecutionCancelled(RuntimeError): """Bounded proof that an adapter stopped because the run was cancelled.""" def __init__(self, reason: str) -> None: self.reason = _normalize_reason(reason) super().__init__(f"execution cancelled ({self.reason})") class ExecutionCancel: """Thread-safe one-shot cancel with optional process/HTTP stop callbacks.""" def __init__(self) -> None: self._lock = threading.Lock() self._event = threading.Event() self._reason: str | None = None self._stops: list[Callable[[], None]] = [] @property def cancelled(self) -> bool: return self._event.is_set() @property def reason(self) -> str | None: with self._lock: return self._reason def wait(self, timeout: float | None = None) -> bool: return self._event.wait(timeout) def check(self) -> None: if self.cancelled: raise ExecutionCancelled(self.reason or "cancelled") def cancel(self, reason: str) -> str: """Record the first reason and invoke registered stop callbacks once.""" callbacks: list[Callable[[], None]] = [] with self._lock: if self._reason is None: self._reason = _normalize_reason(reason) self._event.set() callbacks = list(self._stops) recorded = self._reason for callback in callbacks: _invoke_stop(callback) return recorded def register_stop(self, stop: Callable[[], None]) -> None: """Register a best-effort stopper; invoke immediately if already cancelled.""" invoke_now = False with self._lock: self._stops.append(stop) invoke_now = self._reason is not None if invoke_now: _invoke_stop(stop) def register_process(self, proc: Any) -> None: """Kill an in-flight subprocess. Safe against mock and exited processes.""" self.register_stop(lambda: _kill_process(proc)) def evidence(self) -> dict[str, str]: if not self.cancelled: return {} return {"cancelled": "true", "reason": self.reason or "cancelled"} def active_cancel() -> ExecutionCancel | None: return _active.get() def resolve_cancel(explicit: ExecutionCancel | None = None) -> ExecutionCancel | None: return explicit if explicit is not None else active_cancel() @contextmanager def using_cancel(cancel: ExecutionCancel) -> Iterator[ExecutionCancel]: token = _active.set(cancel) try: yield cancel finally: _active.reset(token) def _normalize_reason(reason: str) -> str: text = (reason or "").strip()[:_REASON_LIMIT] if text in _ALLOWED_REASONS: return text return "cancelled" def _invoke_stop(stop: Callable[[], None]) -> None: try: stop() except Exception: return def _kill_process(proc: Any) -> None: poll = getattr(proc, "poll", None) if callable(poll): try: status = poll() except Exception: status = None if isinstance(status, int): return kill = getattr(proc, "kill", None) if callable(kill): try: kill() except Exception: return wait = getattr(proc, "wait", None) if callable(wait): try: wait(timeout=2) except Exception: return