Compare commits

...
2 Commits
Author SHA1 Message Date
quoije a4d132f134 Harden worker cleanup + reduce cover-loading churn
build / Linux x86_64 (push) Successful in 3m30s
build / Windows x64 (Wine) (push) Successful in 4m22s
- Library/metadata workers: delete the worker on its own thread via
  worker.finished -> worker.deleteLater (bound) instead of letting Python GC
  delete it on the GUI thread after untracking.
- Cover templates: compute the transparent-window scan outside the lock so a
  slow first scan never holds the lock and stalls other cover threads.
- Store: larger cover/date batches (500/400/300) so the store loads 3449
  archives with far fewer QThread + ThreadPoolExecutor creations.
2026-09-14 22:33:49 -04:00
quoije 3a69f3f6a1 Fix scrape crash: don't run GUI slots on worker threads
build / Linux x86_64 (push) Successful in 3m4s
build / Windows x64 (Wine) (push) Successful in 3m44s
Connecting a signal to a Python lambda has no QObject receiver, so PySide6
treats it as a direct connection and runs the lambda on the emitting (worker)
thread. The scrape dialog connected its results signals to lambdas, so
_on_results/_on_scraped executed on the worker thread and called
accept()/done() there -> QThread::wait on itself and GUI widget mutation off
the GUI thread -> 'QThread: Destroyed while thread is still running' abort.

Connect to bound methods (queued to the GUI thread) and carry the generation
on the worker (read via sender().gen). Also replaced the thread.finished
lambdas in load_library/metadata with bound-method cleanup keyed by a
thread._worker attribute.
2026-09-14 21:54:59 -04:00
2 changed files with 85 additions and 58 deletions
+6 -4
View File
@@ -797,9 +797,8 @@ def _transparent_rect(img):
def _game_template():
"""Load the PSP game cover template; return ``(template, window)``."""
global _game_template_cache
if _game_template_cache is None:
with _game_template_lock:
if _game_template_cache is None:
if _game_template_cache is not None:
return _game_template_cache
try:
from PySide6.QtGui import QImage
except Exception: # noqa: BLE001 - headless API may lack PySide6
@@ -815,10 +814,13 @@ def _game_template():
if not img.isNull():
break
img = img.convertToFormat(QImage.Format_ARGB32) if not img.isNull() else None
_game_template_cache = (
entry = (
img,
_transparent_rect(img) if img is not None and not img.isNull() else (0, 0, 0, 0),
)
with _game_template_lock:
if _game_template_cache is None:
_game_template_cache = entry
return _game_template_cache
+51 -26
View File
@@ -172,9 +172,8 @@ _template_lock = threading.Lock()
def _umd_template():
"""Return ``(template QImage, transparent window rect)`` — loaded once."""
global _template_cache
if _template_cache is None:
with _template_lock:
if _template_cache is None:
if _template_cache is not None:
return _template_cache
img = QImage()
for path in (
config.COVERS_DIR / "UMD_COVER_TEMPLATE.png",
@@ -185,7 +184,10 @@ def _umd_template():
if not img.isNull():
break
img = img.convertToFormat(QImage.Format_ARGB32)
_template_cache = (img, _transparent_window(img))
entry = (img, _transparent_window(img))
with _template_lock:
if _template_cache is None:
_template_cache = entry
return _template_cache
@@ -969,10 +971,11 @@ class ScrapeSearchWorker(QObject):
results = Signal(list) # candidates with thumb_bytes added
finished = Signal()
def __init__(self, system, query):
def __init__(self, system, query, gen):
super().__init__()
self.system = system
self.query = query
self.gen = gen
self._cancel = threading.Event()
def cancel(self):
@@ -1003,11 +1006,12 @@ class ScrapeFetchWorker(QObject):
result = Signal(dict) # {"meta": {...}, "cover": bytes|None}
finished = Signal()
def __init__(self, system, rid, candidate):
def __init__(self, system, rid, candidate, gen):
super().__init__()
self.system = system
self.rid = rid
self.candidate = candidate
self.gen = gen
self._cancel = threading.Event()
def cancel(self):
@@ -1122,17 +1126,17 @@ class ScrapeDialog(QDialog):
self.list.clear()
self.candidates = []
thread = QThread(self)
worker = ScrapeSearchWorker(self.system, query)
worker = ScrapeSearchWorker(self.system, query, gen)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.results.connect(lambda r, g=gen: self._on_results(r, g))
worker.results.connect(self._on_results)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_results(self, results, gen):
if gen != self._search_gen:
def _on_results(self, results):
if getattr(self.sender(), "gen", None) != self._search_gen:
return
self.search_btn.setEnabled(True)
self.candidates = results
@@ -1172,17 +1176,17 @@ class ScrapeDialog(QDialog):
self.search_btn.setEnabled(False)
self.status.setText("Scraping…")
thread = QThread(self)
worker = ScrapeFetchWorker(self.system, self.rid, candidate)
worker = ScrapeFetchWorker(self.system, self.rid, candidate, gen)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(lambda p, g=gen: self._on_scraped(p, g))
worker.result.connect(self._on_scraped)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_scraped(self, payload, gen):
if gen != self._search_gen:
def _on_scraped(self, payload):
if getattr(self.sender(), "gen", None) != self._search_gen:
return
meta = payload.get("meta") or {}
cover = payload.get("cover")
@@ -1198,7 +1202,15 @@ class ScrapeDialog(QDialog):
self.search_btn.setEnabled(True)
self.status.setText("Could not fetch anything for that selection.")
def closeEvent(self, event):
def _shutdown_threads(self):
"""Cancel workers and wait for their threads to fully finish.
Must run before the dialog is destroyed — QThread children abort the
process with "QThread: Destroyed while thread is still running" if they
are deleted while their worker thread is alive. ``done()`` covers
accept()/reject() (including the accept() in ``_on_scraped``); the
close event covers the window-manager close path.
"""
for w in self._workers:
w.cancel()
for t in self._threads:
@@ -1207,8 +1219,15 @@ class ScrapeDialog(QDialog):
t.wait()
self._workers = []
self._threads = []
def closeEvent(self, event):
self._shutdown_threads()
event.accept()
def done(self, result):
self._shutdown_threads()
super().done(result)
class SettingsPage(QWidget):
saved = Signal()
@@ -2268,11 +2287,11 @@ class StorePage(QWidget):
return (0, -date_int, -mtime)
return (1, 0, -mtime)
CHUNK = 150 # items filled per timer tick
COVER_BATCH = 200 # covers fetched for the initially-visible area
COVER_SCROLL_BATCH = 100
CHUNK = 200 # items filled per timer tick
COVER_BATCH = 500 # covers fetched for the initially-visible area
COVER_SCROLL_BATCH = 400
RECENT_LIMIT = 300 # default: show only the newest N store items
DATE_BATCH = 150 # release-date metadata fetched per batch
DATE_BATCH = 300 # release-date metadata fetched per batch
def _populate(self, items, show_count):
self._generation += 1
@@ -4856,12 +4875,13 @@ class MainWindow(QMainWindow):
thread = QThread(self)
worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir, self._nps_dir)
worker.moveToThread(thread)
thread._worker = worker
thread.started.connect(worker.run)
worker.result.connect(self._on_library_loaded)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
worker.finished.connect(thread.quit)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda t=thread, w=worker: self._forget_library_thread(t, w))
thread.finished.connect(self._forget_library_thread)
self._library_threads.append(thread)
self._library_workers.append(worker)
thread.start()
@@ -4875,10 +4895,12 @@ class MainWindow(QMainWindow):
self.store_page.set_library_items(self._library_cache)
self.statusBar().showMessage("")
def _forget_library_thread(self, thread, worker):
def _forget_library_thread(self):
thread = self.sender()
worker = getattr(thread, "_worker", None)
if thread in self._library_threads:
self._library_threads.remove(thread)
if worker in self._library_workers:
if worker is not None and worker in self._library_workers:
self._library_workers.remove(worker)
def _apply_library_filter(self):
@@ -5012,20 +5034,23 @@ class MainWindow(QMainWindow):
thread = QThread(self)
worker = MetadataWorker(title, gen, system, rid)
worker.moveToThread(thread)
thread._worker = worker
thread.started.connect(worker.run)
worker.result.connect(self._on_metadata)
worker.finished.connect(worker.deleteLater)
worker.finished.connect(thread.quit)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda t=thread, w=worker: self._forget_meta_thread(t, w))
thread.finished.connect(self._forget_meta_thread)
self._meta_threads.append(thread)
self._meta_workers.append(worker)
thread.start()
def _forget_meta_thread(self, thread, worker):
def _forget_meta_thread(self):
thread = self.sender()
worker = getattr(thread, "_worker", None)
if thread in self._meta_threads:
self._meta_threads.remove(thread)
if worker in self._meta_workers:
if worker is not None and worker in self._meta_workers:
self._meta_workers.remove(worker)
def _on_metadata(self, gen, meta):