Files
pristontale-eu-linux/ptlinux/command.py

324 lines
8.3 KiB
Python

"""Inspectable subprocess execution for installer operations."""
from __future__ import annotations
from collections import deque
from dataclasses import asdict, dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import threading
import time
from typing import Callable, Mapping, Sequence, TextIO
OutputLineFilter = Callable[[str], str | None]
MAX_CAPTURE_CHARACTERS = 1024 * 1024
SUBPROCESS_ENCODING = "utf-8"
SUBPROCESS_DECODE_ERRORS = "replace"
class BoundedTextCapture:
"""Retain the newest subprocess output without unbounded memory growth."""
def __init__(self, max_characters: int = MAX_CAPTURE_CHARACTERS) -> None:
if max_characters <= 0:
raise ValueError("capture size must be positive")
self._max_characters = max_characters
self._parts: deque[str] = deque()
self._characters = 0
self._lock = threading.Lock()
def append(self, value: str) -> None:
if not value:
return
with self._lock:
if len(value) >= self._max_characters:
self._parts.clear()
self._parts.append(value[-self._max_characters:])
self._characters = self._max_characters
return
self._parts.append(value)
self._characters += len(value)
while self._characters > self._max_characters:
overflow = self._characters - self._max_characters
oldest = self._parts[0]
if len(oldest) <= overflow:
self._parts.popleft()
self._characters -= len(oldest)
else:
self._parts[0] = oldest[overflow:]
self._characters -= overflow
def getvalue(self) -> str:
with self._lock:
return "".join(self._parts)
@dataclass(frozen=True)
class CommandOutcome:
argv: list[str]
returncode: int | None
stdout: str
stderr: str
duration_seconds: float
timed_out: bool
dry_run: bool
class CommandError(RuntimeError):
"""Raised when a required installer command fails."""
def __init__(self, outcome: CommandOutcome) -> None:
self.outcome = outcome
returncode = "unknown" if outcome.returncode is None else str(outcome.returncode)
message = f"command failed ({returncode}): {outcome.argv!r}"
if outcome.timed_out:
message = f"command timed out: {outcome.argv!r}"
super().__init__(message)
class CommandRunner:
"""Run argument arrays with timeouts and append-only JSONL logging."""
def __init__(self, log_path: Path, *, dry_run: bool = False) -> None:
self.log_path = log_path
self.dry_run = dry_run
def run(
self,
argv: Sequence[str],
*,
cwd: Path | None = None,
env_overrides: Mapping[str, str] | None = None,
timeout_seconds: float = 300.0,
check: bool = True,
stream_output: bool = False,
output_line_filter: OutputLineFilter | None = None,
) -> CommandOutcome:
arguments = [str(value) for value in argv]
if not arguments:
raise ValueError("command argv cannot be empty")
overrides = dict(env_overrides or {})
started = time.monotonic()
if self.dry_run:
outcome = CommandOutcome(
argv=arguments,
returncode=0,
stdout="",
stderr="",
duration_seconds=0.0,
timed_out=False,
dry_run=True,
)
self._record(outcome, cwd, overrides)
return outcome
environment = os.environ.copy()
environment.update(overrides)
if stream_output:
outcome = self._run_streamed(
arguments,
cwd=cwd,
environment=environment,
timeout_seconds=timeout_seconds,
started=started,
output_line_filter=output_line_filter,
)
else:
try:
completed = subprocess.run(
arguments,
cwd=str(cwd) if cwd is not None else None,
env=environment,
text=True,
encoding=SUBPROCESS_ENCODING,
errors=SUBPROCESS_DECODE_ERRORS,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
)
outcome = CommandOutcome(
argv=arguments,
returncode=completed.returncode,
stdout=_bounded_text(completed.stdout),
stderr=_bounded_text(completed.stderr),
duration_seconds=time.monotonic() - started,
timed_out=False,
dry_run=False,
)
except subprocess.TimeoutExpired as exc:
outcome = CommandOutcome(
argv=arguments,
returncode=None,
stdout=_bounded_text(_as_text(exc.stdout)),
stderr=_bounded_text(_as_text(exc.stderr)),
duration_seconds=time.monotonic() - started,
timed_out=True,
dry_run=False,
)
self._record(outcome, cwd, overrides)
if check and (outcome.timed_out or outcome.returncode != 0):
raise CommandError(outcome)
return outcome
@staticmethod
def _run_streamed(
arguments: list[str],
*,
cwd: Path | None,
environment: Mapping[str, str],
timeout_seconds: float,
started: float,
output_line_filter: OutputLineFilter | None,
) -> CommandOutcome:
process = subprocess.Popen(
arguments,
cwd=str(cwd) if cwd is not None else None,
env=dict(environment),
text=True,
encoding=SUBPROCESS_ENCODING,
errors=SUBPROCESS_DECODE_ERRORS,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
start_new_session=True,
)
stdout = BoundedTextCapture()
stderr = BoundedTextCapture()
def relay(
stream: TextIO,
capture: BoundedTextCapture,
destination: TextIO,
) -> None:
try:
while True:
line = stream.readline()
if not line:
return
if output_line_filter is not None:
line = output_line_filter(line)
if line is None:
continue
capture.append(line)
destination.write(line)
destination.flush()
except (OSError, ValueError):
# Shutdown can close a pipe while its relay thread is leaving readline().
return
assert process.stdout is not None
assert process.stderr is not None
stdout_thread = threading.Thread(
target=relay,
args=(process.stdout, stdout, sys.stdout),
daemon=True,
)
stderr_thread = threading.Thread(
target=relay,
args=(process.stderr, stderr, sys.stderr),
daemon=True,
)
stdout_thread.start()
stderr_thread.start()
timed_out = False
try:
returncode = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
timed_out = True
returncode = _terminate_process_group(process)
except BaseException:
_terminate_process_group(process)
_finish_stream_relays(process, stdout_thread, stderr_thread)
raise
_finish_stream_relays(process, stdout_thread, stderr_thread)
return CommandOutcome(
argv=arguments,
returncode=None if timed_out else returncode,
stdout=stdout.getvalue(),
stderr=stderr.getvalue(),
duration_seconds=time.monotonic() - started,
timed_out=timed_out,
dry_run=False,
)
def _record(
self,
outcome: CommandOutcome,
cwd: Path | None,
env_overrides: Mapping[str, str],
) -> None:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
entry = {
"timestamp": datetime.now().astimezone().isoformat(),
"cwd": str(cwd) if cwd is not None else None,
"env_overrides": dict(sorted(env_overrides.items())),
**asdict(outcome),
}
with self.log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, sort_keys=True) + "\n")
def _as_text(value: bytes | str | None) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value
def _bounded_text(value: str) -> str:
return value[-MAX_CAPTURE_CHARACTERS:]
def _terminate_process_group(
process: subprocess.Popen[str],
*,
grace_seconds: float = 5.0,
) -> int | None:
"""Stop the complete child session, allowing Proton a short clean exit."""
if process.poll() is not None:
return process.returncode
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return process.poll()
try:
return process.wait(timeout=grace_seconds)
except subprocess.TimeoutExpired:
pass
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
return process.poll()
try:
return process.wait(timeout=grace_seconds)
except subprocess.TimeoutExpired:
return process.poll()
def _finish_stream_relays(
process: subprocess.Popen[str],
stdout_thread: threading.Thread,
stderr_thread: threading.Thread,
) -> None:
"""Bound relay cleanup so a second interrupt can never be required."""
stdout_thread.join(timeout=2.0)
stderr_thread.join(timeout=2.0)
for stream in (process.stdout, process.stderr):
if stream is not None:
try:
stream.close()
except OSError:
pass
stdout_thread.join(timeout=0.25)
stderr_thread.join(timeout=0.25)