Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SystemError on disconnect with pyside 6.7.1 #558

Closed
tlambert03 opened this issue May 28, 2024 · 8 comments
Closed

SystemError on disconnect with pyside 6.7.1 #558

tlambert03 opened this issue May 28, 2024 · 8 comments

Comments

@tlambert03
Copy link

Don't see an existing issue for this yet, so opening one. This seems related to #552, but has now turned into an exception with pyside 6.7.1:

from PySide6.QtWidgets import QLineEdit

def test_thing(qtbot):
    wdg = QLineEdit()
    qtbot.addWidget(wdg)
    with qtbot.waitSignal(wdg.textChanged):
        wdg.setText("hello")

running this test raises:

tests/test_thing.py F                                                    [100%]

=================================== FAILURES ===================================
__________________________________ test_thing __________________________________
CALL ERROR: Exceptions caught in Qt event loop:
________________________________________________________________________________
RuntimeWarning: Failed to disconnect (<bound method _AbstractSignalBlocker._quit_loop_by_timeout of <pytestqt.wait_signal.SignalBlocker object at 0x113115c50>>) from signal "timeout()".

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py", line 219, in _quit_loop_by_signal
    self._cleanup()
  File "/Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py", line 224, in _cleanup
    super()._cleanup()
  File "/Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py", line 66, in _cleanup
    _silent_disconnect(self._timer.timeout, self._quit_loop_by_timeout)
  File "/Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py", line 741, in _silent_disconnect
    signal.disconnect(slot)
SystemError: <method 'disconnect' of 'PySide6.QtCore.SignalInstance' objects> returned a result with an exception set
________________________________________________________________________________
----------------------------- Captured stderr call -----------------------------
Exceptions caught in Qt event loop:
________________________________________________________________________________
RuntimeWarning: Failed to disconnect (<bound method _AbstractSignalBlocker._quit_loop_by_timeout of <pytestqt.wait_signal.SignalBlocker object at 0x113115c50>>) from signal "timeout()".

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py", line 219, in _quit_loop_by_signal
    self._cleanup()
  File "/Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py", line 224, in _cleanup
    super()._cleanup()
  File "/Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py", line 66, in _cleanup
    _silent_disconnect(self._timer.timeout, self._quit_loop_by_timeout)
  File "/Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py", line 741, in _silent_disconnect
    signal.disconnect(slot)
SystemError: <method 'disconnect' of 'PySide6.QtCore.SignalInstance' objects> returned a result with an exception set
________________________________________________________________________________
=========================== short test summary info ============================
FAILED tests/test_thing.py::test_thing - Failed: CALL ERROR: Exceptions caught in Qt event loop:
============================== 1 failed in 0.17s ===============================

on Pyside 6.7.0, this worked but caused the RuntimeError warning as noted in #552.

tests/test_thing.py .                                                    [100%]

=============================== warnings summary ===============================
tests/test_thing.py::test_thing
  /Users/talley/miniforge3/envs/superqt/lib/python3.11/site-packages/pytestqt/wait_signal.py:741: RuntimeError: Failed to disconnect (<bound method _AbstractSignalBlocker._quit_loop_by_timeout of <pytestqt.wait_signal.SignalBlocker object at 0x108880d50>>) from signal "timeout()".
    signal.disconnect(slot)

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 1 passed, 1 warning in 0.52s =========================

@nicoddemus
Copy link
Member

SystemError is often an internal error in the bindings... I would report this upstream, seems like a bug to me.

@tlambert03
Copy link
Author

k

@tlambert03
Copy link
Author

would it be trivial for you to suggest an MRE without using pytest-qt's _AbstractSignalBlocker? it's ok if not

@nicoddemus
Copy link
Member

would it be trivial for you to suggest an MRE without using pytest-qt's _AbstractSignalBlocker? it's ok if not

I don't have the time to work on it right now, but I suspect if you just connect a signal and try to disconnect it twice you will get the SystemError.

@nicoddemus
Copy link
Member

If you do report it upstream, please link it here for reference. 👍

@bersbersbers
Copy link

PYSIDE-2705 suggests that the exception-like warning in 6.7.0 has been converted to an actual warning, compare https://codereview.qt-project.org/c/pyside/pyside-setup/+/558142/3/sources/pyside6/libpyside/pysidesignal.cpp. So there should be no more (unhandled) RuntimeError.

I was able to suppress these errors like this (pyproject.toml):

[tool.pytest.ini_options]
filterwarnings = [
    # https://github.com/pytest-dev/pytest-qt/issues/558
    "ignore:Failed to disconnect .* from signal:RuntimeWarning",
]

That gets rid of pytest reporting both the new RuntimeWarning as well as the underlying, and seemingly caught, SystemError.

@tlambert03
Copy link
Author

ahh, thank you for noting that. indeed I turn my warnings into exceptions, and I failed to note that this was just a warning and not an actual exception. adding that ignore works fine for my case as well. I'll close this issue, but @nicoddemus feel free to reopen if you want to track anything else related to this change

The-Compiler added a commit that referenced this issue Mar 22, 2025
- 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.
The-Compiler added a commit that referenced this issue Mar 22, 2025
- 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.
@The-Compiler
Copy link
Member

This should be fixed properly with #596.

FWIW the PySide6 fix seems incomplete too, as with -Werror it still results in a SystemError, but oh well.

The-Compiler added a commit that referenced this issue Mar 22, 2025
- 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.
The-Compiler added a commit that referenced this issue Mar 22, 2025
- 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.
The-Compiler added a commit that referenced this issue Mar 22, 2025
- 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.
The-Compiler added a commit that referenced this issue Mar 22, 2025
- 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.
The-Compiler added a commit that referenced this issue Mar 25, 2025
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants