Skip to content

Commit

Permalink
backends/winrt: don't throw exception for properly configured GUI apps (
Browse files Browse the repository at this point in the history
#1581)

In commit 4a653e6 ("backends/winrt: raise exception when trying to scan
with STA") we added a check to raise an exception when trying to scan
when PyWinRT set the apartment model to STA. However, properly working
GUI apps will have the apartment model set to STA but Bleak will still
work because there is something pumping the Windows message loop.

We don't want to raise an exception in this case to avoid breaking
working apps. We can improve the test by checking if the current thread
is actually pumping the message loop by scheduling a callback via a
the win32 SetTimeout function. If the callback is called, then we know
that the message loop is being pumped. If not, then we probably are not
going to get async callbacks from the WinRT APIs and we raise an
exception in this case.
  • Loading branch information
dlech authored Jun 1, 2024
1 parent d45ec90 commit bd8f022
Show file tree
Hide file tree
Showing 7 changed files with 257 additions and 79 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Fixed
* Fixed ``discovered_devices_and_advertisement_data`` returning devices that should
be filtered out by service UUIDs. Fixes #1576.
* Fixed a ``Descriptor None was not found!`` exception occurring in ``start_notify()`` on Android. Fixes #823.
* Fixed exception raised when starting ``BleakScanner`` while running in a Windows GUI app.

`0.22.1`_ (2024-05-07)
======================
Expand Down
2 changes: 1 addition & 1 deletion bleak/backends/winrt/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ async def start(self) -> None:

# Callbacks for WinRT async methods will never happen in STA mode if
# there is nothing pumping a Windows message loop.
assert_mta()
await assert_mta()

# start with fresh list of discovered devices
self.seen_devices = {}
Expand Down
103 changes: 93 additions & 10 deletions bleak/backends/winrt/util.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import asyncio
import ctypes
import sys
from ctypes import wintypes
from enum import IntEnum
from typing import Tuple

from ...exc import BleakError

if sys.version_info < (3, 11):
from async_timeout import timeout as async_timeout
else:
from asyncio import timeout as async_timeout


def _check_result(result, func, args):
if not result:
raise ctypes.WinError()

return args


def _check_hresult(result, func, args):
if result:
Expand All @@ -12,6 +27,26 @@ def _check_hresult(result, func, args):
return args


# not defined in wintypes
_UINT_PTR = wintypes.WPARAM

# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-timerproc
_TIMERPROC = ctypes.WINFUNCTYPE(
None, wintypes.HWND, _UINT_PTR, wintypes.UINT, wintypes.DWORD
)

# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-settimer
_SetTimer = ctypes.windll.user32.SetTimer
_SetTimer.restype = _UINT_PTR
_SetTimer.argtypes = [wintypes.HWND, _UINT_PTR, wintypes.UINT, _TIMERPROC]
_SetTimer.errcheck = _check_result

# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-killtimer
_KillTimer = ctypes.windll.user32.KillTimer
_KillTimer.restype = wintypes.BOOL
_KillTimer.argtypes = [wintypes.HWND, wintypes.UINT]


# https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cogetapartmenttype
_CoGetApartmentType = ctypes.windll.ole32.CoGetApartmentType
_CoGetApartmentType.restype = ctypes.c_int
Expand Down Expand Up @@ -60,28 +95,71 @@ def _get_apartment_type() -> Tuple[_AptType, _AptQualifierType]:
return _AptType(api_type.value), _AptQualifierType(api_type_qualifier.value)


def assert_mta() -> None:
async def assert_mta() -> None:
"""
Asserts that the current apartment type is MTA.
Raises:
BleakError: If the current apartment type is not MTA.
BleakError:
If the current apartment type is not MTA and there is no Windows
message loop running.
.. versionadded:: 0.22
.. versionchanged:: unreleased
Function is now async and will not raise if the current apartment type
is STA and the Windows message loop is running.
"""
if hasattr(allow_sta, "_allowed"):
return

try:
apt_type, _ = _get_apartment_type()
if apt_type != _AptType.MTA:
raise BleakError(
f"The current thread apartment type is not MTA: {apt_type.name}. Beware of packages like pywin32 that may change the apartment type implicitly."
)
except OSError as e:
# All is OK if not initialized yet. WinRT will initialize it.
if e.winerror != _CO_E_NOTINITIALIZED:
raise
if e.winerror == _CO_E_NOTINITIALIZED:
return

raise

if apt_type == _AptType.MTA:
# if we get here, WinRT probably set the apartment type to MTA and all
# is well, we don't need to check again
setattr(allow_sta, "_allowed", True)
return

event = asyncio.Event()

def wait_event(*_):
event.set()

# have to keep a reference to the callback or it will be garbage collected
# before it is called
callback = _TIMERPROC(wait_event)

# set a timer to see if we get a callback to ensure the windows event loop
# is running
timer = _SetTimer(None, 1, 0, callback)

try:
async with async_timeout(0.5):
await event.wait()
except asyncio.TimeoutError:
raise BleakError(
"Thread is configured for Windows GUI but callbacks are not working."
+ (
" Suspect unwanted side effects from importing 'pythoncom'."
if "pythoncom" in sys.modules
else ""
)
)
else:
# if the windows event loop is running, we assume it is going to keep
# running and we don't need to check again
setattr(allow_sta, "_allowed", True)
finally:
_KillTimer(None, timer)


def allow_sta():
Expand Down Expand Up @@ -115,7 +193,12 @@ def uninitialize_sta():
.. versionadded:: 0.22
"""

try:
assert_mta()
except BleakError:
_get_apartment_type()
except OSError as e:
# All is OK if not initialized yet. WinRT will initialize it.
if e.winerror == _CO_E_NOTINITIALIZED:
return
else:
ctypes.windll.ole32.CoUninitialize()
45 changes: 34 additions & 11 deletions docs/troubleshooting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,32 @@ Not working when threading model is STA

Packages like ``pywin32`` and it's subsidiaries have an unfortunate side effect
of initializing the threading model to Single Threaded Apartment (STA) when
imported. This causes async WinRT functions to never complete. because there
isn't a message loop running. Bleak needs to run in a Multi Threaded Apartment
(MTA) instead (this happens automatically on the first WinRT call).
imported. This causes async WinRT functions to never complete if Bleak is being
used in a console application (no Windows graphical user interface). This is
because there isn't a Windows message loop running to handle async callbacks.
Bleak, when used in a console application, needs to run in a Multi Threaded
Apartment (MTA) instead (this happens automatically on the first WinRT call).

Bleak should detect this and raise an exception with a message similar to::

The current thread apartment type is not MTA: STA.
Thread is configured for Windows GUI but callbacks are not working.

To work around this, you can use one of the utility functions provided by Bleak.
You can tell a ``pywin32`` package caused the issue by checking for
``"pythoncom" in sys.modules``. If it is there, then likely it triggered the
problem. You can avoid this by setting ``sys.coinit_flags = 0`` before importing
any package that indirectly imports ``pythoncom``. This will cause ``pythoncom``
to use the default threading model (MTA) instead of STA.

Example::

import sys
sys.coinit_flags = 0 # 0 means MTA

import win32com # or any other package that causes the issue


If the issue was caused by something other than the ``pythoncom`` module, there
are a couple of other helper functions you can try.

If your program has a graphical user interface and the UI framework *and* it is
properly integrated with asyncio *and* Bleak is not running on a background
Expand All @@ -201,14 +218,20 @@ thread then call ``allow_sta()`` before calling any other Bleak APis::
# can safely ignore
pass

The more typical case, though, is that some library has imported something like
``pywin32`` which breaks Bleak. In this case, you can uninitialize the threading
model like this::
The more typical case, though, is that some library has imported something similar
to ``pythoncom`` with the same unwanted side effect of initializing the main
thread of a console application to STA. In this case, you can uninitialize the
threading model like this::

import win32com # this sets current thread to STA :-(
from bleak.backends.winrt.util import uninitialize_sta
import naughty_module # this sets current thread to STA :-(

uninitialize_sta() # undo the unwanted side effect
try:
from bleak.backends.winrt.util import uninitialize_sta

uninitialize_sta() # undo the unwanted side effect
except ImportError:
# not Windows, so no problem
pass


--------------
Expand Down
Loading

0 comments on commit bd8f022

Please sign in to comment.