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

"RuntimeError: Failed to disconnect" with PySide6==6.7.0 #552

Closed
bersbersbers opened this issue Apr 3, 2024 · 20 comments
Closed

"RuntimeError: Failed to disconnect" with PySide6==6.7.0 #552

bersbersbers opened this issue Apr 3, 2024 · 20 comments

Comments

@bersbersbers
Copy link

I recently tried upgrading to an early PySide6==6.7.0 (via https://download.qt.io/snapshots/ci/pyside/6.7/latest/pyside6), and I find that pytest then issues a lot of warnings that I cannot seem to suppress:

c:\Code\project.venv\Lib\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 0x000001BE3BDA6390>>) from signal "timeout()".
signal.disconnect(slot)

filterwarnings = ["ignore"] does not seem to have an effect, possibly because RuntimeError is not a proper warning.

@bersbersbers bersbersbers changed the title "RuntimeError: Failed to disconnect" with `PySide6==6.7.0" "RuntimeError: Failed to disconnect" with PySide6==6.7.0 Apr 3, 2024
@nicoddemus
Copy link
Member

It is probably because the exception is being raised inside the Qt event loop.

@penguinpee
Copy link
Contributor

I'm seeing the same here:

=================================== FAILURES ===================================
________________________________ test_destroyed ________________________________
CALL ERROR: Exceptions caught in Qt event loop:
________________________________________________________________________________
RuntimeError: Internal C++ object (Obj) already deleted.
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
  File "/builddir/build/BUILDROOT/python-pytest-qt-4.4.0-2.fc41.x86_64/usr/lib/python3.12/site-packages/pytestqt/wait_signal.py", line 219, in _quit_loop_by_signal
    self._cleanup()
  File "/builddir/build/BUILDROOT/python-pytest-qt-4.4.0-2.fc41.x86_64/usr/lib/python3.12/site-packages/pytestqt/wait_signal.py", line 226, in _cleanup
    _silent_disconnect(signal, self._quit_loop_by_signal)
  File "/builddir/build/BUILDROOT/python-pytest-qt-4.4.0-2.fc41.x86_64/usr/lib/python3.12/site-packages/pytestqt/wait_signal.py", line 741, in _silent_disconnect
    signal.disconnect(slot)
SystemError: <class 'RuntimeError'> returned a result with an exception set
________________________________________________________________________________
----------------------------- Captured stderr call -----------------------------
Exceptions caught in Qt event loop:
________________________________________________________________________________
RuntimeError: Internal C++ object (Obj) already deleted.
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
  File "/builddir/build/BUILDROOT/python-pytest-qt-4.4.0-2.fc41.x86_64/usr/lib/python3.12/site-packages/pytestqt/wait_signal.py", line 219, in _quit_loop_by_signal
    self._cleanup()
  File "/builddir/build/BUILDROOT/python-pytest-qt-4.4.0-2.fc41.x86_64/usr/lib/python3.12/site-packages/pytestqt/wait_signal.py", line 226, in _cleanup
    _silent_disconnect(signal, self._quit_loop_by_signal)
  File "/builddir/build/BUILDROOT/python-pytest-qt-4.4.0-2.fc41.x86_64/usr/lib/python3.12/site-packages/pytestqt/wait_signal.py", line 741, in _silent_disconnect
    signal.disconnect(slot)
SystemError: <class 'RuntimeError'> returned a result with an exception set
________________________________________________________________________________

That test succeeded with PySide 6.6.2.

@nicoddemus
Copy link
Member

A SystemError usually means a problem with the C code (in this case the C++ bindings).

Perhaps it would be good create a MWE and post this to PySide's tracker.

@bersbersbers
Copy link
Author

Well, one MWE (without pytest-qt) is this:

from PySide6.QtCore import Signal
from PySide6.QtWidgets import QApplication, QMainWindow


class Window(QMainWindow):
    signal = Signal()

    def __init__(self):
        super().__init__()
        self.signal.connect(self.Slot)
        self.signal.disconnect(self.Slot)
        self.signal.disconnect(self.Slot)

    def Slot(self): ...

app = QApplication()
window = Window()
print("Done.")

But I somehow feel reporting this to PySide will give us the question "why are you disconnecting twice"? :)

I think the comment in line 738 is relevant here - is that what you have in mind?

def _silent_disconnect(signal, slot):
"""Disconnects a signal from a slot, ignoring errors. Sometimes
Qt might disconnect a signal automatically for unknown reasons.
"""
try:
signal.disconnect(slot)
except (TypeError, RuntimeError): # pragma: no cover
pass

I don't suppose you remember which code example made you do 86e41e3 :)

@bersbersbers
Copy link
Author

Okay, here's a real one:

from PySide6.QtWidgets import QWidget

def test_disconnect(qtbot):
    widget = QWidget()
    qtbot.addWidget(widget)
    _ = qtbot.waitSignal(widget.windowTitleChanged, timeout=10000)
    widget.windowTitleChanged.emit("")
> pytest bug.py

======================================================================================= test session starts ======================================================================================== 
platform win32 -- Python 3.12.3, pytest-8.1.1, pluggy-1.5.0
PySide6 6.7.0 -- Qt runtime 6.7.0 -- Qt compiled 6.7.0
rootdir: C:\Code\project
plugins: qt-4.4.0
collected 1 item                                                                                                                                                                                     

bug.py .                                                                                                                                                                                      [100%] 

========================================================================================= warnings summary ========================================================================================= 
bug.py::test_disconnect
  c:\Code\project\.venv\Lib\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 0x00000265849F0B60>>) from signal "timeout()".
    signal.disconnect(slot)

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

@bersbersbers
Copy link
Author

bersbersbers commented Apr 25, 2024

And a workaround:

from PySide6.QtWidgets import QWidget

def test_disconnect(qtbot):
    widget = QWidget()
    qtbot.addWidget(widget)
    _ = qtbot.waitSignal(widget.windowTitleChanged, timeout=1)
    del _
    widget.windowTitleChanged.emit("")

Edit: yes, that fixes the issue in my big app.

Edit: another workaround is just to not assign to _ in the first place.

@nicoddemus, do you think this is reportable to PySide in this form, or will they want to see something without pytest-qt? Signal disconnect behaviors have been subject of bug fixing for the last 10 or so releases...

@nicoddemus
Copy link
Member

nicoddemus commented Apr 28, 2024

@bersbersbers sorry for the delay.

The problem is:

  1. pytest-qt does the right thing and ignores the runtime error in case it is disconnected twice. In our case it is OK because the signal might have been disconnected by the user at that point.
  2. PySide is emitting a warning that somehow is bypassing the standard warnings filter, that's why using filterwarnings in pytest has no effect.

Here is a MWE which shows the problem, without using pytest-qt:

import warnings
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QApplication, QMainWindow


warnings.filterwarnings('ignore')

class Window(QMainWindow):
    signal = Signal()

    def __init__(self):
        super().__init__()
        self.signal.connect(self.Slot)
        self.signal.disconnect(self.Slot)
        self.signal.disconnect(self.Slot)

    def Slot(self): ...

warnings.warn(Warning("some warning"))
app = QApplication()
window = Window()
print("Done")

While we should see no warnings at all due to warnings.filterwarnings, this prints:

e:\projects\pytest-qt\tests\foo.py:15: RuntimeError: Failed to disconnect (<bound method Window.Slot of <__main__.Window(0x2220ac06c40) at 0x000002220CF995C0>>) from signal "signal()".
  self.signal.disconnect(self.Slot)
Done

As a sanity check, if we comment out warnings.filterwarnings, we see both warnings as expected:

e:\projects\pytest-qt\tests\foo.py:19: Warning: some warning
  warnings.warn(Warning("some warning"))
e:\projects\pytest-qt\tests\foo.py:15: RuntimeError: Failed to disconnect (<bound method Window.Slot of <__main__.Window(0x28947215dd0) at 0x0000028949219680>>) from signal "signal()".
  self.signal.disconnect(self.Slot)
Done

@bersbersbers
Copy link
Author

@nicoddemus thanks - and no worries about the delay. I have found a workaround for my issue, and will work on getting this solved only as a bonus for others, so I have no strict timeline here. In summary, I see two issues:

I will report both issues to PySide and updates this thread here.

@nicoddemus
Copy link
Member

Thanks!

I will close for now, but looking forward to the follow ups. 👍

@nicoddemus nicoddemus closed this as not planned Won't fix, can't repro, duplicate, stale Apr 28, 2024
@bersbersbers
Copy link
Author

@nicoddemus
Copy link
Member

Thanks! Watching that issue. 👍

@bersbersbers
Copy link
Author

(Hopefully) fixed with PySide6 6.7.1 and 6.8.0.

@bersbersbers
Copy link
Author

I tested this on an early 6.7.1 (pip install https://download.qt.io/snapshots/ci/pyside/6.7/latest/pyside6/shiboken6-6.7.0a1.dev1714496642-cp39-abi3-win_amd64.whl https://download.qt.io/snapshots/ci/pyside/6.7/latest/pyside6/PySide6_Essentials-6.7.0a1.dev1714496642-cp39-abi3-win_amd64.whl) and it is working fine. We are still getting the warning, but it can be suppressed now. Maybe you want to extend your try ... except block to ignore just that particular warning.

@nicoddemus
Copy link
Member

yeah, using catch_warnings around that code would do the trick. Would you like to open a PR with that?

@bersbersbers
Copy link
Author

I might - however, I came across this note:

The catch_warnings manager works by replacing and then later restoring the module’s showwarning() function and internal list of filter specifications. This means the context manager is modifying global state and therefore is not thread-safe.

This means it might impact the user's own warnings filters, which is not ideal.

@nicoddemus
Copy link
Member

nicoddemus commented May 3, 2024

This means it might impact the user's own warnings filters, which is not ideal.

But this is temporary, it will not be a problem because the context manager will be short-lived:

 def _silent_disconnect(signal, slot): 
     """Disconnects a signal from a slot, ignoring errors. Sometimes 
     Qt might disconnect a signal automatically for unknown reasons. 
     """ 
     with warnings.catch_warnings():
         # PySide 6.7+ issues a UserWarning instead of an exception.
         warnings.filterwarnings("ignore", category=UserWarning)
         try: 
             signal.disconnect(slot) 
         except (TypeError, RuntimeError):  # pragma: no cover 
             pass 

@bersbersbers
Copy link
Author

Well, imagine this situation:

Thread 1:

with warnings.catch_warnings():
    warnings.filterwarnings("ignore", category=UserWarning)     
    thread_1_work()

Thread 2:

with warnings.catch_warnings():
    warnings.filterwarnings("ignore", category=OtherWarning)     
    thread_2_work()

What this is equivalent too, roughly:

Thread 1:

saved_warning_state_1 = global_warning_state
warnings.filterwarnings("ignore", category=UserWarning)  # changes global_warning_state
thread_1_work()
global_warning_state = saved_warning_state_1

Thread 2:

saved_warning_state_2 = global_warning_state
warnings.filterwarnings("ignore", category=OtherWarning)  # changes global_warning_state
thread_2_work()
global_warning_state = saved_warning_state_2

And these two run concurrently - for example:

saved_warning_state_1 = global_warning_state
warnings.filterwarnings("ignore", category=UserWarning)  # changes global_warning_state
thread_1_work()

saved_warning_state_2 = global_warning_state
global_warning_state = saved_warning_state_1

warnings.filterwarnings("ignore", category=OtherWarning)  # changes global_warning_state
thread_2_work()
global_warning_state = saved_warning_state_2

# Still ignoring UserWarning

@nicoddemus
Copy link
Member

nicoddemus commented May 3, 2024

pytest is not multi-thread, so this is not a problem. Also, pytest itself uses catch_warnings to capture warnings, so it is safe.

@bersbersbers
Copy link
Author

pytest is not multi-thread

That is true. But what if pytest is used to test multi-threaded code?

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.

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