Compare commits

...
2 Commits
Author SHA1 Message Date
quoije d94ae52e14 Fix store crash when filtering after load (loader thread cleanup)
build / Windows x64 (Wine) (push) Successful in 10m49s
build / Linux x86_64 (push) Successful in 13m4s
StorePage cover/date loaders were never removed from tracking once their
thread finished, and _populate/_begin_scan dropped the whole list while
threads were still running. That garbage-collected QObjects whose QThread
had already been deleted, leaving a dangling thread affinity that crashed
Qt (SIGSEGV) when searching the store after it fully loaded.

Loaders now remove themselves and deleteLater on their finished signal, and
the tracking list is kept until each thread winds down.
2026-09-14 20:36:59 -04:00
quoije d8a552c61e Client: keep PS1/theme covers+metadata local when a server is configured
build / Linux x86_64 (push) Successful in 2m54s
build / Windows x64 (Wine) (push) Successful in 3m40s
2026-09-14 20:15:26 -04:00
3 changed files with 31 additions and 9 deletions
+5 -3
View File
@@ -1220,9 +1220,11 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False):
except OSError: except OSError:
pass pass
# Server is authoritative for covers when configured — no libretro/IGDB # Server is authoritative for game/video covers when configured — no
# fallback; if the server is unreachable the cover just stays empty. # libretro/IGDB fallback; if the server is unreachable the cover just
if server_client.configured(): # stays empty. PS1/theme art stays client-side (the server catalog only
# covers games and videos).
if server_client.configured() and system in ("games", "videos"):
try: try:
data = server_client.fetch_cover(system, name) data = server_client.fetch_cover(system, name)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
+21 -3
View File
@@ -2044,7 +2044,6 @@ class StorePage(QWidget):
self._cover_attempted.clear() self._cover_attempted.clear()
for loader in self._loaders: for loader in self._loaders:
loader.cancel() loader.cancel()
self._loaders = []
self.grid.clear() self.grid.clear()
self.table.setRowCount(0) self.table.setRowCount(0)
self.status.setText("Loading…") self.status.setText("Loading…")
@@ -2220,10 +2219,12 @@ class StorePage(QWidget):
self._cover_attempted.clear() self._cover_attempted.clear()
self._placeholder = QIcon(_placeholder_pixmap(self.ICON_W, self.ICON_H)) self._placeholder = QIcon(_placeholder_pixmap(self.ICON_W, self.ICON_H))
# Cancel any in-flight cover loaders (stale generation). # Cancel any in-flight cover loaders (stale generation). Each loader
# removes itself from ``_loaders`` via its own ``finished`` handler once
# its thread winds down — dropping the list here would garbage-collect
# live QObjects and crash Qt.
for loader in self._loaders: for loader in self._loaders:
loader.cancel() loader.cancel()
self._loaders = []
self.grid.clear() self.grid.clear()
self.grid.setUpdatesEnabled(False) self.grid.setUpdatesEnabled(False)
@@ -2401,6 +2402,7 @@ class StorePage(QWidget):
thread.started.connect(loader.run) thread.started.connect(loader.run)
loader.loaded.connect(self._on_date_loaded) loader.loaded.connect(self._on_date_loaded)
loader.finished.connect(self._on_date_batch_done) loader.finished.connect(self._on_date_batch_done)
loader.finished.connect(self._on_loader_finished)
loader.finished.connect(thread.quit) loader.finished.connect(thread.quit)
thread.finished.connect(self._on_thread_finished) thread.finished.connect(self._on_thread_finished)
self._threads.append(thread) self._threads.append(thread)
@@ -2425,6 +2427,21 @@ class StorePage(QWidget):
if cell is not None: if cell is not None:
cell.setText(date) cell.setText(date)
def _on_loader_finished(self):
"""Drop a finished cover/date loader from tracking.
Loaders stay in ``_loaders`` until their thread finishes so their
QObject wrapper is never garbage-collected while its thread is still
winding down (that left a dangling thread affinity and crashed Qt on
filter/refresh).
"""
loader = self.sender()
if loader is None:
return
if loader in self._loaders:
self._loaders.remove(loader)
loader.deleteLater()
def _start_cover_loader(self, gen, tasks): def _start_cover_loader(self, gen, tasks):
if self._closing or not tasks: if self._closing or not tasks:
return return
@@ -2434,6 +2451,7 @@ class StorePage(QWidget):
thread.started.connect(loader.run) thread.started.connect(loader.run)
loader.loaded.connect(self._on_cover_loaded) loader.loaded.connect(self._on_cover_loaded)
loader.finished.connect(self._on_cover_batch_done) loader.finished.connect(self._on_cover_batch_done)
loader.finished.connect(self._on_loader_finished)
loader.finished.connect(thread.quit) loader.finished.connect(thread.quit)
thread.finished.connect(self._on_thread_finished) thread.finished.connect(self._on_thread_finished)
self._threads.append(thread) self._threads.append(thread)
+5 -3
View File
@@ -199,9 +199,11 @@ def get_or_fetch(release_id, system, fetch_title):
info = load(release_id, system) info = load(release_id, system)
if info is not None: if info is not None:
return info return info
# Server is authoritative for metadata when configured — no IGDB/TMDB # Server is authoritative for game/video metadata when configured — no
# fallback; an unreachable server just leaves metadata empty. # IGDB/TMDB fallback; an unreachable server just leaves metadata empty.
if server_client.configured(): # PS1 metadata stays client-side (the server catalog only covers games
# and videos).
if server_client.configured() and system in ("games", "videos"):
try: try:
meta = server_client.fetch_metadata(system, fetch_title) meta = server_client.fetch_metadata(system, fetch_title)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001