Skip to content

Commit 0245b31

Browse files
committed
Fix and clean up timer handling in signal/callback blockers
- Always create the timeout `QTimer`, even if it's not going to be used. - Connect the signal once in `_AbstractSignalBlocker`/`CallbackBlocker.__init__` and never disconnect it. - When checking whether a timeout was set, simply check `self._timeout` instead of `self._timer`. Before this change, we conditionally created a `QTimer` and deleted it again when cleaning up. In addition to coming with hard to follow complexity, this also caused multiple issues around this timer: Warnings about disconnecting signal =================================== In `AbstractSignalBlocker._cleanup`, we attempted to disconnect `self._timer.timeout()` from `self._quit_loop_by_timeout`, under the condition that a timer was created in `__init__` (i.e. `self.timeout` was not `0` or `None`). However, the signal only got connected in `AbstractSignalBlocker.wait()`. Thus, if `AbstractSignalBlocker` is used as a context manager with a timeout and the signal is emitted inside it: - `self._timer` is present - The signal calls `self._quit_loop_by_signal()` - `self._cleanup()` gets called from there - That tries to disconnect `self._timer.timeout()` from `self._quit_loop_by_timeout()` - Since `self.wait()` was never called, the signal was never connected - Which then results in either an exception (PyQt), an internal SystemError (older PySide6) or a warning (newer PySide6). In 560f565 and later 81b317c this was fixed by ignoring `TypeError`/`RuntimeError`, but that turned out to not be sufficient for newer PySide versions: #552, #558. The proper fix for this is to not attempt to disconnect a signal that was never connected, which makes all the PySide6 warnings go away (and thus we can run our testsuite with `-Werror` now). As a drive-by fix, this also removes the old `filterwarnings` mark definition, which was introduced in 95fee8b (perhaps as a stop-gap for older pytest versions?) but shouldn't be needed anymore (and is unused since e338809). AttributeError when a signal/callback is emitted from a different thread ======================================================================== If a signal is emitted from a thread, `_AbstractSignalBlocker._cleanup()` also gets called from the non-main thread (Qt will complain: "QObject::killTimer: Timers cannot be stopped from another thread"). If the signal emission just happened to happen between the None-check and calling `.start()` in `wait()`, it failed with an `AttributeError`: Main thread in `AbstractSignalBlocker.wait()`: ```python if self._timer is not None: # <--- this was true... self._timer.timeout.connect(self._quit_loop_by_timeout) # <--- ...now here, but self._timer is None now! self._timer.start() ```` Emitting thread in `AbstractSignalBlocker._cleanup()` via `._quit_loop_by_signal()`: ```python if self._timer is not None: ... self._timer = None # <--- here ``` In SignalBlocker.connect, we used: ```python actual_signal.connect(self._quit_loop_by_signal) ``` which by default is supposed to use a `QueuedConnection` if the signal gets emitted in a different thread: https://doc.qt.io/qt-6/qt.html#ConnectionType-enum (Default) If the receiver lives in the thread that emits the signal, `Qt::DirectConnection` is used. Otherwise, `Qt::QueuedConnection` is used. The connection type is determined when the signal is emitted. though then that page says: https://doc.qt.io/qt-6/qobject.html#thread-affinity Note: If a `QObject` has no thread affinity (that is, if `thread()` returns zero), or if it lives in a thread that has no running event loop, then it cannot receive queued signals or posted events. Which means `AbstractSignalBlocker` needs to be a `QObject` for this to work. However, that means we need to use `qt_api.QtCore.QObject` as subclass, i.e. at import time of `wait_signal.py`. Yet, `qt_api` only gets initialized in `pytest_configure` so that it's configurable via a pytest setting. Unfortunately, doing that is tricky, as it means we can't import `wait_signal.py` at all during import time, and people might do so for e.g. type annotations. With this refactoring, the `AttributeError` is out of the way, though there are other subtle failures with multi-threaded signals now: 1) `_quit_loop_by_signal()` -> `_cleanup()` now simply calls `self._timer.stop()` without setting `self._timer` to `None`. This still results in the same Qt message quoted above (after all, the timer still doesn't belong to the calling thread!), but it looks like the test terminates without any timeout anyways. From what I can gather, while the "low level timer" continues to run (and waste a minimal amount of resources), the QTimer still "detaches" from it and stops running. The commit adds a test to catch this case (currently marked as xfail). 2) The main thread in `wait()` can now still call `self._timer.start()` without an `AttributeError`. However, in theory this could restart the timer after it was already stopped by the signal emission, with a race between `_cleanup()` and `wait()`. See #586. This fixes the test-case posted by the reporter (added to the testsuite in a simplified version), but not the additional considerations above. The same fix is also applied to `CallbackBlocker`, though the test there is way more unreliable in triggering the issue, and thus is skipped for taking too long.
1 parent ce1a689 commit 0245b31

File tree

4 files changed

+162
-28
lines changed

4 files changed

+162
-28
lines changed

CHANGELOG.rst

+14-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
UNRELEASED
22
----------
33

4-
* Added official support for Python 3.13.
5-
* Dropped support for EOL Python 3.8.
6-
* Dropped support for EOL PySide 2.
4+
- Added official support for Python 3.13.
5+
- Dropped support for EOL Python 3.8.
6+
- Dropped support for EOL PySide 2.
7+
- Fixed PySide6 exceptions / warnings about being unable to disconnect signals
8+
with ``qtbot.waitSignal`` (`#552`_, `#558`_).
9+
- Reduced the likelyhood of trouble when using ``qtbot.waitSignal(s)`` and
10+
``qtbot.waitCallback`` where the signal/callback is emitted from a non-main
11+
thread. In theory, more problems remain and this isn't a proper fix yet. In
12+
practice, it seems impossible to provoke any problems in pytest-qt's testsuite.
13+
(`#586`_)
14+
15+
.. _#552: https://github.com/pytest-dev/pytest-qt/issues/552
16+
.. _#558: https://github.com/pytest-dev/pytest-qt/issues/558
17+
.. _#586: https://github.com/pytest-dev/pytest-qt/issues/586
718

819
4.4.0 (2024-02-07)
920
------------------

pytest.ini

+1-2
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,4 @@
22
testpaths = tests
33
addopts = --strict-markers --strict-config
44
xfail_strict = true
5-
markers =
6-
filterwarnings: pytest's filterwarnings marker
5+
filterwarnings = error

src/pytestqt/wait_signal.py

+18-23
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ def __init__(self, timeout=5000, raising=True):
2424
self.raising = raising
2525
self._signals = None # will be initialized by inheriting implementations
2626
self._timeout_message = ""
27-
if timeout is None or timeout == 0:
28-
self._timer = None
29-
else:
30-
self._timer = qt_api.QtCore.QTimer(self._loop)
31-
self._timer.setSingleShot(True)
27+
28+
self._timer = qt_api.QtCore.QTimer(self._loop)
29+
self._timer.setSingleShot(True)
30+
if timeout is not None:
3231
self._timer.setInterval(timeout)
32+
self._timer.timeout.connect(self._quit_loop_by_timeout)
3333

3434
def wait(self):
3535
"""
@@ -43,11 +43,13 @@ def wait(self):
4343
return
4444
if self.timeout is None and not self._signals:
4545
raise ValueError("No signals or timeout specified.")
46-
if self._timer is not None:
47-
self._timer.timeout.connect(self._quit_loop_by_timeout)
48-
self._timer.start()
4946

5047
if self.timeout != 0:
48+
if self.timeout is not None:
49+
# asserts as a stop-gap for possible multithreading issues
50+
assert not self.signal_triggered
51+
self._timer.start()
52+
assert not self.signal_triggered
5153
qt_api.exec(self._loop)
5254

5355
if not self.signal_triggered and self.raising:
@@ -62,10 +64,7 @@ def _quit_loop_by_timeout(self):
6264
def _cleanup(self):
6365
# store timeout message before the data to construct it is lost
6466
self._timeout_message = self._get_timeout_error_message()
65-
if self._timer is not None:
66-
_silent_disconnect(self._timer.timeout, self._quit_loop_by_timeout)
67-
self._timer.stop()
68-
self._timer = None
67+
self._timer.stop()
6968

7069
def _get_timeout_error_message(self):
7170
"""Subclasses have to implement this, returning an appropriate error message for a TimeoutError."""
@@ -649,12 +648,12 @@ def __init__(self, timeout=5000, raising=True):
649648
self.kwargs = None
650649
self.called = False
651650
self._loop = qt_api.QtCore.QEventLoop()
652-
if timeout is None:
653-
self._timer = None
654-
else:
655-
self._timer = qt_api.QtCore.QTimer(self._loop)
656-
self._timer.setSingleShot(True)
651+
652+
self._timer = qt_api.QtCore.QTimer(self._loop)
653+
self._timer.setSingleShot(True)
654+
if timeout is not None:
657655
self._timer.setInterval(timeout)
656+
self._timer.timeout.connect(self._quit_loop_by_timeout)
658657

659658
def wait(self):
660659
"""
@@ -664,8 +663,7 @@ def wait(self):
664663
__tracebackhide__ = True
665664
if self.called:
666665
return
667-
if self._timer is not None:
668-
self._timer.timeout.connect(self._quit_loop_by_timeout)
666+
if self.timeout is not None:
669667
self._timer.start()
670668
qt_api.exec(self._loop)
671669
if not self.called and self.raising:
@@ -687,10 +685,7 @@ def _quit_loop_by_timeout(self):
687685
self._loop.quit()
688686

689687
def _cleanup(self):
690-
if self._timer is not None:
691-
_silent_disconnect(self._timer.timeout, self._quit_loop_by_timeout)
692-
self._timer.stop()
693-
self._timer = None
688+
self._timer.stop()
694689

695690
def __call__(self, *args, **kwargs):
696691
# Not inside the try: block, as if self.called is True, we did quit the

tests/test_wait_signal.py

+129
Original file line numberDiff line numberDiff line change
@@ -1364,3 +1364,132 @@ def test_timeout_not_raising(self, qtbot):
13641364
assert not callback.called
13651365
assert callback.args is None
13661366
assert callback.kwargs is None
1367+
1368+
1369+
@pytest.mark.parametrize(
1370+
"check_stderr, count",
1371+
[
1372+
# Checking stderr messages
1373+
pytest.param(
1374+
True, # check stderr
1375+
200, # gets output reliably even with only few runs (often the first)
1376+
id="stderr",
1377+
),
1378+
# Triggering AttributeError
1379+
pytest.param(
1380+
False, # don't check stderr
1381+
# Hopefully enough to trigger the AttributeError race condition reliably.
1382+
# With 500 runs, only 1 of 5 Windows PySide6 CI jobs triggered it (but all
1383+
# Ubuntu/macOS jobs did). With 1500 runs, Windows jobs still only triggered
1384+
# it 0-2 times.
1385+
#
1386+
# On my machine (Linux, Intel Core Ultra 9 185H), 500 runs trigger it
1387+
# reliably and take ~1s in total.
1388+
2500 if sys.platform == "win32" else 500,
1389+
id="attributeerror",
1390+
),
1391+
],
1392+
)
1393+
@pytest.mark.parametrize("multi_blocker", [True, False])
1394+
def test_signal_raised_from_thread(
1395+
pytester: pytest.Pytester, check_stderr: bool, multi_blocker: bool, count: int
1396+
) -> None:
1397+
"""Wait for a signal with a thread.
1398+
1399+
Extracted from https://github.com/pytest-dev/pytest-qt/issues/586
1400+
"""
1401+
pytester.makepyfile(
1402+
f"""
1403+
import pytest
1404+
from pytestqt.qt_compat import qt_api
1405+
1406+
1407+
class Worker(qt_api.QtCore.QObject):
1408+
signal = qt_api.Signal()
1409+
1410+
1411+
@pytest.mark.parametrize("_", range({count}))
1412+
def test_thread(qtbot, capfd, _):
1413+
worker = Worker()
1414+
thread = qt_api.QtCore.QThread()
1415+
worker.moveToThread(thread)
1416+
thread.start()
1417+
1418+
try:
1419+
if {multi_blocker}: # multi_blocker
1420+
with qtbot.waitSignals([worker.signal], timeout=500) as blocker:
1421+
worker.signal.emit()
1422+
else:
1423+
with qtbot.waitSignal(worker.signal, timeout=500) as blocker:
1424+
worker.signal.emit()
1425+
finally:
1426+
thread.quit()
1427+
thread.wait()
1428+
1429+
if {check_stderr}: # check_stderr
1430+
out, err = capfd.readouterr()
1431+
assert not err
1432+
"""
1433+
)
1434+
1435+
res = pytester.runpytest_subprocess("-x", "-s")
1436+
outcomes = res.parseoutcomes()
1437+
1438+
if outcomes.get("failed", 0) and check_stderr and qt_api.pytest_qt_api == "pyside6":
1439+
# The test succeeds on PyQt (unsure why!), but we can't check
1440+
# qt_api.pytest_qt_api at import time, so we can't use
1441+
# pytest.mark.xfail conditionally.
1442+
pytest.xfail(
1443+
"Qt error: QObject::killTimer: "
1444+
"Timers cannot be stopped from another thread"
1445+
)
1446+
1447+
res.assert_outcomes(passed=outcomes["passed"]) # no failed/error
1448+
1449+
1450+
@pytest.mark.skip(reason="Runs ~1min to reproduce bug reliably")
1451+
def test_callback_in_thread(pytester: pytest.Pytester) -> None:
1452+
"""Wait for a callback with a thread.
1453+
1454+
Inspired by https://github.com/pytest-dev/pytest-qt/issues/586
1455+
"""
1456+
# Hopefully enough to trigger the bug reliably.
1457+
#
1458+
# On my machine (Linux, Intel Core Ultra 9 185H), sometimes the bug only
1459+
# triggers after ~30k runs (~44s). Thus, we skip this test by default.
1460+
count = 50_000
1461+
1462+
pytester.makepyfile(
1463+
f"""
1464+
import pytest
1465+
from pytestqt.qt_compat import qt_api
1466+
1467+
1468+
class Worker(qt_api.QtCore.QObject):
1469+
def __init__(self, callback):
1470+
super().__init__()
1471+
self.callback = callback
1472+
1473+
def call_callback(self):
1474+
self.callback()
1475+
1476+
1477+
@pytest.mark.parametrize("_", range({count}))
1478+
def test_thread(qtbot, _):
1479+
thread = qt_api.QtCore.QThread()
1480+
1481+
try:
1482+
with qtbot.waitCallback() as callback:
1483+
worker = Worker(callback)
1484+
worker.moveToThread(thread)
1485+
thread.started.connect(worker.call_callback)
1486+
thread.start()
1487+
finally:
1488+
thread.quit()
1489+
thread.wait()
1490+
"""
1491+
)
1492+
1493+
res = pytester.runpytest_subprocess("-x")
1494+
outcomes = res.parseoutcomes()
1495+
res.assert_outcomes(passed=outcomes["passed"]) # no failed/error

0 commit comments

Comments
 (0)