Skip to content

Commit ac27a17

Browse files
vdrhtctacaswell
authored andcommitted
MNT: fix interrupt handling in Qt event loop
signal.set_wakeup_fd() used instead, start_event_loop() is interruptible now, allow_interrupt context manager, do not override None, SIG_DFL, SIG_IGN
1 parent af1c95f commit ac27a17

File tree

3 files changed

+123
-24
lines changed

3 files changed

+123
-24
lines changed

lib/matplotlib/backends/backend_qt.py

Lines changed: 12 additions & 7 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+
_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,13 @@ 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+
old_sigint_handler = signal.getsignal(signal.SIGINT)
406+
try:
407+
with _allow_interrupt(qApp, old_sigint_handler):
408+
qt_compat._exec(event_loop)
409+
except ValueError:
410+
qt_compat._exec(event_loop)
403411

404412
def stop_event_loop(self, event=None):
405413
# docstring inherited
@@ -1005,10 +1013,7 @@ def mainloop():
10051013
# allow SIGINT exceptions to close the plot window.
10061014
is_python_signal_handler = old_signal is not None
10071015
if is_python_signal_handler:
1008-
signal.signal(signal.SIGINT, signal.SIG_DFL)
1009-
try:
1016+
with _allow_interrupt(qApp, old_signal):
1017+
qt_compat._exec(qApp)
1018+
else:
10101019
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: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import os
1717
import platform
1818
import sys
19+
import signal
20+
import socket
1921

2022
from packaging.version import parse as parse_version
2123

@@ -74,30 +76,30 @@
7476

7577

7678
def _setup_pyqt5plus():
77-
global QtCore, QtGui, QtWidgets, __version__, is_pyqt5, \
79+
global QtCore, QtGui, QtWidgets, QtNetwork, __version__, is_pyqt5, \
7880
_isdeleted, _getSaveFileName
7981

8082
if QT_API == QT_API_PYQT6:
81-
from PyQt6 import QtCore, QtGui, QtWidgets, sip
83+
from PyQt6 import QtCore, QtGui, QtWidgets, QtNetwork, sip
8284
__version__ = QtCore.PYQT_VERSION_STR
8385
QtCore.Signal = QtCore.pyqtSignal
8486
QtCore.Slot = QtCore.pyqtSlot
8587
QtCore.Property = QtCore.pyqtProperty
8688
_isdeleted = sip.isdeleted
8789
elif QT_API == QT_API_PYSIDE6:
88-
from PySide6 import QtCore, QtGui, QtWidgets, __version__
90+
from PySide6 import QtCore, QtGui, QtWidgets, QtNetwork, __version__
8991
import shiboken6
9092
def _isdeleted(obj): return not shiboken6.isValid(obj)
9193
elif QT_API == QT_API_PYQT5:
92-
from PyQt5 import QtCore, QtGui, QtWidgets
94+
from PyQt5 import QtCore, QtGui, QtWidgets, QtNetwork
9395
import sip
9496
__version__ = QtCore.PYQT_VERSION_STR
9597
QtCore.Signal = QtCore.pyqtSignal
9698
QtCore.Slot = QtCore.pyqtSlot
9799
QtCore.Property = QtCore.pyqtProperty
98100
_isdeleted = sip.isdeleted
99101
elif QT_API == QT_API_PYSIDE2:
100-
from PySide2 import QtCore, QtGui, QtWidgets, __version__
102+
from PySide2 import QtCore, QtGui, QtWidgets, QtNetwork, __version__
101103
import shiboken2
102104
def _isdeleted(obj): return not shiboken2.isValid(obj)
103105
else:
@@ -191,3 +193,49 @@ def _setDevicePixelRatio(obj, val):
191193
if hasattr(obj, 'setDevicePixelRatio'):
192194
# Not available on Qt4 or some older Qt5.
193195
obj.setDevicePixelRatio(val)
196+
197+
198+
class _allow_interrupt:
199+
def __init__(self, qApp, old_sigint_handler):
200+
self.interrupted_qobject = qApp
201+
self.old_fd = None
202+
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
203+
raise ValueError(f"Old SIGINT handler {old_sigint_handler}"
204+
f" will not be overridden")
205+
self.old_sigint_handler = old_sigint_handler
206+
self.caught_args = None
207+
208+
QAS = QtNetwork.QAbstractSocket
209+
self.qt_socket = QAS(QAS.TcpSocket, qApp)
210+
# Create a socket pair
211+
self.wsock, self.rsock = socket.socketpair()
212+
# Let Qt listen on the one end
213+
self.qt_socket.setSocketDescriptor(self.rsock.fileno())
214+
self.wsock.setblocking(False)
215+
self.qt_socket.readyRead.connect(self._readSignal)
216+
217+
def __enter__(self):
218+
signal.signal(signal.SIGINT, self._handle)
219+
# And let Python write on the other end
220+
self.old_fd = signal.set_wakeup_fd(self.wsock.fileno())
221+
222+
def __exit__(self, type, val, traceback):
223+
signal.set_wakeup_fd(self.old_fd)
224+
signal.signal(signal.SIGINT, self.old_sigint_handler)
225+
226+
self.wsock.close()
227+
self.rsock.close()
228+
self.qt_socket.abort()
229+
if self.caught_args is not None:
230+
self.old_sigint_handler(*self.caught_args)
231+
232+
def _readSignal(self):
233+
# Read the written byte.
234+
# Note: readyRead is blocked from
235+
# occurring again until readData() was called, so call it,
236+
# even if you don't need the value.
237+
self.qt_socket.readData(1)
238+
239+
def _handle(self, *args):
240+
self.caught_args = args
241+
self.interrupted_qobject.quit()

lib/matplotlib/tests/test_backend_qt.py

Lines changed: 58 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,39 @@ 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+
try:
80+
target(**kwargs)
81+
except KeyboardInterrupt as e:
82+
assert True
83+
else:
84+
assert False # KeyboardInterrupt must be raised
85+
86+
87+
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
88+
def test_fig_sigint_override(qt_core):
89+
from matplotlib.backends.backend_qt5 import _BackendQT5
5590
# Create a figure
5691
plt.figure()
5792

58-
# Access signals
59-
event_loop_signal = None
93+
# Variable to access the handler from the inside of the event loop
94+
event_loop_handler = None
6095

6196
# Callback to fire during event loop: save SIGINT handler, then exit
6297
def fire_signal_and_quit():
6398
# Save event loop signal
64-
nonlocal event_loop_signal
65-
event_loop_signal = signal.getsignal(signal.SIGINT)
99+
nonlocal event_loop_handler
100+
event_loop_handler = signal.getsignal(signal.SIGINT)
66101

67102
# Request event loop exit
68103
qt_core.QCoreApplication.exit()
@@ -71,26 +106,37 @@ def fire_signal_and_quit():
71106
qt_core.QTimer.singleShot(0, fire_signal_and_quit)
72107

73108
# Save original SIGINT handler
74-
original_signal = signal.getsignal(signal.SIGINT)
109+
original_handler = signal.getsignal(signal.SIGINT)
75110

76111
# Use our own SIGINT handler to be 100% sure this is working
77-
def CustomHandler(signum, frame):
112+
def custom_handler(signum, frame):
78113
pass
79114

80-
signal.signal(signal.SIGINT, CustomHandler)
115+
signal.signal(signal.SIGINT, custom_handler)
81116

82117
# mainloop() sets SIGINT, starts Qt event loop (which triggers timer and
83118
# exits) and then mainloop() resets SIGINT
84119
matplotlib.backends.backend_qt._BackendQT.mainloop()
85120

86-
# Assert: signal handler during loop execution is signal.SIG_DFL
87-
assert event_loop_signal == signal.SIG_DFL
121+
# Assert: signal handler during loop execution is changed
122+
# (can't test equality with func)
123+
assert event_loop_handler != custom_handler
88124

89125
# Assert: current signal handler is the same as the one we set before
90-
assert CustomHandler == signal.getsignal(signal.SIGINT)
126+
assert signal.getsignal(signal.SIGINT) == custom_handler
127+
128+
# Repeat again to test that SIG_DFL and SIG_IGN will not be overridden
129+
for custom_handler in (signal.SIG_DFL, signal.SIG_IGN):
130+
qt_core.QTimer.singleShot(0, fire_signal_and_quit)
131+
signal.signal(signal.SIGINT, custom_handler)
132+
133+
_BackendQT5.mainloop()
134+
135+
assert event_loop_handler == custom_handler
136+
assert signal.getsignal(signal.SIGINT) == custom_handler
91137

92138
# Reset SIGINT handler to what it was before the test
93-
signal.signal(signal.SIGINT, original_signal)
139+
signal.signal(signal.SIGINT, original_handler)
94140

95141

96142
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)