Harden shutdown: no segfault closing mid-load (thread cleanup + graceful Ctrl+C)

This commit is contained in:
2026-09-13 23:49:17 -04:00
parent 733e3c9089
commit 96e876b8ec
+51 -7
View File
@@ -7,6 +7,7 @@ Run with::
import datetime
import json
import logging
import signal
import sys
import threading
from pathlib import Path
@@ -158,7 +159,9 @@ class CoverLoader(QObject):
else:
self.failed.emit(self.gen, i, self.items[i]["name"])
finally:
pool.shutdown(wait=True, cancel_futures=True)
# Return promptly on cancel/exit instead of blocking the GUI thread
# on in-flight network timeouts during shutdown.
pool.shutdown(wait=not self._cancel.is_set(), cancel_futures=True)
self.finished.emit()
@@ -480,7 +483,7 @@ class CoverShowcase(QWidget):
return QIcon(pm)
def _on_loaded(self, gen, index, image):
if gen != self._generation:
if self._closing or gen != self._generation:
return
item = self.list.item(index)
if item is not None:
@@ -502,7 +505,7 @@ class CoverShowcase(QWidget):
self.play_requested.emit(data)
def _on_failed(self, gen, index, name):
if gen != self._generation:
if self._closing or gen != self._generation:
return
if index not in self.missing:
self.missing.append(index)
@@ -510,6 +513,8 @@ class CoverShowcase(QWidget):
self._update_status()
def _on_loading_done(self):
if self._closing:
return
sender = self.sender()
if sender is not None and getattr(sender, "gen", None) != self._generation:
return
@@ -517,6 +522,8 @@ class CoverShowcase(QWidget):
self._update_status()
def _update_status(self):
if self._closing:
return
if self._loading:
self.status.setText("Loading covers…")
self.missing_changed.emit(0)
@@ -1541,7 +1548,9 @@ class StoreCoverLoader(QObject):
if not img.isNull():
self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h))
finally:
pool.shutdown(wait=True, cancel_futures=True)
# Return promptly on cancel/exit instead of blocking the GUI thread
# on in-flight network timeouts during shutdown.
pool.shutdown(wait=not self._cancel.is_set(), cancel_futures=True)
self.finished.emit()
@@ -2353,7 +2362,7 @@ class StorePage(QWidget):
thread.start()
def _on_date_loaded(self, gen, row, date):
if gen != self._generation or not date:
if self._closing or gen != self._generation or not date:
return
if 0 <= row < len(self._filtered):
self._filtered[row]["release_date"] = date
@@ -2376,7 +2385,7 @@ class StorePage(QWidget):
thread.start()
def _on_cover_loaded(self, gen, row, image):
if gen != self._generation:
if self._closing or gen != self._generation:
return
item = self.grid.item(row)
if item is not None:
@@ -4130,6 +4139,7 @@ class Ps1Page(QWidget):
self._preview_gen = 0
self._preview_threads = []
self._preview_workers = []
self._closing = False
layout = QVBoxLayout(self)
bar = QHBoxLayout()
@@ -4228,7 +4238,7 @@ class Ps1Page(QWidget):
self._preview_workers.append(worker)
def _on_preview_result(self, gen, game_id, icon0, pic0, pic1):
if gen != self._preview_gen:
if self._closing or gen != self._preview_gen:
return
self.preview_icon0.setText("")
self.preview_pic0.setText("")
@@ -4276,6 +4286,8 @@ class Ps1Page(QWidget):
self._thread.start()
def _on_results(self, entries):
if self._closing:
return
# Only show discs that still need converting; converted games already
# appear in the Library tab.
entries = [e for e in entries if e["kind"] == "disc" and e.get("status") != "converted"]
@@ -4291,8 +4303,23 @@ class Ps1Page(QWidget):
self.table.selectRow(0)
def _on_failed(self, message):
if not self._closing:
self.status.setText(f"PS1 scan failed: {message}")
def shutdown(self):
self._closing = True
if self._thread is not None and _thread_running(self._thread):
self._thread.quit()
self._thread.wait()
for t in self._preview_threads:
if _thread_running(t):
t.quit()
t.wait()
self._preview_threads = []
self._preview_workers = []
self._thread = None
self._worker = None
def _convert_selected(self):
row = self.table.currentRow()
if row < 0 or row >= len(self._entries):
@@ -5685,6 +5712,8 @@ class MainWindow(QMainWindow):
self.psp_page.shutdown()
if hasattr(self, "saves_page"):
self.saves_page.shutdown()
if hasattr(self, "ps1_page"):
self.ps1_page.shutdown()
event.accept()
@@ -5718,6 +5747,21 @@ def main():
window.show()
log.info("main window ready")
threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start()
# Ctrl+C should close the window gracefully (running closeEvent's thread
# cleanup) rather than tearing Qt down mid-thread, which segfaults.
interrupt = {"pending": False}
def _sigint_handler(signum, frame):
interrupt["pending"] = True
signal.signal(signal.SIGINT, _sigint_handler)
sigint_timer = QTimer()
sigint_timer.setInterval(100)
sigint_timer.timeout.connect(
lambda: window.close() if interrupt["pending"] else None)
sigint_timer.start()
return app.exec()