Skip to content

Commit bd8f022

Browse files
authored
backends/winrt: don't throw exception for properly configured GUI apps (#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.
1 parent d45ec90 commit bd8f022

File tree

7 files changed

+257
-79
lines changed

7 files changed

+257
-79
lines changed

CHANGELOG.rst

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Fixed
2020
* Fixed ``discovered_devices_and_advertisement_data`` returning devices that should
2121
be filtered out by service UUIDs. Fixes #1576.
2222
* Fixed a ``Descriptor None was not found!`` exception occurring in ``start_notify()`` on Android. Fixes #823.
23+
* Fixed exception raised when starting ``BleakScanner`` while running in a Windows GUI app.
2324

2425
`0.22.1`_ (2024-05-07)
2526
======================

bleak/backends/winrt/scanner.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ async def start(self) -> None:
222222

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

227227
# start with fresh list of discovered devices
228228
self.seen_devices = {}

bleak/backends/winrt/util.py

+93-10
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
1+
import asyncio
12
import ctypes
3+
import sys
4+
from ctypes import wintypes
25
from enum import IntEnum
36
from typing import Tuple
47

58
from ...exc import BleakError
69

10+
if sys.version_info < (3, 11):
11+
from async_timeout import timeout as async_timeout
12+
else:
13+
from asyncio import timeout as async_timeout
14+
15+
16+
def _check_result(result, func, args):
17+
if not result:
18+
raise ctypes.WinError()
19+
20+
return args
21+
722

823
def _check_hresult(result, func, args):
924
if result:
@@ -12,6 +27,26 @@ def _check_hresult(result, func, args):
1227
return args
1328

1429

30+
# not defined in wintypes
31+
_UINT_PTR = wintypes.WPARAM
32+
33+
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-timerproc
34+
_TIMERPROC = ctypes.WINFUNCTYPE(
35+
None, wintypes.HWND, _UINT_PTR, wintypes.UINT, wintypes.DWORD
36+
)
37+
38+
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-settimer
39+
_SetTimer = ctypes.windll.user32.SetTimer
40+
_SetTimer.restype = _UINT_PTR
41+
_SetTimer.argtypes = [wintypes.HWND, _UINT_PTR, wintypes.UINT, _TIMERPROC]
42+
_SetTimer.errcheck = _check_result
43+
44+
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-killtimer
45+
_KillTimer = ctypes.windll.user32.KillTimer
46+
_KillTimer.restype = wintypes.BOOL
47+
_KillTimer.argtypes = [wintypes.HWND, wintypes.UINT]
48+
49+
1550
# https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cogetapartmenttype
1651
_CoGetApartmentType = ctypes.windll.ole32.CoGetApartmentType
1752
_CoGetApartmentType.restype = ctypes.c_int
@@ -60,28 +95,71 @@ def _get_apartment_type() -> Tuple[_AptType, _AptQualifierType]:
6095
return _AptType(api_type.value), _AptQualifierType(api_type_qualifier.value)
6196

6297

63-
def assert_mta() -> None:
98+
async def assert_mta() -> None:
6499
"""
65100
Asserts that the current apartment type is MTA.
66101
67102
Raises:
68-
BleakError: If the current apartment type is not MTA.
103+
BleakError:
104+
If the current apartment type is not MTA and there is no Windows
105+
message loop running.
69106
70107
.. versionadded:: 0.22
108+
109+
.. versionchanged:: unreleased
110+
111+
Function is now async and will not raise if the current apartment type
112+
is STA and the Windows message loop is running.
71113
"""
72114
if hasattr(allow_sta, "_allowed"):
73115
return
74116

75117
try:
76118
apt_type, _ = _get_apartment_type()
77-
if apt_type != _AptType.MTA:
78-
raise BleakError(
79-
f"The current thread apartment type is not MTA: {apt_type.name}. Beware of packages like pywin32 that may change the apartment type implicitly."
80-
)
81119
except OSError as e:
82120
# All is OK if not initialized yet. WinRT will initialize it.
83-
if e.winerror != _CO_E_NOTINITIALIZED:
84-
raise
121+
if e.winerror == _CO_E_NOTINITIALIZED:
122+
return
123+
124+
raise
125+
126+
if apt_type == _AptType.MTA:
127+
# if we get here, WinRT probably set the apartment type to MTA and all
128+
# is well, we don't need to check again
129+
setattr(allow_sta, "_allowed", True)
130+
return
131+
132+
event = asyncio.Event()
133+
134+
def wait_event(*_):
135+
event.set()
136+
137+
# have to keep a reference to the callback or it will be garbage collected
138+
# before it is called
139+
callback = _TIMERPROC(wait_event)
140+
141+
# set a timer to see if we get a callback to ensure the windows event loop
142+
# is running
143+
timer = _SetTimer(None, 1, 0, callback)
144+
145+
try:
146+
async with async_timeout(0.5):
147+
await event.wait()
148+
except asyncio.TimeoutError:
149+
raise BleakError(
150+
"Thread is configured for Windows GUI but callbacks are not working."
151+
+ (
152+
" Suspect unwanted side effects from importing 'pythoncom'."
153+
if "pythoncom" in sys.modules
154+
else ""
155+
)
156+
)
157+
else:
158+
# if the windows event loop is running, we assume it is going to keep
159+
# running and we don't need to check again
160+
setattr(allow_sta, "_allowed", True)
161+
finally:
162+
_KillTimer(None, timer)
85163

86164

87165
def allow_sta():
@@ -115,7 +193,12 @@ def uninitialize_sta():
115193
116194
.. versionadded:: 0.22
117195
"""
196+
118197
try:
119-
assert_mta()
120-
except BleakError:
198+
_get_apartment_type()
199+
except OSError as e:
200+
# All is OK if not initialized yet. WinRT will initialize it.
201+
if e.winerror == _CO_E_NOTINITIALIZED:
202+
return
203+
else:
121204
ctypes.windll.ole32.CoUninitialize()

docs/troubleshooting.rst

+34-11
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,32 @@ Not working when threading model is STA
177177

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

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

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

188-
To work around this, you can use one of the utility functions provided by Bleak.
190+
You can tell a ``pywin32`` package caused the issue by checking for
191+
``"pythoncom" in sys.modules``. If it is there, then likely it triggered the
192+
problem. You can avoid this by setting ``sys.coinit_flags = 0`` before importing
193+
any package that indirectly imports ``pythoncom``. This will cause ``pythoncom``
194+
to use the default threading model (MTA) instead of STA.
195+
196+
Example::
197+
198+
import sys
199+
sys.coinit_flags = 0 # 0 means MTA
200+
201+
import win32com # or any other package that causes the issue
202+
203+
204+
If the issue was caused by something other than the ``pythoncom`` module, there
205+
are a couple of other helper functions you can try.
189206

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

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

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

211-
uninitialize_sta() # undo the unwanted side effect
228+
try:
229+
from bleak.backends.winrt.util import uninitialize_sta
230+
231+
uninitialize_sta() # undo the unwanted side effect
232+
except ImportError:
233+
# not Windows, so no problem
234+
pass
212235

213236

214237
--------------

0 commit comments

Comments
 (0)