From 10a2eb03e1d931016d70d05783a3ddb157e389a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Sat, 11 Jul 2026 00:19:46 +0400 Subject: [PATCH] Stop PRINT_REDIRECTOR from outliving the LogViewer PRINT_REDIRECTOR is a module-level singleton, so the lambda connected to sigStdoutWrite in prepare_panes accumulated one connection per MainWindow and was never disconnected. Each lambda also closed over self and resolved self.components["log"] late through a dict that MainMixin holds as a class attribute, so it reached whichever LogViewer registered last. Once a LogViewer was destroyed, the next print() called into a deleted C++ object and raised RuntimeError. Connecting the bound method instead lets PyQt drop the connection when the receiver is destroyed. --- cq_editor/main_window.py | 8 +++++--- tests/test_main_window.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/test_main_window.py diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py index bece4094..986e8f44 100644 --- a/cq_editor/main_window.py +++ b/cq_editor/main_window.py @@ -291,9 +291,11 @@ def prepare_panes(self): for d in self.docks.values(): d.show() - PRINT_REDIRECTOR.sigStdoutWrite.connect( - lambda text: self.components["log"].append(text) - ) + # Connect the bound method rather than a lambda: PRINT_REDIRECTOR is a + # module-level singleton, and PyQt drops a connection to a QObject's + # bound method when that QObject is destroyed. A lambda would outlive + # the LogViewer and call into a deleted C++ object. + PRINT_REDIRECTOR.sigStdoutWrite.connect(self.components["log"].append) def prepare_menubar(self): diff --git a/tests/test_main_window.py b/tests/test_main_window.py new file mode 100644 index 00000000..fef7fe1e --- /dev/null +++ b/tests/test_main_window.py @@ -0,0 +1,37 @@ +import sys + +from PyQt5.QtWidgets import QMessageBox +from PyQt5 import sip + +from cq_editor.__main__ import MainWindow +from cq_editor.main_window import PRINT_REDIRECTOR + + +def test_print_redirector_released_with_window(qtbot, mocker): + """ + PRINT_REDIRECTOR is a module-level singleton that outlives any MainWindow. + Once a window's LogViewer is destroyed, a write must be dropped rather than + delivered to the now-deleted C++ object (which raises "wrapped C/C++ object + of type LogViewer has been deleted"). + """ + + mocker.patch.object(QMessageBox, "question", return_value=QMessageBox.Yes) + mocker.patch.object(QMessageBox, "warning", return_value=QMessageBox.Discard) + + win = MainWindow() + qtbot.addWidget(win) + + # destroy this window's LogViewer, as tearing the window down would + sip.delete(win.components["log"]) + + # PyQt routes an exception raised inside a slot to sys.excepthook rather + # than propagating it, so capture it there while emitting + errors = [] + original_hook = sys.excepthook + sys.excepthook = lambda exc_type, *rest: errors.append(exc_type) + try: + PRINT_REDIRECTOR.sigStdoutWrite.emit("stray output after close") + finally: + sys.excepthook = original_hook + + assert errors == []