fix(startup): prevent main thread hangs and optimize resource loading
All checks were successful
Code check / Check code (push) Successful in 1m33s

- run start_sh initialization via QTimer.singleShot with timeout
- add timeout protection to load_theme_fonts()
- load Steam/EGS/PortProton games in parallel instead of sequential
- delay game loading until UI is fully initialized
- fix callback chaining to avoid blocking operations
- add proper timeout + error handling for all Steam/EGS network requests
- add timeouts for flatpak subprocess calls
- improve file I/O error handling to avoid UI freeze
- optimize theme font loading:
  - delay font loading via QTimer.singleShot
  - load fonts in batches of 10
  - reduce font load timeout to 3s
  - remove fonts only when switching themes

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-11-28 12:00:00 +05:00
parent 77b025f580
commit 3abaccb1e0
8 changed files with 282 additions and 97 deletions

View File

@@ -34,13 +34,12 @@ def main():
os.environ["PROCESS_LOG"] = "1"
os.environ["START_FROM_STEAM"] = "1"
# Get the PortProton start command
start_sh = get_portproton_start_command()
if start_sh is None:
return
subprocess.run(start_sh + ["cli", "--initial"])
app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__)
@@ -144,6 +143,22 @@ def main():
save_fullscreen_config(False)
window.showNormal()
# Execute the initial PortProton command after the UI is set up
def run_initial_command():
nonlocal start_sh
if start_sh:
try:
subprocess.run(start_sh + ["cli", "--initial"], timeout=10)
except subprocess.TimeoutExpired:
logger.warning("Initial PortProton command timed out")
except Exception as e:
logger.error(f"Error running initial PortProton command: {e}")
else:
logger.warning("PortProton start command not available, skipping initial command")
# Run the initial command after the UI is displayed
QTimer.singleShot(100, run_initial_command)
# --- Cleanup ---
def cleanup_on_exit():
try:

View File

@@ -137,8 +137,14 @@ def save_time_config(detail_level):
def read_file_content(file_path):
"""Reads the content of a file and returns it as a string."""
with open(file_path, encoding="utf-8") as f:
return f.read().strip()
try:
# Add timeout protection for file operations using a simple approach
with open(file_path, encoding="utf-8") as f:
content = f.read().strip()
return content
except Exception as e:
logger.warning(f"Error reading file {file_path}: {e}")
raise # Re-raise the exception to be handled by the caller
def get_portproton_location():
"""Возвращает путь к PortProton каталогу (строку) или None."""
@@ -159,6 +165,8 @@ def get_portproton_location():
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
except (OSError, PermissionError) as e:
logger.warning(f"Failed to read PortProton configuration file: {e}")
except Exception as e:
logger.warning(f"Unexpected error reading PortProton configuration file: {e}")
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_flatpak_dir):
@@ -180,12 +188,17 @@ def get_portproton_start_command():
["flatpak", "list"],
capture_output=True,
text=True,
check=False
check=False,
timeout=10
)
if "ru.linux_gaming.PortProton" in result.stdout:
logger.info("Detected Flatpak installation")
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
except Exception:
except subprocess.TimeoutExpired:
logger.warning("Flatpak list command timed out")
return None
except Exception as e:
logger.warning(f"Error checking flatpak list: {e}")
pass
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")

View File

@@ -577,7 +577,7 @@ def get_egs_game_description_async(
"https://launcher.store.epicgames.com/graphql",
json=search_query,
headers=headers,
timeout=5
timeout=10
)
response.raise_for_status()
data = orjson.loads(response.content)
@@ -597,7 +597,7 @@ def get_egs_game_description_async(
def fetch_legacy_description(url: str) -> str:
"""Fetches description from the legacy API, handling DNS failures."""
try:
response = requests.get(url, headers=headers, timeout=5)
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
data = orjson.loads(response.content)
if not isinstance(data, dict):
@@ -619,6 +619,9 @@ def get_egs_game_description_async(
except requests.exceptions.ConnectionError as e:
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
return ""
except requests.exceptions.Timeout:
logger.warning("Request timeout for legacy API %s", url)
return ""
except requests.RequestException as e:
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
return ""
@@ -670,7 +673,7 @@ def get_egs_game_description_async(
url = "https://graphql.epicgames.com/graphql"
try:
response = requests.post(url, json=search_query, headers=headers, timeout=5)
response = requests.post(url, json=search_query, headers=headers, timeout=10)
response.raise_for_status()
data = orjson.loads(response.content)
if namespace:
@@ -689,6 +692,9 @@ def get_egs_game_description_async(
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
return element.get("description", ""), element.get("productSlug", "")
return "", ""
except requests.exceptions.Timeout:
logger.warning("GraphQL request timeout for %s with locale %s", app_name, locale)
return "", ""
except requests.RequestException as e:
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
return "", ""
@@ -717,6 +723,10 @@ def get_egs_game_description_async(
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
except requests.exceptions.ConnectionError:
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
except requests.exceptions.Timeout:
logger.warning("Legacy API request timed out for %s", app_name)
except Exception as e:
logger.error("Unexpected error fetching legacy API for %s: %s", app_name, str(e))
# Step 3: If still no description and no namespace, try GraphQL with title
if not description and not namespace:

View File

@@ -159,7 +159,8 @@ class InputManager(QObject):
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled:
self.enable_mouse_emulation()
# Initialize mouse emulation asynchronously to avoid blocking startup
QTimer.singleShot(0, self._async_enable_mouse_emulation)
# FileExplorer specific attributes
self.file_explorer = None
@@ -197,6 +198,10 @@ class InputManager(QObject):
# Initialize evdev + hotplug
self.init_gamepad()
def _async_enable_mouse_emulation(self):
"""Asynchronously enable mouse emulation to avoid blocking startup."""
self.enable_mouse_emulation()
def _update_emulation_flag(self):
"""Update emulation_active flag based on Qt app focus (main thread only)."""
active = QApplication.activeWindow()

View File

@@ -60,6 +60,7 @@ class MainWindow(QMainWindow):
self.is_exiting = False
selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme
# Apply theme but defer heavy font loading
self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size()
@@ -132,6 +133,13 @@ class MainWindow(QMainWindow):
self.update_progress.connect(self.progress_bar.setValue)
self.update_status_message.connect(self.statusBar().showMessage)
# Show immediate startup progress to indicate the app is loading
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # Indeterminate initially
self.progress_bar.setValue(0) # Reset value
self.update_status_message.emit(_("Starting PortProton..."), 0)
QApplication.processEvents() # Process to show progress bar immediately
self.installing = False
self.current_install_script = None
self.install_process = None
@@ -219,7 +227,6 @@ class MainWindow(QMainWindow):
self.keyboard = VirtualKeyboard(self, self.theme)
self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames)
if read_fullscreen_config():
self.showFullScreen()
@@ -230,6 +237,14 @@ class MainWindow(QMainWindow):
else:
self.showNormal()
# Process events to ensure UI is responsive before starting heavy operations
QApplication.processEvents()
# Delay game loading until after the UI is fully displayed to prevent blocking
# Use a longer delay to ensure window is fully rendered and responsive
# Use a custom event processing approach to make sure UI stays responsive
QTimer.singleShot(500, self.loadGames) # Reduced delay but ensure UI gets event processing
def on_slider_released(self) -> None:
"""Delegate to game library manager."""
if hasattr(self, 'game_library_manager'):
@@ -563,6 +578,8 @@ class MainWindow(QMainWindow):
def on_games_loaded(self, games: list[tuple]):
self.game_library_manager.set_games(games)
self.progress_bar.setVisible(False)
self.progress_bar.setRange(0, 100) # Reset to determinate state for next use
self.progress_bar.setValue(0)
# Clear the refresh in progress flag
if hasattr(self, '_refresh_in_progress'):
@@ -589,58 +606,128 @@ class MainWindow(QMainWindow):
favorites = read_favorites()
self.pending_games = []
self.games = []
# Show initial progress bar and status message immediately
self.progress_bar.setRange(0, 100) # Set to determinate range
self.progress_bar.setValue(0)
self.progress_bar.setVisible(True)
if display_filter == "steam":
self._load_steam_games_async(lambda games: self.games_loaded.emit(games))
elif display_filter == "portproton":
self._load_portproton_games_async(lambda games: self.games_loaded.emit(games))
elif display_filter == "epic":
load_egs_games_async(
self.legendary_path,
lambda games: self.games_loaded.emit(games),
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
elif display_filter == "favorites":
def on_all_games(portproton_games, steam_games, epic_games):
games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites]
self.games_loaded.emit(games)
self._load_portproton_games_async(
lambda pg: self._load_steam_games_async(
lambda sg: load_egs_games_async(
self.legendary_path,
lambda eg: on_all_games(pg, sg, eg),
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
self.update_status_message.emit(_("Loading games..."), 0)
# Process events to keep UI responsive
QApplication.processEvents()
def start_loading():
# Make sure progress bar is still visible
self.progress_bar.setValue(0)
self.progress_bar.setVisible(True)
QApplication.processEvents() # Allow UI to update
if display_filter == "steam":
self._load_steam_games_async(lambda games: self.games_loaded.emit(games))
elif display_filter == "portproton":
self._load_portproton_games_async(lambda games: self.games_loaded.emit(games))
elif display_filter == "epic":
load_egs_games_async(
self.legendary_path,
lambda games: self.games_loaded.emit(games),
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
)
else:
def on_all_games(portproton_games, steam_games, epic_games):
seen = set()
games = []
for game in portproton_games + steam_games + epic_games:
# Уникальный ключ: имя + exec_line
key = (game[0], game[4])
if key not in seen:
seen.add(key)
games.append(game)
self.games_loaded.emit(games)
self._load_portproton_games_async(
lambda pg: self._load_steam_games_async(
lambda sg: load_egs_games_async(
self.legendary_path,
lambda eg: on_all_games(pg, sg, eg),
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
elif display_filter == "favorites":
def on_all_games_favorites(portproton_games, steam_games, epic_games):
games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites]
self.games_loaded.emit(games)
# Load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'epic': []}
completed = {'portproton': False, 'steam': False, 'epic': False}
def check_completion():
if all(completed.values()):
QApplication.processEvents() # Keep UI responsive
on_all_games_favorites(results['portproton'], results['steam'], results['epic'])
def portproton_callback(games):
results['portproton'] = games
completed['portproton'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def steam_callback(games):
results['steam'] = games
completed['steam'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def epic_callback(games):
results['epic'] = games
completed['epic'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
load_egs_games_async(
self.legendary_path,
epic_callback,
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
)
return []
else:
# For 'all' filter - load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'epic': []}
completed = {'portproton': False, 'steam': False, 'epic': False}
def on_all_games():
seen = set()
games = []
for game in results['portproton'] + results['steam'] + results['epic']:
# Уникальный ключ: имя + exec_line
key = (game[0], game[4])
if key not in seen:
seen.add(key)
games.append(game)
QApplication.processEvents() # Keep UI responsive
self.games_loaded.emit(games)
def check_completion():
if all(completed.values()):
QApplication.processEvents() # Keep UI responsive
on_all_games()
def portproton_callback(games):
results['portproton'] = games
completed['portproton'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def steam_callback(games):
results['steam'] = games
completed['steam'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def epic_callback(games):
results['epic'] = games
completed['epic'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
# Load all sources in parallel
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
load_egs_games_async(
self.legendary_path,
epic_callback,
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
# Run loading with minimal delay to allow UI to be responsive
QTimer.singleShot(100, start_loading) # Reduced to 100ms
def _load_steam_games_async(self, callback: Callable[[list[tuple]], None]):
steam_games = []

View File

@@ -254,6 +254,8 @@ class PortProtonAPI:
try:
mod_time = os.path.getmtime(cache_file)
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
# Add timeout protection for file operations
start_time = time.time()
with open(cache_file, "rb") as f:
data = orjson.loads(f.read())
# Check signature
@@ -261,6 +263,10 @@ class PortProtonAPI:
current_signature = self._compute_scripts_signature(
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
)
# Check for timeout during signature computation
if time.time() - start_time > 3: # 3 second timeout
logger.warning("Cache loading took too long, skipping cache")
return None
if cached_signature != current_signature:
logger.info("Scripts signature mismatch; invalidating cache")
return None
@@ -287,21 +293,26 @@ class PortProtonAPI:
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
"""Start loading auto-install games in a background thread. Returns the thread for management."""
# Check cache first (sync, fast)
cached_games = self._load_autoinstall_cache()
if cached_games is not None:
# Emit via callback immediately if cached
QThread.msleep(0) # Yield to Qt event loop
callback(cached_games)
return None # No thread needed
# No cache: Start background thread
class AutoinstallWorker(QThread):
finished = Signal(list)
api: "PortProtonAPI"
portproton_location: str | None
def run(self):
import time
# Check cache in this background thread, not in main thread
start_time = time.time()
cached_games = self.api._load_autoinstall_cache()
# If cache loading took too long (>2 seconds), skip cache and load directly
if time.time() - start_time > 2:
logger.warning("Cache loading took too long, proceeding without cache")
cached_games = None
if cached_games is not None:
self.finished.emit(cached_games)
return
# No cache: Load games from scratch
games = []
auto_dir = os.path.join(
self.portproton_location or "", "data", "scripts", "pw_autoinstall"

View File

@@ -420,7 +420,7 @@ def fetch_sgdb_cover(game_name: str) -> str:
try:
encoded = urllib.parse.quote(game_name)
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
resp = requests.get(url, timeout=5)
resp = requests.get(url, timeout=10)
if resp.status_code != 200:
logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
return ""
@@ -431,17 +431,30 @@ def fetch_sgdb_cover(game_name: str) -> str:
if text:
logger.info("Fetched SGDB cover for %s: %s", game_name, text)
return text
except requests.exceptions.Timeout:
logger.warning(f"SGDB request timed out for {game_name}")
return ""
except requests.exceptions.RequestException as e:
logger.warning(f"SGDB request error for {game_name}: {e}")
return ""
except Exception as e:
logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
return ""
logger.warning(f"Unexpected error while fetching SGDB cover for {game_name}: {e}")
return ""
def check_url_exists(url: str) -> bool:
"""Check whether a URL returns HTTP 200."""
try:
r = requests.head(url, timeout=3)
r = requests.head(url, timeout=5)
return r.status_code == 200
except Exception:
except requests.exceptions.Timeout:
logger.warning(f"URL check timed out for: {url}")
return False
except requests.exceptions.RequestException as e:
logger.warning(f"Request error when checking URL {url}: {e}")
return False
except Exception as e:
logger.warning(f"Unexpected error when checking URL {url}: {e}")
return False

View File

@@ -111,34 +111,65 @@ def load_theme_fonts(theme_name):
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
return
QFontDatabase.removeAllApplicationFonts()
fonts_folder = None
if theme_name == "standart":
base_dir = os.path.dirname(os.path.abspath(__file__))
fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
else:
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
possible_fonts_folder = os.path.join(theme_folder, "fonts")
if os.path.exists(possible_fonts_folder):
fonts_folder = possible_fonts_folder
break
def load_fonts_delayed():
global _loaded_theme
try:
# Only remove fonts if this is a theme change (not initial load)
current_loaded_theme = _loaded_theme # Capture the current value
if current_loaded_theme is not None and current_loaded_theme != theme_name:
# Run font removal in the GUI thread with delay
QFontDatabase.removeAllApplicationFonts()
if not fonts_folder or not os.path.exists(fonts_folder):
logger.error(f"Fonts folder not found for theme '{theme_name}'")
return
import time
import os
start_time = time.time()
timeout = 3 # Reduced timeout to 3 seconds for faster loading
for filename in os.listdir(fonts_folder):
if filename.lower().endswith((".ttf", ".otf")):
font_path = os.path.join(fonts_folder, filename)
font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Font {filename} successfully loaded: {families}")
fonts_folder = None
if theme_name == "standart":
base_dir = os.path.dirname(os.path.abspath(__file__))
fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
else:
logger.error(f"Error loading font: {filename}")
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
possible_fonts_folder = os.path.join(theme_folder, "fonts")
if os.path.exists(possible_fonts_folder):
fonts_folder = possible_fonts_folder
break
_loaded_theme = theme_name
if not fonts_folder or not os.path.exists(fonts_folder):
logger.error(f"Fonts folder not found for theme '{theme_name}'")
return
font_files = []
for filename in os.listdir(fonts_folder):
if filename.lower().endswith((".ttf", ".otf")):
font_files.append(filename)
# Limit number of fonts loaded to prevent too much blocking
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
for filename in font_files:
if time.time() - start_time > timeout:
logger.warning(f"Font loading timed out for theme '{theme_name}' after loading {len(font_files)} fonts")
break
font_path = os.path.join(fonts_folder, filename)
font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Font {filename} successfully loaded: {families}")
else:
logger.error(f"Error loading font: {filename}")
# Update the global variable in the main thread
_loaded_theme = theme_name
except Exception as e:
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
# Use QTimer to delay font loading until after the UI is rendered
from PySide6.QtCore import QTimer
QTimer.singleShot(100, load_fonts_delayed) # Delay font loading by 100ms
class ThemeWrapper:
"""