Harden shutdown: no segfault closing mid-load (thread cleanup + graceful Ctrl+C)
This commit is contained in:
+51
-7
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user