"""Bounded child output in memory; never spool inventory to temporary files.""" import subprocess import threading import time class CollectionCancelled(Exception): pass class ProcessFailure(RuntimeError): def __init__(self, code, returncode=None): self.code = code self.returncode = returncode super().__init__('Inventory subprocess failed (' + code + '); diagnostics omitted.') def capture(arguments, *, env, timeout, cancelled=lambda: False, limit=10*1024*1024, creationflags=0): if cancelled(): raise CollectionCancelled() try: child = subprocess.Popen(arguments, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=False, env=env, creationflags=creationflags) except OSError: raise ProcessFailure('command_unavailable') from None buffer = bytearray() exceeded = threading.Event() read_failed = threading.Event() def drain(): try: while chunk := child.stdout.read(65536): if len(buffer)+len(chunk)>limit: exceeded.set() break buffer.extend(chunk) except (OSError, ValueError): read_failed.set() reader = threading.Thread(target=drain, daemon=True) reader.start() deadline = time.monotonic()+timeout try: while child.poll() is None: if cancelled(): raise CollectionCancelled() if exceeded.is_set(): raise ProcessFailure('output_limit') if time.monotonic()>=deadline: raise ProcessFailure('timeout') time.sleep(.02) reader.join(timeout=1) if reader.is_alive(): raise ProcessFailure('cleanup_failed') if cancelled(): raise CollectionCancelled() if exceeded.is_set(): raise ProcessFailure('output_limit') if read_failed.is_set() or child.returncode: raise ProcessFailure('collection_failed', child.returncode) return bytes(buffer) finally: if child.poll() is None: child.kill() try: child.wait(timeout=3) except subprocess.TimeoutExpired: raise ProcessFailure('cleanup_failed') from None # Fixed collectors must not spawn descendants holding stdout open. # Do not block on closing a buffered stream owned by a stalled reader. reader.join(timeout=.2) if not reader.is_alive(): child.stdout.close()