332 lines
8.6 KiB
Python
332 lines
8.6 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,
|
|
pass_fds: Sequence[int] = (),
|
|
) -> CommandOutcome:
|
|
arguments = [str(value) for value in argv]
|
|
if not arguments:
|
|
raise ValueError("command argv cannot be empty")
|
|
inherited_fds = tuple(dict.fromkeys(pass_fds))
|
|
if any(not isinstance(value, int) or value < 0 for value in inherited_fds):
|
|
raise ValueError("inherited file descriptors must be non-negative integers")
|
|
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,
|
|
pass_fds=inherited_fds,
|
|
)
|
|
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,
|
|
pass_fds=inherited_fds,
|
|
)
|
|
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,
|
|
pass_fds: tuple[int, ...],
|
|
) -> 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,
|
|
pass_fds=pass_fds,
|
|
)
|
|
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)
|