Skip to content

Commit 478daf4

Browse files
committed
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.
1 parent 9d1f886 commit 478daf4

File tree

1 file changed

+76
-22
lines changed

1 file changed

+76
-22
lines changed

src/core/ytsage_downloader.py

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import gc
12
import os
23
import re
34
import shlex # For safely parsing command arguments
5+
import signal
46
import subprocess # For direct CLI command execution
7+
import sys
58
import time
69
from pathlib import Path
710
from typing import Optional, List, Set
@@ -110,26 +113,74 @@ def cleanup_partial_files(self) -> None:
110113
# Don't emit error signal for cleanup issues to avoid crashing the thread
111114
logger.error(f"Error cleaning partial files: {e}")
112115

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"""
115118
for attempt in range(max_retries):
116119
try:
120+
# Force garbage collection to release any Python-held file handles
121+
gc.collect()
122+
117123
if file_path.exists():
118124
file_path.unlink(missing_ok=True)
119125
logger.info(f"Successfully deleted {file_path.name}")
120126
return
121127
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:
123129
logger.warning(f"File {file_path.name} is locked, retrying in {delay} seconds... (attempt {attempt + 1}/{max_retries})")
124130
time.sleep(delay)
125-
delay *= 1.5 # Exponential backoff
131+
delay = min(delay * 1.5, 5.0) # Exponential backoff, capped at 5 seconds
126132
else:
127133
logger.error(f"Failed to delete {file_path.name} after {max_retries} attempts: {e}")
128134
return
129135
except Exception as e:
130136
logger.error(f"Error deleting {file_path.name}: {e}")
131137
return
132138

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+
133184
def cleanup_subtitle_files(self) -> None:
134185
"""Delete subtitle files after they have been merged into the video file"""
135186
deleted_count: List[int] = [0, 0]
@@ -332,31 +383,34 @@ def _run_direct_command(self) -> None:
332383

333384
# Start the process
334385
# 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
335401

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)
345403

346404
# Process output line by line to update progress
347405
for line in iter(self.process.stdout.readline, ""): # type: ignore
348406
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)
357409

358410
# 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)
360414
self.cleanup_partial_files()
361415
self.status_signal.emit(_("download.cancelled"))
362416
return

0 commit comments

Comments
 (0)