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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user