Skip to content

Commit 9e8c8fa

Browse files
authored
Merge pull request #13306 from vdrhtc/qt5-sigint
Qt5: SIGINT kills just the mpl window and not the process itself
2 parents 2f140ff + c3772ae commit 9e8c8fa

File tree

3 files changed

+160
-31
lines changed

3 files changed

+160
-31
lines changed

lib/matplotlib/backends/backend_qt.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
QtCore, QtGui, QtWidgets, __version__, QT_API,
1818
_enum, _to_int,
1919
_devicePixelRatioF, _isdeleted, _setDevicePixelRatio,
20+
_maybe_allow_interrupt
2021
)
2122

23+
2224
backend_version = __version__
2325

2426
# SPECIAL_KEYS are Qt::Key that do *not* return their unicode name
@@ -399,7 +401,9 @@ def start_event_loop(self, timeout=0):
399401
if timeout > 0:
400402
timer = QtCore.QTimer.singleShot(int(timeout * 1000),
401403
event_loop.quit)
402-
qt_compat._exec(event_loop)
404+
405+
with _maybe_allow_interrupt(event_loop):
406+
qt_compat._exec(event_loop)
403407

404408
def stop_event_loop(self, event=None):
405409
# docstring inherited
@@ -1001,14 +1005,5 @@ class _BackendQT(_Backend):
10011005

10021006
@staticmethod
10031007
def mainloop():
1004-
old_signal = signal.getsignal(signal.SIGINT)
1005-
# allow SIGINT exceptions to close the plot window.
1006-
is_python_signal_handler = old_signal is not None
1007-
if is_python_signal_handler:
1008-
signal.signal(signal.SIGINT, signal.SIG_DFL)
1009-
try:
1008+
with _maybe_allow_interrupt(qApp):
10101009
qt_compat._exec(qApp)
1011-
finally:
1012-
# reset the SIGINT exception handler
1013-
if is_python_signal_handler:
1014-
signal.signal(signal.SIGINT, old_signal)

lib/matplotlib/backends/qt_compat.py

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
import os
1717
import platform
1818
import sys
19+
import signal
20+
import socket
21+
import contextlib
1922

2023
from packaging.version import parse as parse_version
2124

@@ -28,7 +31,7 @@
2831
QT_API_PYSIDE2 = "PySide2"
2932
QT_API_PYQTv2 = "PyQt4v2"
3033
QT_API_PYSIDE = "PySide"
31-
QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2).
34+
QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2).
3235
QT_API_ENV = os.environ.get("QT_API")
3336
if QT_API_ENV is not None:
3437
QT_API_ENV = QT_API_ENV.lower()
@@ -74,32 +77,33 @@
7477

7578

7679
def _setup_pyqt5plus():
77-
global QtCore, QtGui, QtWidgets, __version__, is_pyqt5, \
80+
global QtCore, QtGui, QtWidgets, QtNetwork, __version__, is_pyqt5, \
7881
_isdeleted, _getSaveFileName
7982

8083
if QT_API == QT_API_PYQT6:
81-
from PyQt6 import QtCore, QtGui, QtWidgets, sip
84+
from PyQt6 import QtCore, QtGui, QtWidgets, QtNetwork, sip
8285
__version__ = QtCore.PYQT_VERSION_STR
8386
QtCore.Signal = QtCore.pyqtSignal
8487
QtCore.Slot = QtCore.pyqtSlot
8588
QtCore.Property = QtCore.pyqtProperty
8689
_isdeleted = sip.isdeleted
8790
elif QT_API == QT_API_PYSIDE6:
88-
from PySide6 import QtCore, QtGui, QtWidgets, __version__
91+
from PySide6 import QtCore, QtGui, QtWidgets, QtNetwork, __version__
8992
import shiboken6
9093
def _isdeleted(obj): return not shiboken6.isValid(obj)
9194
elif QT_API == QT_API_PYQT5:
92-
from PyQt5 import QtCore, QtGui, QtWidgets
95+
from PyQt5 import QtCore, QtGui, QtWidgets, QtNetwork
9396
import sip
9497
__version__ = QtCore.PYQT_VERSION_STR
9598
QtCore.Signal = QtCore.pyqtSignal
9699
QtCore.Slot = QtCore.pyqtSlot
97100
QtCore.Property = QtCore.pyqtProperty
98101
_isdeleted = sip.isdeleted
99102
elif QT_API == QT_API_PYSIDE2:
100-
from PySide2 import QtCore, QtGui, QtWidgets, __version__
103+
from PySide2 import QtCore, QtGui, QtWidgets, QtNetwork, __version__
101104
import shiboken2
102-
def _isdeleted(obj): return not shiboken2.isValid(obj)
105+
def _isdeleted(obj):
106+
return not shiboken2.isValid(obj)
103107
else:
104108
raise AssertionError(f"Unexpected QT_API: {QT_API}")
105109
_getSaveFileName = QtWidgets.QFileDialog.getSaveFileName
@@ -134,7 +138,6 @@ def _isdeleted(obj): return not shiboken2.isValid(obj)
134138
"QT_MAC_WANTS_LAYER" not in os.environ):
135139
os.environ["QT_MAC_WANTS_LAYER"] = "1"
136140

137-
138141
# These globals are only defined for backcompatibility purposes.
139142
ETS = dict(pyqt5=(QT_API_PYQT5, 5), pyside2=(QT_API_PYSIDE2, 5))
140143

@@ -191,3 +194,62 @@ def _setDevicePixelRatio(obj, val):
191194
if hasattr(obj, 'setDevicePixelRatio'):
192195
# Not available on Qt4 or some older Qt5.
193196
obj.setDevicePixelRatio(val)
197+
198+
199+
@contextlib.contextmanager
200+
def _maybe_allow_interrupt(qapp):
201+
"""
202+
This manager allows to terminate a plot by sending a SIGINT. It is
203+
necessary because the running Qt backend prevents Python interpreter to
204+
run and process signals (i.e., to raise KeyboardInterrupt exception). To
205+
solve this one needs to somehow wake up the interpreter and make it close
206+
the plot window. We do this by using the signal.set_wakeup_fd() function
207+
which organizes a write of the signal number into a socketpair connected
208+
to the QSocketNotifier (since it is part of the Qt backend, it can react
209+
to that write event). Afterwards, the Qt handler empties the socketpair
210+
by a recv() command to re-arm it (we need this if a signal different from
211+
SIGINT was caught by set_wakeup_fd() and we shall continue waiting). If
212+
the SIGINT was caught indeed, after exiting the on_signal() function the
213+
interpreter reacts to the SIGINT according to the handle() function which
214+
had been set up by a signal.signal() call: it causes the qt_object to
215+
exit by calling its quit() method. Finally, we call the old SIGINT
216+
handler with the same arguments that were given to our custom handle()
217+
handler.
218+
219+
We do this only if the old handler for SIGINT was not None, which means
220+
that a non-python handler was installed, i.e. in Julia, and not SIG_IGN
221+
which means we should ignore the interrupts.
222+
"""
223+
old_sigint_handler = signal.getsignal(signal.SIGINT)
224+
handler_args = None
225+
skip = False
226+
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
227+
skip = True
228+
else:
229+
wsock, rsock = socket.socketpair()
230+
wsock.setblocking(False)
231+
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
232+
sn = QtCore.QSocketNotifier(
233+
rsock.fileno(), _enum('QtCore.QSocketNotifier.Type').Read
234+
)
235+
236+
# Clear the socket to re-arm the notifier.
237+
sn.activated.connect(lambda *args: rsock.recv(1))
238+
239+
def handle(*args):
240+
nonlocal handler_args
241+
handler_args = args
242+
qapp.quit()
243+
244+
signal.signal(signal.SIGINT, handle)
245+
try:
246+
yield
247+
finally:
248+
if not skip:
249+
wsock.close()
250+
rsock.close()
251+
sn.setEnabled(False)
252+
signal.set_wakeup_fd(old_wakeup_fd)
253+
signal.signal(signal.SIGINT, old_sigint_handler)
254+
if handler_args is not None:
255+
old_sigint_handler(*handler_args)

lib/matplotlib/tests/test_backend_qt.py

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,22 @@ def qt_core(request):
3333
return QtCore
3434

3535

36+
@pytest.fixture
37+
def platform_simulate_ctrl_c(request):
38+
import signal
39+
from functools import partial
40+
41+
if hasattr(signal, "CTRL_C_EVENT"):
42+
from win32api import GenerateConsoleCtrlEvent
43+
return partial(GenerateConsoleCtrlEvent, 0, 0)
44+
else:
45+
# we're not on windows
46+
return partial(os.kill, os.getpid(), signal.SIGINT)
47+
48+
3649
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
3750
def test_fig_close():
51+
3852
# save the state of Gcf.figs
3953
init_figs = copy.copy(Gcf.figs)
4054

@@ -51,18 +65,65 @@ def test_fig_close():
5165

5266

5367
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
54-
def test_fig_signals(qt_core):
68+
@pytest.mark.parametrize("target, kwargs", [
69+
(plt.show, {"block": True}),
70+
(plt.pause, {"interval": 10})
71+
])
72+
def test_sigint(qt_core, platform_simulate_ctrl_c, target,
73+
kwargs):
74+
plt.figure()
75+
def fire_signal():
76+
platform_simulate_ctrl_c()
77+
78+
qt_core.QTimer.singleShot(100, fire_signal)
79+
with pytest.raises(KeyboardInterrupt):
80+
target(**kwargs)
81+
82+
83+
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
84+
@pytest.mark.parametrize("target, kwargs", [
85+
(plt.show, {"block": True}),
86+
(plt.pause, {"interval": 10})
87+
])
88+
def test_other_signal_before_sigint(qt_core, platform_simulate_ctrl_c,
89+
target, kwargs):
90+
plt.figure()
91+
92+
sigcld_caught = False
93+
def custom_sigpipe_handler(signum, frame):
94+
nonlocal sigcld_caught
95+
sigcld_caught = True
96+
signal.signal(signal.SIGCLD, custom_sigpipe_handler)
97+
98+
def fire_other_signal():
99+
os.kill(os.getpid(), signal.SIGCLD)
100+
101+
def fire_sigint():
102+
platform_simulate_ctrl_c()
103+
104+
qt_core.QTimer.singleShot(50, fire_other_signal)
105+
qt_core.QTimer.singleShot(100, fire_sigint)
106+
107+
with pytest.raises(KeyboardInterrupt):
108+
target(**kwargs)
109+
110+
assert sigcld_caught
111+
112+
113+
@pytest.mark.backend('Qt5Agg')
114+
def test_fig_sigint_override(qt_core):
115+
from matplotlib.backends.backend_qt5 import _BackendQT5
55116
# Create a figure
56117
plt.figure()
57118

58-
# Access signals
59-
event_loop_signal = None
119+
# Variable to access the handler from the inside of the event loop
120+
event_loop_handler = None
60121

61122
# Callback to fire during event loop: save SIGINT handler, then exit
62123
def fire_signal_and_quit():
63124
# Save event loop signal
64-
nonlocal event_loop_signal
65-
event_loop_signal = signal.getsignal(signal.SIGINT)
125+
nonlocal event_loop_handler
126+
event_loop_handler = signal.getsignal(signal.SIGINT)
66127

67128
# Request event loop exit
68129
qt_core.QCoreApplication.exit()
@@ -71,26 +132,37 @@ def fire_signal_and_quit():
71132
qt_core.QTimer.singleShot(0, fire_signal_and_quit)
72133

73134
# Save original SIGINT handler
74-
original_signal = signal.getsignal(signal.SIGINT)
135+
original_handler = signal.getsignal(signal.SIGINT)
75136

76137
# Use our own SIGINT handler to be 100% sure this is working
77-
def CustomHandler(signum, frame):
138+
def custom_handler(signum, frame):
78139
pass
79140

80-
signal.signal(signal.SIGINT, CustomHandler)
141+
signal.signal(signal.SIGINT, custom_handler)
81142

82143
# mainloop() sets SIGINT, starts Qt event loop (which triggers timer and
83144
# exits) and then mainloop() resets SIGINT
84145
matplotlib.backends.backend_qt._BackendQT.mainloop()
85146

86-
# Assert: signal handler during loop execution is signal.SIG_DFL
87-
assert event_loop_signal == signal.SIG_DFL
147+
# Assert: signal handler during loop execution is changed
148+
# (can't test equality with func)
149+
assert event_loop_handler != custom_handler
88150

89151
# Assert: current signal handler is the same as the one we set before
90-
assert CustomHandler == signal.getsignal(signal.SIGINT)
152+
assert signal.getsignal(signal.SIGINT) == custom_handler
153+
154+
# Repeat again to test that SIG_DFL and SIG_IGN will not be overridden
155+
for custom_handler in (signal.SIG_DFL, signal.SIG_IGN):
156+
qt_core.QTimer.singleShot(0, fire_signal_and_quit)
157+
signal.signal(signal.SIGINT, custom_handler)
158+
159+
_BackendQT5.mainloop()
160+
161+
assert event_loop_handler == custom_handler
162+
assert signal.getsignal(signal.SIGINT) == custom_handler
91163

92164
# Reset SIGINT handler to what it was before the test
93-
signal.signal(signal.SIGINT, original_signal)
165+
signal.signal(signal.SIGINT, original_handler)
94166

95167

96168
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)