ytptube/app/library/downloads/process_manager.py
2026-01-13 20:05:01 +03:00

184 lines
5.7 KiB
Python

"""Process lifecycle management for downloads."""
import logging
import multiprocessing
import os
import signal
from collections.abc import Callable
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.proc: multiprocessing.Process | None = None
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.proc = multiprocessing.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 via SIGUSR1 signal first (POSIX only),
then escalates to terminate() and finally kill() if needed.
Live streams get shorter timeouts as they're more likely to be unresponsive.
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(f"Killing download process: PID={self.proc.pid}, ident={procId}.")
if self.proc.pid and "posix" == os.name:
try:
self.logger.debug(f"Sending SIGUSR1 signal to PID={self.proc.pid}.")
os.kill(self.proc.pid, signal.SIGUSR1)
if wait_for_process_with_timeout(self.proc, 2 if self.is_live else 5):
self.logger.debug(f"Process PID={self.proc.pid} terminated gracefully.")
return True
self.logger.warning(
f"Process PID={self.proc.pid} did not respond to SIGUSR1 "
f"({'live stream' if self.is_live else 'regular download'}), "
f"forcing termination."
)
except (OSError, AttributeError) as e:
self.logger.debug(f"Failed to send SIGUSR1 signal: {e}")
if self.proc.is_alive():
self.logger.info(f"Force-terminating process PID={self.proc.pid}.")
self.proc.terminate()
if not wait_for_process_with_timeout(self.proc, 1 if self.is_live else 2):
self.logger.warning(f"Process PID={self.proc.pid} did not respond, killing forcefully.")
self.proc.kill()
wait_for_process_with_timeout(self.proc, 1)
self.logger.info(f"Process PID={self.proc.pid} killed.")
return True
except Exception as e:
self.logger.error(f"Failed to kill process PID={self.proc.pid}, ident={procId}. {e}")
self.logger.exception(e)
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(f"Closing PID='{procId}' download process.")
try:
self.kill()
import asyncio
loop = asyncio.get_running_loop()
if self.proc.is_alive():
self.logger.debug(f"Waiting for PID='{procId}' to close.")
await loop.run_in_executor(None, self.proc.join)
self.logger.debug(f"PID='{procId}' closed.")
if self.proc:
self.proc.close()
self.proc = None
self.logger.debug(f"Closed PID='{procId}' download process.")
return True
except Exception as e:
self.logger.error(f"Failed to close process: '{procId}'. {e}")
self.logger.exception(e)
return False