Skip to content

Commit dde7a06

Browse files
committed
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.
1 parent 6daf9c0 commit dde7a06

2 files changed

Lines changed: 37 additions & 3 deletions

File tree

cq_editor/main_window.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,11 @@ def prepare_panes(self):
291291
for d in self.docks.values():
292292
d.show()
293293

294-
PRINT_REDIRECTOR.sigStdoutWrite.connect(
295-
lambda text: self.components["log"].append(text)
296-
)
294+
# Connect the bound method rather than a lambda: PRINT_REDIRECTOR is a
295+
# module-level singleton, and PyQt drops a connection to a QObject's
296+
# bound method when that QObject is destroyed. A lambda would outlive
297+
# the LogViewer and call into a deleted C++ object.
298+
PRINT_REDIRECTOR.sigStdoutWrite.connect(self.components["log"].append)
297299

298300
def prepare_menubar(self):
299301

tests/test_app.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
from PyQt5.QtCore import Qt, QSettings, QPoint, QEvent, QSize
1515
from PyQt5.QtWidgets import QFileDialog, QMessageBox
1616
from PyQt5.QtGui import QMouseEvent
17+
from PyQt5 import sip
1718

1819
from cq_editor.__main__ import MainWindow
20+
from cq_editor.main_window import PRINT_REDIRECTOR
1921
from cq_editor.widgets.editor import Editor
2022
from cq_editor.cq_utils import export, get_occ_color
2123

@@ -2202,3 +2204,33 @@ def test_editor_autoreload(editor):
22022204
# with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT):
22032205
# # modify file - NB: separate process is needed to avoid Windows quirks
22042206
# modify_file(code_nested_bottom, "test_nested_bottom.py")
2207+
2208+
2209+
def test_print_redirector_released_with_window(qtbot, mocker):
2210+
"""
2211+
PRINT_REDIRECTOR is a module-level singleton that outlives any MainWindow.
2212+
Its connection to a window's LogViewer must be released when that window is
2213+
destroyed; otherwise a later write reaches a deleted C++ object and raises
2214+
"wrapped C/C++ object of type LogViewer has been deleted".
2215+
"""
2216+
2217+
mocker.patch.object(QMessageBox, "question", return_value=QMessageBox.Yes)
2218+
mocker.patch.object(QMessageBox, "warning", return_value=QMessageBox.Discard)
2219+
2220+
win = MainWindow()
2221+
qtbot.addWidget(win)
2222+
2223+
# destroy this window's LogViewer, as tearing the window down would
2224+
sip.delete(win.components["log"])
2225+
2226+
# PyQt routes an exception raised inside a slot to sys.excepthook rather
2227+
# than propagating it, so capture it there while emitting
2228+
errors = []
2229+
original_hook = sys.excepthook
2230+
sys.excepthook = lambda exc_type, *rest: errors.append(exc_type)
2231+
try:
2232+
PRINT_REDIRECTOR.sigStdoutWrite.emit("stray output after close")
2233+
finally:
2234+
sys.excepthook = original_hook
2235+
2236+
assert errors == []

0 commit comments

Comments
 (0)