from datetime import datetime import subprocess import xml.etree.ElementTree as ET from typing import Optional from .entry import Entry from .util import escape_text_for_xml class BackgroundEntry(Entry): """ Entry type for long-running background processes. Attributes: script: The script/command to execute stdout: Captured standard output since last update stderr: Captured standard error since last update process: The running subprocess.Popen instance _accumulated_stdout: Total stdout collected so far _accumulated_stderr: Total stderr collected so far _exit_code: Exit code when process completes """ def __init__(self, script: str, id: str, timestamp: datetime): """ Initialize a new background entry. Args: script: The script/command to execute id: Unique identifier for this entry timestamp: Creation timestamp for this entry """ super().__init__(id, timestamp) self.script = script self.stdout = "" self.stderr = "" self.process: Optional[subprocess.Popen] = None self._accumulated_stdout = "" self._accumulated_stderr = "" self._exit_code: Optional[int] = None def cleanup(self) -> None: """ Clean up the background process if it's still running. Ensures process is terminated and file handles are closed. """ self._cleanup_process() def _cleanup_process(self): """Clean up process and file handles""" if self.process is not None: try: # Close file handles if they're open if self.process.stdout: self.process.stdout.close() if self.process.stderr: self.process.stderr.close() # Terminate process if it's still running if self.process.poll() is None: self.process.terminate() try: self.process.wait(timeout=1.0) except subprocess.TimeoutExpired: self.process.kill() self.process.wait() except: pass # Ignore cleanup errors finally: self.process = None def update(self) -> None: """ Start the process if not running and collect any new output. Updates stdout and stderr with any new output since last update. """ try: # Reset current output buffers self.stdout = "" self.stderr = "" # Start process if not running if self.process is None and self._exit_code is None: self.process = subprocess.Popen( self.script, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1 # Line buffered ) return # Return after starting to allow process to generate output if self.process is None: return # Process already completed # Check if process has finished exit_code = self.process.poll() if exit_code is not None: # Process has terminated, collect remaining output try: remaining_out, remaining_err = self.process.communicate(timeout=0.1) self.stdout = remaining_out self.stderr = remaining_err self._accumulated_stdout += remaining_out self._accumulated_stderr += remaining_err except subprocess.TimeoutExpired: pass # Process didn't finish communicating, try again next update self._exit_code = exit_code self._cleanup_process() return # Read stdout if available if self.process.stdout: while True: try: line = self.process.stdout.readline() if not line: break self.stdout += line self._accumulated_stdout += line except: break # Read stderr if available if self.process.stderr: while True: try: line = self.process.stderr.readline() if not line: break self.stderr += line self._accumulated_stderr += line except: break except Exception as e: # Handle any errors error_msg = f"Error handling background process: {str(e)}" self.stderr = error_msg self._accumulated_stderr += f"\n{error_msg}" self._exit_code = -1 self._cleanup_process() def generate_context(self) -> ET.Element: """ Generate an XML Element representing this background entry. Returns: ET.Element: XML element containing the entry's data """ # Create root element with appropriate status element = ET.Element("background", {"id": self.id}) if self.process is not None: element.set("pid", str(self.process.pid)) elif self._exit_code is not None: element.set("exit_code", str(self._exit_code)) # Add script as CDATA or escaped text element.text = escape_text_for_xml(self.script) # Add stdout element with accumulated output stdout_elem = ET.SubElement(element, "stdout") stdout_elem.text = escape_text_for_xml(self._accumulated_stdout) # Add stderr element with accumulated output stderr_elem = ET.SubElement(element, "stderr") stderr_elem.text = escape_text_for_xml(self._accumulated_stderr) return element def __del__(self): """Ensure process is terminated and file handles are closed when entry is destroyed""" self._cleanup_process()