|
| 1 | +import gc |
1 | 2 | import os |
2 | 3 | import re |
3 | 4 | import shlex # For safely parsing command arguments |
| 5 | +import signal |
4 | 6 | import subprocess # For direct CLI command execution |
| 7 | +import sys |
5 | 8 | import time |
6 | 9 | from pathlib import Path |
7 | 10 | from typing import Optional, List, Set |
@@ -110,26 +113,74 @@ def cleanup_partial_files(self) -> None: |
110 | 113 | # Don't emit error signal for cleanup issues to avoid crashing the thread |
111 | 114 | logger.error(f"Error cleaning partial files: {e}") |
112 | 115 |
|
113 | | - def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 3, delay: float = 1.0) -> None: |
114 | | - """Safely delete a file with retry mechanism for Windows file locking issues""" |
| 116 | + def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 5, delay: float = 2.0) -> None: |
| 117 | + """Safely delete a file with retry mechanism for file locking issues across platforms""" |
115 | 118 | for attempt in range(max_retries): |
116 | 119 | try: |
| 120 | + # Force garbage collection to release any Python-held file handles |
| 121 | + gc.collect() |
| 122 | + |
117 | 123 | if file_path.exists(): |
118 | 124 | file_path.unlink(missing_ok=True) |
119 | 125 | logger.info(f"Successfully deleted {file_path.name}") |
120 | 126 | return |
121 | 127 | except PermissionError as e: |
122 | | - if "being used by another process" in str(e) and attempt < max_retries - 1: |
| 128 | + if attempt < max_retries - 1: |
123 | 129 | logger.warning(f"File {file_path.name} is locked, retrying in {delay} seconds... (attempt {attempt + 1}/{max_retries})") |
124 | 130 | time.sleep(delay) |
125 | | - delay *= 1.5 # Exponential backoff |
| 131 | + delay = min(delay * 1.5, 5.0) # Exponential backoff, capped at 5 seconds |
126 | 132 | else: |
127 | 133 | logger.error(f"Failed to delete {file_path.name} after {max_retries} attempts: {e}") |
128 | 134 | return |
129 | 135 | except Exception as e: |
130 | 136 | logger.error(f"Error deleting {file_path.name}: {e}") |
131 | 137 | return |
132 | 138 |
|
| 139 | + def _terminate_process_tree(self, process: subprocess.Popen) -> None: |
| 140 | + """Terminate a process and all its children across platforms""" |
| 141 | + pid = process.pid |
| 142 | + |
| 143 | + try: |
| 144 | + if sys.platform == "win32": |
| 145 | + # Windows: Use taskkill to kill the entire process tree |
| 146 | + # /T = kill child processes, /F = force kill |
| 147 | + subprocess.run( |
| 148 | + ["taskkill", "/F", "/T", "/PID", str(pid)], |
| 149 | + capture_output=True, |
| 150 | + creationflags=SUBPROCESS_CREATIONFLAGS, |
| 151 | + ) |
| 152 | + logger.debug(f"Killed process tree on Windows (PID: {pid})") |
| 153 | + else: |
| 154 | + # Unix-like systems: Kill the process group |
| 155 | + try: |
| 156 | + # Try to kill the process group |
| 157 | + os.killpg(os.getpgid(pid), signal.SIGTERM) |
| 158 | + time.sleep(0.5) |
| 159 | + # Force kill if still running |
| 160 | + os.killpg(os.getpgid(pid), signal.SIGKILL) |
| 161 | + except (ProcessLookupError, PermissionError): |
| 162 | + # Process already terminated or no permission |
| 163 | + pass |
| 164 | + logger.debug(f"Killed process group on Unix (PID: {pid})") |
| 165 | + except Exception as e: |
| 166 | + logger.warning(f"Error killing process tree: {e}") |
| 167 | + # Fallback to standard termination |
| 168 | + try: |
| 169 | + process.terminate() |
| 170 | + process.wait(timeout=2) |
| 171 | + except Exception: |
| 172 | + try: |
| 173 | + process.kill() |
| 174 | + process.wait() |
| 175 | + except Exception: |
| 176 | + pass |
| 177 | + |
| 178 | + # Ensure process is waited on to avoid zombies |
| 179 | + try: |
| 180 | + process.wait(timeout=3) |
| 181 | + except Exception: |
| 182 | + pass |
| 183 | + |
133 | 184 | def cleanup_subtitle_files(self) -> None: |
134 | 185 | """Delete subtitle files after they have been merged into the video file""" |
135 | 186 | deleted_count: List[int] = [0, 0] |
@@ -332,31 +383,34 @@ def _run_direct_command(self) -> None: |
332 | 383 |
|
333 | 384 | # Start the process |
334 | 385 | # Extra logic moved to src\utils\ytsage_constants.py |
| 386 | + # Use start_new_session on Unix to enable process group termination |
| 387 | + |
| 388 | + popen_kwargs = { |
| 389 | + "stdout": subprocess.PIPE, |
| 390 | + "stderr": subprocess.STDOUT, |
| 391 | + "bufsize": 1, # Line buffered |
| 392 | + "encoding": "utf-8", |
| 393 | + "errors": "replace", |
| 394 | + } |
| 395 | + |
| 396 | + if sys.platform == "win32": |
| 397 | + popen_kwargs["creationflags"] = SUBPROCESS_CREATIONFLAGS |
| 398 | + else: |
| 399 | + # On Unix, start a new session so we can kill the entire process group |
| 400 | + popen_kwargs["start_new_session"] = True |
335 | 401 |
|
336 | | - self.process = subprocess.Popen( |
337 | | - cmd, |
338 | | - stdout=subprocess.PIPE, |
339 | | - stderr=subprocess.STDOUT, |
340 | | - bufsize=1, # Line buffered |
341 | | - encoding="utf-8", |
342 | | - errors="replace", |
343 | | - creationflags=SUBPROCESS_CREATIONFLAGS, |
344 | | - ) |
| 402 | + self.process = subprocess.Popen(cmd, **popen_kwargs) |
345 | 403 |
|
346 | 404 | # Process output line by line to update progress |
347 | 405 | for line in iter(self.process.stdout.readline, ""): # type: ignore |
348 | 406 | if self.cancelled: |
349 | | - self.process.terminate() |
350 | | - # Wait for process to actually terminate before cleaning up files |
351 | | - try: |
352 | | - self.process.wait(timeout=5) # Wait up to 5 seconds |
353 | | - except subprocess.TimeoutExpired: |
354 | | - logger.warning("Process didn't terminate gracefully, forcing kill") |
355 | | - self.process.kill() |
356 | | - self.process.wait() |
| 407 | + # Kill the entire process tree (yt-dlp + ffmpeg children) |
| 408 | + self._terminate_process_tree(self.process) |
357 | 409 |
|
358 | 410 | # Add delay before cleanup to allow file handles to be released |
359 | | - time.sleep(1) |
| 411 | + # Force garbage collection to help release resources |
| 412 | + gc.collect() |
| 413 | + time.sleep(2) |
360 | 414 | self.cleanup_partial_files() |
361 | 415 | self.status_signal.emit(_("download.cancelled")) |
362 | 416 | return |
|
0 commit comments