Improve process termination and file cleanup logic

Enhanced cross-platform process tree termination for downloads, using process groups on Unix and taskkill on Windows. Improved file deletion reliability by increasing retries, adding garbage collection, and adjusting backoff logic. Refactored process startup to support process group management and ensured proper resource cleanup after cancellation.
This commit is contained in:
oop7
2025-11-29 14:09:05 +02:00
parent 9d1f886305
commit 478daf4771
+76 -22
View File
@@ -1,7 +1,10 @@
import gc
import os import os
import re import re
import shlex # For safely parsing command arguments import shlex # For safely parsing command arguments
import signal
import subprocess # For direct CLI command execution import subprocess # For direct CLI command execution
import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional, List, Set from typing import Optional, List, Set
@@ -110,19 +113,22 @@ class DownloadThread(QThread):
# Don't emit error signal for cleanup issues to avoid crashing the thread # Don't emit error signal for cleanup issues to avoid crashing the thread
logger.error(f"Error cleaning partial files: {e}") logger.error(f"Error cleaning partial files: {e}")
def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 3, delay: float = 1.0) -> None: def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 5, delay: float = 2.0) -> None:
"""Safely delete a file with retry mechanism for Windows file locking issues""" """Safely delete a file with retry mechanism for file locking issues across platforms"""
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
# Force garbage collection to release any Python-held file handles
gc.collect()
if file_path.exists(): if file_path.exists():
file_path.unlink(missing_ok=True) file_path.unlink(missing_ok=True)
logger.info(f"Successfully deleted {file_path.name}") logger.info(f"Successfully deleted {file_path.name}")
return return
except PermissionError as e: except PermissionError as e:
if "being used by another process" in str(e) and attempt < max_retries - 1: if attempt < max_retries - 1:
logger.warning(f"File {file_path.name} is locked, retrying in {delay} seconds... (attempt {attempt + 1}/{max_retries})") logger.warning(f"File {file_path.name} is locked, retrying in {delay} seconds... (attempt {attempt + 1}/{max_retries})")
time.sleep(delay) time.sleep(delay)
delay *= 1.5 # Exponential backoff delay = min(delay * 1.5, 5.0) # Exponential backoff, capped at 5 seconds
else: else:
logger.error(f"Failed to delete {file_path.name} after {max_retries} attempts: {e}") logger.error(f"Failed to delete {file_path.name} after {max_retries} attempts: {e}")
return return
@@ -130,6 +136,51 @@ class DownloadThread(QThread):
logger.error(f"Error deleting {file_path.name}: {e}") logger.error(f"Error deleting {file_path.name}: {e}")
return return
def _terminate_process_tree(self, process: subprocess.Popen) -> None:
"""Terminate a process and all its children across platforms"""
pid = process.pid
try:
if sys.platform == "win32":
# Windows: Use taskkill to kill the entire process tree
# /T = kill child processes, /F = force kill
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)],
capture_output=True,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
logger.debug(f"Killed process tree on Windows (PID: {pid})")
else:
# Unix-like systems: Kill the process group
try:
# Try to kill the process group
os.killpg(os.getpgid(pid), signal.SIGTERM)
time.sleep(0.5)
# Force kill if still running
os.killpg(os.getpgid(pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
# Process already terminated or no permission
pass
logger.debug(f"Killed process group on Unix (PID: {pid})")
except Exception as e:
logger.warning(f"Error killing process tree: {e}")
# Fallback to standard termination
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
process.wait()
except Exception:
pass
# Ensure process is waited on to avoid zombies
try:
process.wait(timeout=3)
except Exception:
pass
def cleanup_subtitle_files(self) -> None: def cleanup_subtitle_files(self) -> None:
"""Delete subtitle files after they have been merged into the video file""" """Delete subtitle files after they have been merged into the video file"""
deleted_count: List[int] = [0, 0] deleted_count: List[int] = [0, 0]
@@ -332,31 +383,34 @@ class DownloadThread(QThread):
# Start the process # Start the process
# Extra logic moved to src\utils\ytsage_constants.py # Extra logic moved to src\utils\ytsage_constants.py
# Use start_new_session on Unix to enable process group termination
self.process = subprocess.Popen( popen_kwargs = {
cmd, "stdout": subprocess.PIPE,
stdout=subprocess.PIPE, "stderr": subprocess.STDOUT,
stderr=subprocess.STDOUT, "bufsize": 1, # Line buffered
bufsize=1, # Line buffered "encoding": "utf-8",
encoding="utf-8", "errors": "replace",
errors="replace", }
creationflags=SUBPROCESS_CREATIONFLAGS,
) if sys.platform == "win32":
popen_kwargs["creationflags"] = SUBPROCESS_CREATIONFLAGS
else:
# On Unix, start a new session so we can kill the entire process group
popen_kwargs["start_new_session"] = True
self.process = subprocess.Popen(cmd, **popen_kwargs)
# Process output line by line to update progress # Process output line by line to update progress
for line in iter(self.process.stdout.readline, ""): # type: ignore for line in iter(self.process.stdout.readline, ""): # type: ignore
if self.cancelled: if self.cancelled:
self.process.terminate() # Kill the entire process tree (yt-dlp + ffmpeg children)
# Wait for process to actually terminate before cleaning up files self._terminate_process_tree(self.process)
try:
self.process.wait(timeout=5) # Wait up to 5 seconds
except subprocess.TimeoutExpired:
logger.warning("Process didn't terminate gracefully, forcing kill")
self.process.kill()
self.process.wait()
# Add delay before cleanup to allow file handles to be released # Add delay before cleanup to allow file handles to be released
time.sleep(1) # Force garbage collection to help release resources
gc.collect()
time.sleep(2)
self.cleanup_partial_files() self.cleanup_partial_files()
self.status_signal.emit(_("download.cancelled")) self.status_signal.emit(_("download.cancelled"))
return return