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

backends/winrt: don't throw exception for properly configured GUI apps #1581

Merged
merged 11 commits into from
Jun 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

dlech marked this conversation as resolved.
Show resolved Hide resolved
# 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
dlech marked this conversation as resolved.
Show resolved Hide resolved

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():
dlech marked this conversation as resolved.
Show resolved Hide resolved
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

dlech marked this conversation as resolved.
Show resolved Hide resolved
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
Loading