"""Process lifecycle management for downloads.""" import logging import multiprocessing import os import signal from collections.abc import Callable from app.library.config import Config from .utils import wait_for_process_with_timeout class ProcessManager: """ Manages the lifecycle of a download subprocess. Handles process creation, monitoring, graceful termination, and force-kill operations. Includes special handling for live streams which may not respond to standard termination signals. """ def __init__(self, download_id: str, is_live: bool, logger: logging.Logger): """ Initialize process manager. Args: download_id: Unique identifier for this download is_live: Whether this is a live stream download logger: Logger instance """ self.download_id = download_id self.is_live = is_live self.logger = logger self._context = multiprocessing.get_context() self.proc: multiprocessing.Process | None = None self.cancel_event = Config.get_manager().Event() self.cancelled = False self.cancel_in_progress = False def create_process(self, target: Callable[[], None]) -> multiprocessing.Process: """ Create a new download process. Args: target: Function to execute in the subprocess Returns: Created but not started process """ self.cancel_event.clear() self.proc = self._context.Process(name=f"download-{self.download_id}", target=target) return self.proc def start(self) -> None: if self.proc: self.proc.start() def started(self) -> bool: return self.proc is not None def running(self) -> bool: try: return self.proc is not None and self.proc.is_alive() except ValueError: return False def is_cancelled(self) -> bool: return self.cancelled def cancel(self) -> bool: """ Mark download as cancelled and kill the process. Returns: True if cancellation was initiated, False otherwise """ if not self.started(): return False self.cancelled = True return self.kill() def kill(self) -> bool: """ Kill the download process. Attempts graceful termination first, then escalates to terminate() and finally kill() if needed. Live streams use a shared cancel event so the worker can raise SIGINT inside itself and let yt-dlp finalize cleanly. Returns: True if process was killed successfully, False otherwise """ if not self.running(): return False procId: int | None = self.proc.ident try: self.logger.info( "Stopping download process PID=%s.", self.proc.pid, extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "process_ident": procId, "is_live": self.is_live, } }, ) if self.is_live: self.logger.debug( "Requesting graceful live cancellation for PID=%s.", self.proc.pid, extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "is_live": self.is_live, } }, ) self.cancel_event.set() if wait_for_process_with_timeout(self.proc, 10): self.logger.debug( "Download process PID=%s stopped gracefully.", self.proc.pid, extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "is_live": self.is_live, } }, ) return True self.logger.warning( "Download process PID=%s did not respond to live cancellation; forcing termination.", self.proc.pid, extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "is_live": self.is_live, "force": True, } }, ) elif self.proc.pid and "posix" == os.name: signal_name = "SIGUSR1" try: self.logger.debug( "Sending %s signal to download process PID=%s.", signal_name, self.proc.pid, extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "signal": signal_name, } }, ) os.kill(self.proc.pid, signal.SIGUSR1) if wait_for_process_with_timeout(self.proc, 5): self.logger.debug( "Download process PID=%s stopped gracefully.", self.proc.pid, extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "signal": signal_name, } }, ) return True self.logger.warning( "Download process PID=%s did not respond to %s; forcing termination.", self.proc.pid, signal_name, extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "signal": signal_name, "force": True, } }, ) except (OSError, AttributeError) as e: self.logger.debug( "Failed to send %s signal to download process PID=%s. %s", signal_name, self.proc.pid, e, extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "signal": signal_name, "exception_type": type(e).__name__, } }, ) if self.proc.is_alive(): self.logger.info( "Terminating download process PID=%s.", self.proc.pid, extra={"download": {"download_id": self.download_id, "process_id": self.proc.pid, "force": True}}, ) self.proc.terminate() if not wait_for_process_with_timeout(self.proc, 1 if self.is_live else 2): self.logger.warning( "Download process PID=%s did not terminate; killing forcefully.", self.proc.pid, extra={ "download": {"download_id": self.download_id, "process_id": self.proc.pid, "force": True} }, ) self.proc.kill() wait_for_process_with_timeout(self.proc, 1) self.logger.info( "Download process PID=%s stopped.", self.proc.pid, extra={"download": {"download_id": self.download_id, "process_id": self.proc.pid}}, ) return True except Exception as e: self.logger.exception( f"Failed to stop download process PID={self.proc.pid}, ident={procId}.", extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid, "process_ident": procId, "is_live": self.is_live, "exception_type": type(e).__name__, } }, ) return False async def close(self) -> bool: """ Close the download process and clean up resources. This method must be called to properly release resources. Returns: True if close was successful, False otherwise """ if not self.started() or self.cancel_in_progress: return False self.cancel_in_progress = True procId: int | None = self.proc.ident if not procId: if self.proc: self.proc.close() self.proc = None self.logger.warning("Attempted to close download process, but it is not running.") return False self.logger.info( "Closing download process PID='%s'.", procId, extra={"download": {"download_id": self.download_id, "process_ident": procId, "is_live": self.is_live}}, ) try: self.kill() import asyncio loop = asyncio.get_running_loop() if self.proc.is_alive(): self.logger.debug( "Waiting for download process PID='%s' to close.", procId, extra={"download": {"download_id": self.download_id, "process_ident": procId}}, ) await loop.run_in_executor(None, self.proc.join) self.logger.debug( "Download process PID='%s' closed.", procId, extra={"download": {"download_id": self.download_id, "process_ident": procId}}, ) if self.proc: self.proc.close() self.proc = None self.logger.debug( "Closed download process PID='%s'.", procId, extra={"download": {"download_id": self.download_id, "process_ident": procId}}, ) return True except Exception as e: self.logger.exception( f"Failed to close download process PID='{procId}'.", extra={ "download": { "download_id": self.download_id, "process_id": self.proc.pid if self.proc else None, "process_ident": procId, "is_live": self.is_live, "exception_type": type(e).__name__, } }, ) return False