from __future__ import annotations
|
|
from dataclasses import dataclass
|
from datetime import datetime, timezone
|
import os
|
from pathlib import Path
|
import signal
|
import subprocess
|
import time
|
from typing import Sequence
|
|
from .models import ErrorCode
|
|
|
@dataclass(frozen=True)
|
class ProcessResult:
|
argv_redacted: tuple[str, ...]
|
started: bool
|
pid: int | None
|
exit_code: int | None
|
timed_out: bool
|
terminate_issued: bool
|
exited: bool
|
liveness_unknown: bool
|
stdout_bytes: bytes
|
stderr_bytes: bytes
|
stdout_truncated: bool
|
stderr_truncated: bool
|
started_at_utc: str | None
|
ended_at_utc: str | None
|
elapsed_ms: int | None
|
error_code: ErrorCode | None
|
|
|
class ProcessSupervisor:
|
def __init__(self, *, max_stream_bytes: int = 1_048_576, kill_wait_ms: int = 5_000):
|
self.max_stream_bytes = max_stream_bytes
|
self.kill_wait_ms = kill_wait_ms
|
|
@staticmethod
|
def _utc_now() -> str:
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
def run(self, argv: Sequence[str], *, timeout_ms: int, cwd: Path | None = None,
|
stdin_bytes: bytes | None = None, redacted_argv: Sequence[str] | None = None) -> ProcessResult:
|
if not argv or timeout_ms <= 0:
|
raise ValueError("argv and positive timeout required")
|
shown = tuple(redacted_argv or argv)
|
started_at = self._utc_now()
|
begin = time.monotonic()
|
deadline = begin + timeout_ms / 1000
|
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
try:
|
proc = subprocess.Popen(
|
tuple(argv), cwd=str(cwd) if cwd else None, stdin=subprocess.PIPE if stdin_bytes is not None else subprocess.DEVNULL,
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, creationflags=creationflags,
|
start_new_session=(os.name != "nt"),
|
)
|
except OSError:
|
return ProcessResult(shown, False, None, None, False, False, True, False, b"", b"", False,
|
False, started_at, self._utc_now(), round((time.monotonic()-begin)*1000),
|
ErrorCode.PROCESS_START_FAILED)
|
timed_out = False
|
terminate_issued = False
|
liveness_unknown = False
|
try:
|
termination_reserve_ms = min(self.kill_wait_ms, max(100, min(1_000, timeout_ms // 5)),
|
max(1, timeout_ms - 1))
|
normal_wait = max(0.001, (timeout_ms - termination_reserve_ms) / 1000)
|
out, err = proc.communicate(stdin_bytes, timeout=normal_wait)
|
except subprocess.TimeoutExpired:
|
timed_out = True
|
terminate_issued = True
|
self._terminate_tree(proc, max(0.001, deadline - time.monotonic()))
|
try:
|
kill_remaining = max(0.0, deadline - time.monotonic())
|
if kill_remaining <= 0:
|
raise subprocess.TimeoutExpired(tuple(argv), timeout_ms / 1000)
|
out, err = proc.communicate(timeout=min(self.kill_wait_ms / 1000, kill_remaining))
|
except subprocess.TimeoutExpired:
|
out, err = b"", b""
|
liveness_unknown = True
|
try:
|
proc.kill()
|
except OSError:
|
pass
|
elapsed = round((time.monotonic() - begin) * 1000)
|
out_trim, out_trunc = self._cap(out)
|
err_trim, err_trunc = self._cap(err)
|
exited = proc.poll() is not None
|
code = None
|
if liveness_unknown:
|
code = ErrorCode.PROCESS_LIVENESS_UNKNOWN
|
elif timed_out:
|
code = ErrorCode.PROCESS_TIMEOUT
|
elif out_trunc or err_trunc:
|
code = ErrorCode.PROCESS_OUTPUT_LIMIT
|
return ProcessResult(shown, True, proc.pid, proc.returncode if exited else None, timed_out, terminate_issued,
|
exited, liveness_unknown, out_trim, err_trim, out_trunc, err_trunc,
|
started_at, self._utc_now(), elapsed, code)
|
|
def _cap(self, data: bytes) -> tuple[bytes, bool]:
|
if len(data) <= self.max_stream_bytes:
|
return data, False
|
return data[:self.max_stream_bytes], True
|
|
@staticmethod
|
def _terminate_tree(proc: subprocess.Popen[bytes], timeout_seconds: float = 5.0) -> None:
|
try:
|
if os.name == "nt":
|
subprocess.run(("taskkill", "/PID", str(proc.pid), "/T", "/F"), shell=False,
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
timeout=max(0.001, min(5.0, timeout_seconds)))
|
else:
|
os.killpg(proc.pid, signal.SIGKILL)
|
except (OSError, subprocess.SubprocessError):
|
try:
|
proc.kill()
|
except OSError:
|
pass
|