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 datetime
import json import json
import logging import logging
import signal
import sys import sys
import threading import threading
from pathlib import Path from pathlib import Path
@@ -158,7 +159,9 @@ class CoverLoader(QObject):
else: else:
self.failed.emit(self.gen, i, self.items[i]["name"]) self.failed.emit(self.gen, i, self.items[i]["name"])
finally: 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() self.finished.emit()
@@ -480,7 +483,7 @@ class CoverShowcase(QWidget):
return QIcon(pm) return QIcon(pm)
def _on_loaded(self, gen, index, image): def _on_loaded(self, gen, index, image):
if gen != self._generation: if self._closing or gen != self._generation:
return return
item = self.list.item(index) item = self.list.item(index)
if item is not None: if item is not None:
@@ -502,7 +505,7 @@ class CoverShowcase(QWidget):
self.play_requested.emit(data) self.play_requested.emit(data)
def _on_failed(self, gen, index, name): def _on_failed(self, gen, index, name):
if gen != self._generation: if self._closing or gen != self._generation:
return return
if index not in self.missing: if index not in self.missing:
self.missing.append(index) self.missing.append(index)
@@ -510,6 +513,8 @@ class CoverShowcase(QWidget):
self._update_status() self._update_status()
def _on_loading_done(self): def _on_loading_done(self):
if self._closing:
return
sender = self.sender() sender = self.sender()
if sender is not None and getattr(sender, "gen", None) != self._generation: if sender is not None and getattr(sender, "gen", None) != self._generation:
return return
@@ -517,6 +522,8 @@ class CoverShowcase(QWidget):
self._update_status() self._update_status()
def _update_status(self): def _update_status(self):
if self._closing:
return
if self._loading: if self._loading:
self.status.setText("Loading covers…") self.status.setText("Loading covers…")
self.missing_changed.emit(0) self.missing_changed.emit(0)
@@ -1541,7 +1548,9 @@ class StoreCoverLoader(QObject):
if not img.isNull(): if not img.isNull():
self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h)) self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h))
finally: 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() self.finished.emit()
@@ -2353,7 +2362,7 @@ class StorePage(QWidget):
thread.start() thread.start()
def _on_date_loaded(self, gen, row, date): 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 return
if 0 <= row < len(self._filtered): if 0 <= row < len(self._filtered):
self._filtered[row]["release_date"] = date self._filtered[row]["release_date"] = date
@@ -2376,7 +2385,7 @@ class StorePage(QWidget):
thread.start() thread.start()
def _on_cover_loaded(self, gen, row, image): def _on_cover_loaded(self, gen, row, image):
if gen != self._generation: if self._closing or gen != self._generation:
return return
item = self.grid.item(row) item = self.grid.item(row)
if item is not None: if item is not None:
@@ -4130,6 +4139,7 @@ class Ps1Page(QWidget):
self._preview_gen = 0 self._preview_gen = 0
self._preview_threads = [] self._preview_threads = []
self._preview_workers = [] self._preview_workers = []
self._closing = False
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
bar = QHBoxLayout() bar = QHBoxLayout()
@@ -4228,7 +4238,7 @@ class Ps1Page(QWidget):
self._preview_workers.append(worker) self._preview_workers.append(worker)
def _on_preview_result(self, gen, game_id, icon0, pic0, pic1): 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 return
self.preview_icon0.setText("") self.preview_icon0.setText("")
self.preview_pic0.setText("") self.preview_pic0.setText("")
@@ -4276,6 +4286,8 @@ class Ps1Page(QWidget):
self._thread.start() self._thread.start()
def _on_results(self, entries): def _on_results(self, entries):
if self._closing:
return
# Only show discs that still need converting; converted games already # Only show discs that still need converting; converted games already
# appear in the Library tab. # appear in the Library tab.
entries = [e for e in entries if e["kind"] == "disc" and e.get("status") != "converted"] 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) self.table.selectRow(0)
def _on_failed(self, message): def _on_failed(self, message):
if not self._closing:
self.status.setText(f"PS1 scan failed: {message}") 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): def _convert_selected(self):
row = self.table.currentRow() row = self.table.currentRow()
if row < 0 or row >= len(self._entries): if row < 0 or row >= len(self._entries):
@@ -5685,6 +5712,8 @@ class MainWindow(QMainWindow):
self.psp_page.shutdown() self.psp_page.shutdown()
if hasattr(self, "saves_page"): if hasattr(self, "saves_page"):
self.saves_page.shutdown() self.saves_page.shutdown()
if hasattr(self, "ps1_page"):
self.ps1_page.shutdown()
event.accept() event.accept()
@@ -5718,6 +5747,21 @@ def main():
window.show() window.show()
log.info("main window ready") log.info("main window ready")
threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start() 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() return app.exec()