Files
Jackify/jackify/backend/handlers/subprocess_utils.py

314 lines
11 KiB
Python

import os
import signal
import subprocess
import time
import resource
import sys
import shutil
import logging
import threading
logger = logging.getLogger(__name__)
def suspend_baloo() -> bool:
"""Suspend KDE Baloo file indexer. Safe to call on non-KDE or headless systems."""
if not shutil.which("balooctl"):
return False
try:
subprocess.run(["balooctl", "suspend"], capture_output=True, timeout=5)
logger.debug("Baloo file indexer suspended")
return True
except Exception:
return False
def resume_baloo() -> None:
"""Resume KDE Baloo file indexer. No-op if balooctl is not present."""
if not shutil.which("balooctl"):
return
try:
subprocess.run(["balooctl", "resume"], capture_output=True, timeout=5)
logger.debug("Baloo file indexer resumed")
except Exception:
pass
def get_safe_python_executable():
"""
Get a safe Python executable for subprocess calls.
When running as AppImage, returns system Python instead of AppImage path
to prevent recursive AppImage spawning.
Returns:
str: Path to Python executable safe for subprocess calls
"""
# Check if we're running as AppImage
is_appimage = (
'APPIMAGE' in os.environ or
'APPDIR' in os.environ or
(sys.argv[0] and sys.argv[0].endswith('.AppImage'))
)
if is_appimage:
# Running as AppImage - use system Python to avoid recursive spawning
# Try to find system Python (same logic as AppRun)
for cmd in ['python3', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3.8']:
python_path = shutil.which(cmd)
if python_path:
return python_path
# Fallback: if we can't find system Python, this is a problem
# But we'll still return sys.executable as last resort
return sys.executable
else:
# Not AppImage - sys.executable is safe
return sys.executable
def get_clean_subprocess_env(extra_env=None):
"""
Returns a copy of os.environ with bundled-runtime variables and other problematic entries removed.
Optionally merges in extra_env dict.
Also ensures bundled tools (lz4, cabextract, winetricks) are in PATH when running as AppImage.
CRITICAL: Preserves system PATH to ensure system utilities (wget, curl, unzip, xz, gzip, sha256sum) are available.
"""
from pathlib import Path
env = os.environ.copy()
# Save APPDIR before removing it (we need it to find bundled tools)
appdir = env.get('APPDIR')
# Remove AppImage-specific variables that can confuse subprocess calls
# These variables cause subprocesses to be interpreted as new AppImage launches
for key in ['APPIMAGE', 'APPDIR', 'ARGV0', 'OWD']:
env.pop(key, None)
# Remove bundle-specific variables
for k in list(env):
if k.startswith('_MEIPASS'):
del env[k]
# Get current PATH - ensure we preserve system paths
current_path = env.get('PATH', '')
# Ensure common system directories are in PATH if not already present
# Critical for tools in /usr/bin, /usr/local/bin, etc.
system_paths = ['/usr/bin', '/usr/local/bin', '/bin', '/sbin', '/usr/sbin']
path_parts = current_path.split(':') if current_path else []
for sys_path in system_paths:
if sys_path not in path_parts and os.path.isdir(sys_path):
path_parts.append(sys_path)
# Add bundled tools directory to PATH if running as AppImage
# cabextract and winetricks must be available to subprocesses
# System utilities (wget, curl, unzip, xz, gzip, sha256sum) come from system PATH
# appdir saved before env cleanup above
# lz4 was only needed for TTW installer, no longer bundled
tools_dir = None
if appdir:
# Running as AppImage - use APPDIR
tools_dir = os.path.join(appdir, 'opt', 'jackify', 'tools')
logger = logging.getLogger(__name__)
if not os.path.isdir(tools_dir):
logger.debug(f"Tools directory not found: {tools_dir}")
tools_dir = None
else:
# Tools directory exists - add it to PATH for cabextract, winetricks, etc.
logger.debug(f"Found bundled tools directory at: {tools_dir}")
else:
logging.getLogger(__name__).debug("APPDIR not set - not running as AppImage, skipping bundled tools")
# Build final PATH: system PATH first, then bundled tools (lz4, cabextract, winetricks)
# System utilities (wget, curl, unzip, xz, gzip, sha256sum) are preferred from system
final_path_parts = []
# Add all other paths first (system utilities take precedence)
seen = set()
for path_part in path_parts:
if path_part and path_part not in seen:
final_path_parts.append(path_part)
seen.add(path_part)
# Then add bundled tools directory (for cabextract, winetricks, etc.)
if tools_dir and os.path.isdir(tools_dir) and tools_dir not in seen:
final_path_parts.append(tools_dir)
seen.add(tools_dir)
env['PATH'] = ':'.join(final_path_parts)
# Optionally restore LD_LIBRARY_PATH to system default if needed
# (You can add more logic here if you know your system's default)
if extra_env:
env.update(extra_env)
return env
def increase_file_descriptor_limit(target_limit=1048576):
"""
Temporarily increase the file descriptor limit for the current process.
Args:
target_limit (int): Desired file descriptor limit (default: 1048576)
Returns:
tuple: (success: bool, old_limit: int, new_limit: int, message: str)
"""
try:
# Get current soft and hard limits
soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
# Don't decrease the limit if it's already higher
if soft_limit >= target_limit:
return True, soft_limit, soft_limit, f"Current limit ({soft_limit}) already sufficient"
# Set new limit (can't exceed hard limit)
new_limit = min(target_limit, hard_limit)
resource.setrlimit(resource.RLIMIT_NOFILE, (new_limit, hard_limit))
return True, soft_limit, new_limit, f"Increased file descriptor limit from {soft_limit} to {new_limit}"
except (OSError, ValueError) as e:
# Get current limit for reporting
try:
soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
except (OSError, ValueError):
soft_limit = "unknown"
return False, soft_limit, soft_limit, f"Failed to increase file descriptor limit: {e}"
class ProcessManager:
"""
Shared process manager for robust subprocess launching, tracking, and cancellation.
"""
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False):
self.cmd = cmd
# Default to cleaned environment if None to prevent AppImage variable inheritance
if env is None:
self.env = get_clean_subprocess_env()
else:
self.env = env
self.cwd = cwd
self.text = text
self.bufsize = bufsize
self.separate_stderr = separate_stderr
self.enable_stdin = enable_stdin
self.proc = None
self.process_group_pid = None
self._stdin_lock = threading.Lock()
self._start_process()
def _start_process(self):
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
stdin_arg = subprocess.PIPE if self.enable_stdin else None
self.proc = subprocess.Popen(
self.cmd,
stdin=stdin_arg,
stdout=subprocess.PIPE,
stderr=stderr_arg,
env=self.env,
cwd=self.cwd,
text=self.text,
bufsize=self.bufsize,
start_new_session=True
)
self.process_group_pid = os.getpgid(self.proc.pid)
def cancel(self, timeout_terminate=2, timeout_kill=1, max_cleanup_attempts=3):
"""
Attempt to robustly terminate the process and its children.
"""
cleanup_attempts = 0
try:
if self.proc:
# Terminate process group first so child tools don't survive parent exit.
if self.process_group_pid:
try:
os.killpg(self.process_group_pid, signal.SIGTERM)
except Exception:
pass
try:
self.proc.terminate()
except Exception:
pass
try:
self.proc.wait(timeout=timeout_terminate)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
# Escalate to SIGKILL for stubborn children/process group.
if self.process_group_pid:
try:
os.killpg(self.process_group_pid, signal.SIGKILL)
except Exception:
pass
try:
self.proc.kill()
except Exception:
pass
try:
self.proc.wait(timeout=timeout_kill)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
# Last resort: pkill by command name (kept bounded).
while cleanup_attempts < max_cleanup_attempts:
try:
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
except Exception:
pass
cleanup_attempts += 1
finally:
# Always close pipes — unblocks threads blocked on read(1) or iterating stderr
if self.proc:
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
if pipe:
try:
pipe.close()
except Exception:
pass
def is_running(self):
return self.proc and self.proc.poll() is None
def wait(self, timeout=None):
if self.proc:
return self.proc.wait(timeout=timeout)
return None
def read_stdout_line(self):
if self.proc and self.proc.stdout:
return self.proc.stdout.readline()
return None
def read_stdout_char(self):
if self.proc and self.proc.stdout:
try:
return self.proc.stdout.read(1)
except (ValueError, OSError):
return None
return None
def write_stdin(self, line: str) -> bool:
"""
Write a line to the process stdin. Thread-safe.
Returns True on success, False if stdin is not available or process is gone.
"""
if not self.enable_stdin or not self.proc or not self.proc.stdin:
return False
with self._stdin_lock:
try:
payload = line if line.endswith('\n') else line + '\n'
self.proc.stdin.write(payload.encode())
self.proc.stdin.flush()
return True
except (OSError, BrokenPipeError):
return False