diff --git a/graphlink_app/graphlink_app.py b/graphlink_app/graphlink_app.py index 204b24c..09861e4 100644 --- a/graphlink_app/graphlink_app.py +++ b/graphlink_app/graphlink_app.py @@ -6,7 +6,12 @@ import graphlink_licensing from graphlink_config import apply_theme, set_current_model, sync_ollama_task_models from graphlink_logging import configure_logging -from graphlink_crash import install_crash_handlers, mark_running, previous_run_crashed +from graphlink_crash import ( + install_crash_handlers, + mark_running, + previous_run_crashed, + uninstall_crash_handlers, +) from graphlink_version import APP_VERSION def main(): @@ -19,6 +24,8 @@ def main(): app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(True) + # Remove Python/Qt crash callbacks before Qt destroys its Python wrappers. + app.aboutToQuit.connect(uninstall_crash_handlers) # Use the new SettingsManager (formerly LicenseManager) settings_manager = graphlink_licensing.SettingsManager() diff --git a/graphlink_app/graphlink_crash.py b/graphlink_app/graphlink_crash.py index 26f99f6..42adb99 100644 --- a/graphlink_app/graphlink_crash.py +++ b/graphlink_app/graphlink_crash.py @@ -185,6 +185,43 @@ def install_crash_handlers(version="unknown", crash_dir=None): pass +def uninstall_crash_handlers(): + """Restore process-global handlers before Qt/PySide is torn down. + + Crash reporting is installed before ``QApplication`` exists, so the normal + application shutdown path must explicitly remove the Qt message callback + while Qt's Python wrappers are still alive. This is also required by the + test suite: leaving a callback that closes over PySide objects installed + until interpreter shutdown can turn an otherwise passing Qt test run into + a non-zero process exit. + """ + global _installed, _faulthandler_file + + sys.excepthook = sys.__excepthook__ + threading.excepthook = threading.__excepthook__ + + try: + from PySide6.QtCore import qInstallMessageHandler + + qInstallMessageHandler(None) + except (ImportError, AttributeError, RuntimeError, SystemError, TypeError): + pass + + try: + faulthandler.disable() + except (AttributeError, RuntimeError, SystemError, ValueError): + pass + + if _faulthandler_file is not None: + try: + _faulthandler_file.close() + except (AttributeError, OSError, ValueError): + pass + _faulthandler_file = None + + _installed = False + + # --- "did the previous run crash" sentinel --- # # A JSON file at ~/.graphlink/running.lock is written at startup (mark_running) and diff --git a/graphlink_app/tests/test_crash_reporting.py b/graphlink_app/tests/test_crash_reporting.py index 3cdc43e..2fda586 100644 --- a/graphlink_app/tests/test_crash_reporting.py +++ b/graphlink_app/tests/test_crash_reporting.py @@ -164,15 +164,7 @@ def test_url_targets_the_repo_and_embeds_the_report(self): class TestInstallCrashHandlers: def teardown_method(self): - sys.excepthook = sys.__excepthook__ - threading.excepthook = threading.__excepthook__ - graphlink_crash._installed = False - if graphlink_crash._faulthandler_file is not None: - try: - graphlink_crash._faulthandler_file.close() - except Exception: - pass - graphlink_crash._faulthandler_file = None + graphlink_crash.uninstall_crash_handlers() def test_installs_a_non_default_excepthook(self, tmp_path): graphlink_crash.install_crash_handlers(version="v1", crash_dir=tmp_path) @@ -187,6 +179,16 @@ def test_is_idempotent(self, tmp_path): assert sys.excepthook is first_hook + def test_uninstall_restores_process_global_handlers(self, tmp_path): + graphlink_crash.install_crash_handlers(version="v1", crash_dir=tmp_path) + + graphlink_crash.uninstall_crash_handlers() + + assert sys.excepthook is sys.__excepthook__ + assert threading.excepthook is threading.__excepthook__ + assert graphlink_crash._installed is False + assert graphlink_crash._faulthandler_file is None + def test_the_installed_excepthook_writes_a_report(self, tmp_path, monkeypatch): monkeypatch.setattr(graphlink_crash, "_crash_dir", lambda base_dir=None: tmp_path) graphlink_crash.install_crash_handlers(version="v1", crash_dir=tmp_path)