"""Inspectable subprocess execution for installer operations.""" from __future__ import annotations 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] @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, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_seconds, check=False, ) outcome = CommandOutcome( argv=arguments, returncode=completed.returncode, stdout=completed.stdout, stderr=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=_as_text(exc.stdout), stderr=_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, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=1, start_new_session=True, ) stdout: list[str] = [] stderr: list[str] = [] def relay( stream: TextIO, capture: list[str], 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="".join(stdout), stderr="".join(stderr), 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 _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)