Skip to content

Commit 10a2eb0

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 10a2eb0

2 files changed

Lines changed: 42 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_main_window.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import sys
2+
3+
from PyQt5.QtWidgets import QMessageBox
4+
from PyQt5 import sip
5+
6+
from cq_editor.__main__ import MainWindow
7+
from cq_editor.main_window import PRINT_REDIRECTOR
8+
9+
10+
def test_print_redirector_released_with_window(qtbot, mocker):
11+
"""
12+
PRINT_REDIRECTOR is a module-level singleton that outlives any MainWindow.
13+
Once a window's LogViewer is destroyed, a write must be dropped rather than
14+
delivered to the now-deleted C++ object (which raises "wrapped C/C++ object
15+
of type LogViewer has been deleted").
16+
"""
17+
18+
mocker.patch.object(QMessageBox, "question", return_value=QMessageBox.Yes)
19+
mocker.patch.object(QMessageBox, "warning", return_value=QMessageBox.Discard)
20+
21+
win = MainWindow()
22+
qtbot.addWidget(win)
23+
24+
# destroy this window's LogViewer, as tearing the window down would
25+
sip.delete(win.components["log"])
26+
27+
# PyQt routes an exception raised inside a slot to sys.excepthook rather
28+
# than propagating it, so capture it there while emitting
29+
errors = []
30+
original_hook = sys.excepthook
31+
sys.excepthook = lambda exc_type, *rest: errors.append(exc_type)
32+
try:
33+
PRINT_REDIRECTOR.sigStdoutWrite.emit("stray output after close")
34+
finally:
35+
sys.excepthook = original_hook
36+
37+
assert errors == []

0 commit comments

Comments
 (0)