From 4ea2f16b1511ede7e505c1cd6fbead349224c685 Mon Sep 17 00:00:00 2001 From: Clint Goudie-Nice <3596299+cgoudie@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:11:53 -0600 Subject: [PATCH 1/7] backends/bluezdbus: tolerate InProgress from StopDiscovery InProgress on StopDiscovery is bluetoothd relaying MGMT_STATUS_REJECTED from the kernel, which it returns when discovery is already not active. bluetoothd removes the client's discovery session before replying, so by the time bleak sees the error there is nothing left to stop; raising only turns a completed scan into an exception the caller cannot retry, since the scanner nulls _stop before awaiting it. The usual trigger is the kernel's own LE scan timeout: an LE-only scan is stopped after DISCOV_LE_TIMEOUT (10.24 s) and re-armed by bluetoothd IDLE_DISCOV_TIMEOUT (5 s) later, and a stop landing in that gap is rejected. find_device_by_address with a 15 s timeout on an absent device hits it essentially every time on BlueZ 5.72. Treat it like NotReady, which this branch already swallows. The integration test scans for 12 s so that the stop lands inside the gap. Fixes #2021 --- CHANGELOG.rst | 1 + bleak/backends/bluezdbus/manager.py | 12 +++++++++++- tests/integration/test_issue_2021.py | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 tests/integration/test_issue_2021.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 067627157..0128f8941 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,6 +21,7 @@ Changed Fixed ----- * Fixed handling empty notification payloads in BlueZ backend when using "AcquireNotify". Fixes #1982. +* Fixed ``BleakScanner.stop()`` raising ``BleakDBusError`` with ``org.bluez.Error.InProgress`` in BlueZ backend when the kernel had already stopped scanning. Fixes #2021. `3.0.2`_ (2026-05-02) ===================== diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index 414731c0b..4a7380214 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -502,7 +502,17 @@ async def stop() -> None: try: assert_reply(reply) except BleakDBusError as ex: - if ex.dbus_error != defs.BLUEZ_ERROR_NOT_READY: + # InProgress here means the kernel already stopped + # scanning (e.g. its LE scan timeout) and rejected + # the redundant stop; BlueZ has already removed our + # discovery session by the time it replies, so + # nothing is left to stop. See + # https://github.com/hbldh/bleak/issues/2021 and + # https://github.com/bluez/bluez/issues/807. + if ex.dbus_error not in ( + defs.BLUEZ_ERROR_NOT_READY, + defs.BLUEZ_ERROR_IN_PROGRESS, + ): raise else: # remove the filters diff --git a/tests/integration/test_issue_2021.py b/tests/integration/test_issue_2021.py new file mode 100644 index 000000000..cf2536aab --- /dev/null +++ b/tests/integration/test_issue_2021.py @@ -0,0 +1,18 @@ +import asyncio + +from bumble.transport.common import Transport + +from bleak import BleakScanner + +# The Linux kernel stops an LE-only scan on its own after DISCOV_LE_TIMEOUT +# (10.24 s) and BlueZ re-arms it IDLE_DISCOV_TIMEOUT (5 s) later. A stop that +# lands in that gap is rejected by the kernel and surfaces as InProgress. +SCAN_DURATION = 12.0 + + +async def test_stop_after_kernel_le_scan_timeout(hci_transport: Transport) -> None: + """ + Regression test for . + """ + async with BleakScanner(): + await asyncio.sleep(SCAN_DURATION) From 637a0d16ff27ac91f5ace52a8ac7b0ecaac8d7b1 Mon Sep 17 00:00:00 2001 From: Clint Goudie-Nice <3596299+cgoudie@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:48:29 -0600 Subject: [PATCH 2/7] backends/bluezdbus: describe the InProgress condition precisely; widen the test The kernel rejects StopDiscovery whenever it is not actively scanning, which includes the moments while BlueZ is restarting the scan after the kernel's own LE timeout, not only after it has stopped. In the idle gap itself BlueZ already knows the kernel is idle and stops cleanly, so the window is narrow and the failure is intermittent in the field. The test now stops at several points around the restart instead of one. --- bleak/backends/bluezdbus/manager.py | 10 +++++----- tests/integration/test_issue_2021.py | 17 +++++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index 4a7380214..fba374f49 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -502,11 +502,11 @@ async def stop() -> None: try: assert_reply(reply) except BleakDBusError as ex: - # InProgress here means the kernel already stopped - # scanning (e.g. its LE scan timeout) and rejected - # the redundant stop; BlueZ has already removed our - # discovery session by the time it replies, so - # nothing is left to stop. See + # InProgress here is the kernel rejecting the stop + # because it is not actively scanning at that + # moment (already stopped, or mid-restart); BlueZ + # has already removed our discovery session by the + # time it replies, so nothing is left to stop. See # https://github.com/hbldh/bleak/issues/2021 and # https://github.com/bluez/bluez/issues/807. if ex.dbus_error not in ( diff --git a/tests/integration/test_issue_2021.py b/tests/integration/test_issue_2021.py index cf2536aab..445458d10 100644 --- a/tests/integration/test_issue_2021.py +++ b/tests/integration/test_issue_2021.py @@ -1,18 +1,23 @@ import asyncio +import pytest from bumble.transport.common import Transport from bleak import BleakScanner -# The Linux kernel stops an LE-only scan on its own after DISCOV_LE_TIMEOUT -# (10.24 s) and BlueZ re-arms it IDLE_DISCOV_TIMEOUT (5 s) later. A stop that -# lands in that gap is rejected by the kernel and surfaces as InProgress. -SCAN_DURATION = 12.0 +# The kernel stops an LE-only scan after DISCOV_LE_TIMEOUT (10.24 s) and BlueZ +# restarts it IDLE_DISCOV_TIMEOUT (5 s) later. A stop that arrives while the +# kernel is between states is rejected and surfaces as InProgress; the window +# is narrow, so the stop is placed at several points around the restart. +STOP_AFTER = [10.0, 12.0, 15.0, 15.2, 15.4, 16.0] -async def test_stop_after_kernel_le_scan_timeout(hci_transport: Transport) -> None: +@pytest.mark.parametrize("stop_after", STOP_AFTER) +async def test_stop_around_kernel_le_scan_restart( + stop_after: float, hci_transport: Transport +) -> None: """ Regression test for . """ async with BleakScanner(): - await asyncio.sleep(SCAN_DURATION) + await asyncio.sleep(stop_after) From 3b9755f879417defc0d747a8dadca07041212f75 Mon Sep 17 00:00:00 2001 From: Clint Goudie-Nice <3596299+cgoudie@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:17:47 -0600 Subject: [PATCH 3/7] tests/integration: drop the timing-based test for #2021 It cannot fail on unpatched code. The rejection needs bluetoothd to forward a stop after the kernel has left the FINDING state but before bluetoothd has processed the kernel's Discovering(false) event, and on a healthy system that window is a scheduling race, not a time offset: stops at 10, 12, 15, 15.2, 15.4 and 16 s after start were accepted 18 of 18 times on BlueZ 5.72, as were five find_device_by_address timeouts. The in-field occurrences (24 in one day) were under a crash-looping bluetoothd and have not recurred since it was fixed. --- tests/integration/test_issue_2021.py | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 tests/integration/test_issue_2021.py diff --git a/tests/integration/test_issue_2021.py b/tests/integration/test_issue_2021.py deleted file mode 100644 index 445458d10..000000000 --- a/tests/integration/test_issue_2021.py +++ /dev/null @@ -1,23 +0,0 @@ -import asyncio - -import pytest -from bumble.transport.common import Transport - -from bleak import BleakScanner - -# The kernel stops an LE-only scan after DISCOV_LE_TIMEOUT (10.24 s) and BlueZ -# restarts it IDLE_DISCOV_TIMEOUT (5 s) later. A stop that arrives while the -# kernel is between states is rejected and surfaces as InProgress; the window -# is narrow, so the stop is placed at several points around the restart. -STOP_AFTER = [10.0, 12.0, 15.0, 15.2, 15.4, 16.0] - - -@pytest.mark.parametrize("stop_after", STOP_AFTER) -async def test_stop_around_kernel_le_scan_restart( - stop_after: float, hci_transport: Transport -) -> None: - """ - Regression test for . - """ - async with BleakScanner(): - await asyncio.sleep(stop_after) From c3ab67ebca1ac5b500933abba1486a338bcd38c9 Mon Sep 17 00:00:00 2001 From: Clint Goudie-Nice <3596299+cgoudie@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:10:26 -0600 Subject: [PATCH 4/7] backends/bluezdbus: unit test for InProgress on StopDiscovery; name the BlueZ path in the comment A faked-reply test rather than an integration test, because the condition cannot be produced on demand: bluetoothd only forwards a client's stop to the kernel while its own discovery_enable flag is set, and it clears that flag when it processes the kernel's Discovering(false) event, so the kernel's rejection needs a stop to land in the gap between the two. That is a scheduling race, not a time offset a test can aim at; on BlueZ 5.72 stops at 10, 12, 15, 15.2, 15.4 and 16 s after start were accepted 18 of 18 times. The test fakes the bus reply and pins bleak's handling: stop() returns, the callbacks are removed, and any other error still raises. The code comment now names the bluetoothd path (stop_discovery_complete removes the session before checking the status, then relays MGMT_STATUS_REJECTED as InProgress), as requested in the review. --- bleak/backends/bluezdbus/manager.py | 9 ++- tests/backends/bluezdbus/test_issue_2021.py | 73 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/backends/bluezdbus/test_issue_2021.py diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index fba374f49..4bfe90ffc 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -506,9 +506,12 @@ async def stop() -> None: # because it is not actively scanning at that # moment (already stopped, or mid-restart); BlueZ # has already removed our discovery session by the - # time it replies, so nothing is left to stop. See - # https://github.com/hbldh/bleak/issues/2021 and - # https://github.com/bluez/bluez/issues/807. + # time it replies, so nothing is left to stop: + # bluetoothd's stop_discovery_complete() calls + # discovery_remove() before checking the status, + # then relays MGMT_STATUS_REJECTED as InProgress. + # See https://github.com/hbldh/bleak/issues/2021 + # and https://github.com/bluez/bluez/issues/807. if ex.dbus_error not in ( defs.BLUEZ_ERROR_NOT_READY, defs.BLUEZ_ERROR_IN_PROGRESS, diff --git a/tests/backends/bluezdbus/test_issue_2021.py b/tests/backends/bluezdbus/test_issue_2021.py new file mode 100644 index 000000000..68b0b8310 --- /dev/null +++ b/tests/backends/bluezdbus/test_issue_2021.py @@ -0,0 +1,73 @@ +"""Regression test for .""" + +import pytest + +dbus_fast = pytest.importorskip("dbus_fast") + +from dbus_fast import Message, MessageType # noqa: E402 + +from bleak.backends.bluezdbus import defs # noqa: E402 +from bleak.backends.bluezdbus.manager import BlueZManager # noqa: E402 +from bleak.exc import BleakDBusError # noqa: E402 + +ADAPTER_PATH = "/org/bluez/hci0" + + +class FakeBus: + """Answers every call with a method return, except StopDiscovery.""" + + def __init__(self, stop_error: str | None) -> None: + self.stop_error = stop_error + self.members: list[str] = [] + + async def call(self, msg: Message) -> Message: + # outgoing messages carry serial 0 until a real bus assigns one, so + # replies are built explicitly rather than derived from the request + self.members.append(msg.member) + if msg.member == "StopDiscovery" and self.stop_error is not None: + return Message( + message_type=MessageType.ERROR, + reply_serial=1, + error_name=self.stop_error, + signature="s", + body=["Operation already in progress"], + ) + return Message(message_type=MessageType.METHOD_RETURN, reply_serial=1) + + +def make_manager(stop_error: str | None) -> tuple[BlueZManager, FakeBus]: + manager = BlueZManager() + bus = FakeBus(stop_error) + manager._bus = bus # type: ignore[assignment] + manager._properties[ADAPTER_PATH] = {defs.ADAPTER_INTERFACE: {}} + return manager, bus + + +async def test_stop_tolerates_in_progress() -> None: + """ + BlueZ answers StopDiscovery with InProgress when the kernel has already + stopped scanning; the discovery session is gone by then, so stop() must + return normally and leave the manager's bookkeeping clean. + """ + manager, bus = make_manager(defs.BLUEZ_ERROR_IN_PROGRESS) + + stop = await manager.active_scan( + ADAPTER_PATH, {}, lambda path, props: None, lambda path: None + ) + await stop() + + assert bus.members == ["SetDiscoveryFilter", "StartDiscovery", "StopDiscovery"] + assert manager._advertisement_callbacks[ADAPTER_PATH] == [] + assert manager._device_removed_callbacks == [] + + +async def test_stop_still_raises_other_errors() -> None: + manager, _ = make_manager(defs.BLUEZ_ERROR_FAILED) + + stop = await manager.active_scan( + ADAPTER_PATH, {}, lambda path, props: None, lambda path: None + ) + with pytest.raises(BleakDBusError) as info: + await stop() + + assert info.value.dbus_error == defs.BLUEZ_ERROR_FAILED From 50f342f76dc31b3116a4adb72a252d475f064ba8 Mon Sep 17 00:00:00 2001 From: Clint Goudie-Nice <3596299+cgoudie@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:58:57 -0600 Subject: [PATCH 5/7] tests: type-clean the #2021 test and guard it to Linux CI failed on the typecheck subtask, not the tests: mypy flagged Message.member as str | None where the fake bus appended it to a list[str]. Annotate the recorded members, give the no-op scan callbacks their AdvertisementCallback / DeviceRemovedCallback types, and type the empty filter dict, so both mypy and pyright (strict) pass. Guard the module to Linux the way the other bluezdbus unit tests are, since it imports dbus_fast, which is a Linux-only dependency. --- tests/backends/bluezdbus/test_issue_2021.py | 47 +++++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/tests/backends/bluezdbus/test_issue_2021.py b/tests/backends/bluezdbus/test_issue_2021.py index 68b0b8310..f89a2cbdb 100644 --- a/tests/backends/bluezdbus/test_issue_2021.py +++ b/tests/backends/bluezdbus/test_issue_2021.py @@ -1,24 +1,47 @@ """Regression test for .""" +import sys + import pytest -dbus_fast = pytest.importorskip("dbus_fast") +if sys.platform != "linux": + pytest.skip("skipping linux-only tests", allow_module_level=True) + assert False # HACK: work around pyright bug -from dbus_fast import Message, MessageType # noqa: E402 +from dbus_fast import Message, MessageType, Variant -from bleak.backends.bluezdbus import defs # noqa: E402 -from bleak.backends.bluezdbus.manager import BlueZManager # noqa: E402 -from bleak.exc import BleakDBusError # noqa: E402 +from bleak.backends.bluezdbus import defs +from bleak.backends.bluezdbus.manager import ( + AdvertisementCallback, + BlueZManager, + Device1, + DeviceRemovedCallback, +) +from bleak.exc import BleakDBusError ADAPTER_PATH = "/org/bluez/hci0" +NO_FILTERS: dict[str, Variant] = {} + + +def _on_advertisement(path: str, props: Device1) -> None: + pass + + +def _on_removed(path: str) -> None: + pass + + +_ADV: AdvertisementCallback = _on_advertisement +_REMOVED: DeviceRemovedCallback = _on_removed + class FakeBus: """Answers every call with a method return, except StopDiscovery.""" - def __init__(self, stop_error: str | None) -> None: + def __init__(self, stop_error: "str | None") -> None: self.stop_error = stop_error - self.members: list[str] = [] + self.members: "list[str | None]" = [] async def call(self, msg: Message) -> Message: # outgoing messages carry serial 0 until a real bus assigns one, so @@ -35,7 +58,7 @@ async def call(self, msg: Message) -> Message: return Message(message_type=MessageType.METHOD_RETURN, reply_serial=1) -def make_manager(stop_error: str | None) -> tuple[BlueZManager, FakeBus]: +def make_manager(stop_error: "str | None") -> "tuple[BlueZManager, FakeBus]": manager = BlueZManager() bus = FakeBus(stop_error) manager._bus = bus # type: ignore[assignment] @@ -51,9 +74,7 @@ async def test_stop_tolerates_in_progress() -> None: """ manager, bus = make_manager(defs.BLUEZ_ERROR_IN_PROGRESS) - stop = await manager.active_scan( - ADAPTER_PATH, {}, lambda path, props: None, lambda path: None - ) + stop = await manager.active_scan(ADAPTER_PATH, NO_FILTERS, _ADV, _REMOVED) await stop() assert bus.members == ["SetDiscoveryFilter", "StartDiscovery", "StopDiscovery"] @@ -64,9 +85,7 @@ async def test_stop_tolerates_in_progress() -> None: async def test_stop_still_raises_other_errors() -> None: manager, _ = make_manager(defs.BLUEZ_ERROR_FAILED) - stop = await manager.active_scan( - ADAPTER_PATH, {}, lambda path, props: None, lambda path: None - ) + stop = await manager.active_scan(ADAPTER_PATH, NO_FILTERS, _ADV, _REMOVED) with pytest.raises(BleakDBusError) as info: await stop() From d14875a8933dba1c2f2d3f307d9d1210cbfb03eb Mon Sep 17 00:00:00 2001 From: Clint Goudie-Nice <3596299+cgoudie@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:54:25 -0600 Subject: [PATCH 6/7] bluezdbus: raise on the second consecutive InProgress from StopDiscovery A single rejection is a race with the kernel's own scan timeout and the next stop on the adapter succeeds, so it is logged at INFO and tolerated. Two in a row with no clean stop between them cannot come from that race: bluetoothd has lost track of the kernel's scan state and no scan on the adapter reaches the kernel until it is reset. That one is logged at WARNING with the remedy and the original error is raised. Documents the condition and its limits in the troubleshooting guide. --- CHANGELOG.rst | 2 +- bleak/backends/bluezdbus/manager.py | 32 ++++++ docs/troubleshooting.rst | 26 +++++ tests/backends/bluezdbus/test_issue_2021.py | 108 ++++++++++++++++---- 4 files changed, 148 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0128f8941..e5700c1f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,7 +21,7 @@ Changed Fixed ----- * Fixed handling empty notification payloads in BlueZ backend when using "AcquireNotify". Fixes #1982. -* Fixed ``BleakScanner.stop()`` raising ``BleakDBusError`` with ``org.bluez.Error.InProgress`` in BlueZ backend when the kernel had already stopped scanning. Fixes #2021. +* Fixed ``BleakScanner.stop()`` raising ``BleakDBusError`` with ``org.bluez.Error.InProgress`` in BlueZ backend when the kernel had already stopped scanning. A single rejection is now logged and tolerated; two in a row on the same adapter still raise, since that means bluetoothd's discovery state is stuck. Fixes #2021. `3.0.2`_ (2026-05-02) ===================== diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index 4bfe90ffc..3b04a0aef 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -196,6 +196,9 @@ def __init__(self) -> None: defaultdict(list) ) self._device_removed_callbacks: list[DeviceRemovedCallbackAndState] = [] + # adapters whose most recent StopDiscovery was rejected with InProgress + # and has not been followed by a clean stop since; see active_scan() + self._stop_rejected: set[str] = set() self._device_watchers: dict[str, set[DeviceWatcher]] = {} self._condition_callbacks: dict[str, set[DeviceConditionCallback]] = {} self._services_cache: dict[str, BleakGATTServiceCollection] = {} @@ -517,7 +520,36 @@ async def stop() -> None: defs.BLUEZ_ERROR_IN_PROGRESS, ): raise + if ex.dbus_error == defs.BLUEZ_ERROR_IN_PROGRESS: + # A one-off rejection is a harmless race with + # the kernel's own scan timeout, and the next + # stop on the adapter succeeds. Two in a row + # with no clean stop between them cannot come + # from that race: bluetoothd has lost track of + # the kernel's scan state and no scan on this + # adapter will reach the kernel until it is + # reset. Surface that one as the error it is; + # see the Linux section of docs/troubleshooting. + if adapter_path in self._stop_rejected: + logger.warning( + "StopDiscovery on %s returned InProgress " + "twice in a row; bluetoothd's discovery " + "state appears stuck and scans on this " + "adapter will see nothing until it is " + "reset (power cycle or re-plug the " + "adapter, or restart bluetoothd)", + adapter_path, + ) + raise + self._stop_rejected.add(adapter_path) + logger.info( + "StopDiscovery on %s returned InProgress; " + "BlueZ had already ended our discovery " + "session, so the scan is stopped", + adapter_path, + ) else: + self._stop_rejected.discard(adapter_path) # remove the filters reply = await self._bus.call( Message( diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 3634b6612..27c82a608 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -149,6 +149,32 @@ with similar problems on Raspberry Pi and other devices. If you need Wi-Fi, you can possibly work around the issue by using a USB Bluetooth adapter instead. +"StopDiscovery returned InProgress" in the log, or scans that see nothing +========================================================================= + +On Linux the kernel ends an LE scan on its own after about 10 seconds and +BlueZ restarts it a few seconds later for as long as a scanner is active. If +Bleak stops a scan in the moment between those two, BlueZ rejects the stop +with ``InProgress`` even though it has already ended Bleak's discovery +session. Bleak treats this as a completed stop and logs it at INFO level. +Seen once, it is harmless: the scan that follows may return no devices, +because BlueZ still believes it is discovering and does not start the kernel +scan, and the scan after that works normally. + +``bluetoothd`` can also lose track of the kernel's scan state for good (see +`bluez/bluez#807 `_). Then every +stop on that adapter is rejected and no scan on it reaches the kernel until +the adapter is reset. Bleak detects this as two ``InProgress`` rejections in +a row with no successful stop between them: the second one is logged at +WARNING level and ``BleakScanner.stop()`` raises ``BleakDBusError`` so the +caller knows the adapter is not scanning. Power cycle or re-plug the adapter, +or restart ``bluetoothd``. The ``Discovering`` property of the adapter is not +a reliable indicator here, since BlueZ leaves it set to ``true`` in both the +harmless and the stuck case. The detection only sees stops made by the +current process while it is the only scanner on the adapter; another active +scanner on the same adapter makes BlueZ answer the stop without asking the +kernel, which counts as a successful stop. + ---------- macOS Bugs ---------- diff --git a/tests/backends/bluezdbus/test_issue_2021.py b/tests/backends/bluezdbus/test_issue_2021.py index f89a2cbdb..0ddaab3b9 100644 --- a/tests/backends/bluezdbus/test_issue_2021.py +++ b/tests/backends/bluezdbus/test_issue_2021.py @@ -1,5 +1,6 @@ """Regression test for .""" +import logging import sys import pytest @@ -23,6 +24,8 @@ NO_FILTERS: dict[str, Variant] = {} +LOGGER = "bleak.backends.bluezdbus.manager" + def _on_advertisement(path: str, props: Device1) -> None: pass @@ -37,53 +40,120 @@ def _on_removed(path: str) -> None: class FakeBus: - """Answers every call with a method return, except StopDiscovery.""" + """ + Answers every call with a method return, except StopDiscovery, which is + answered from ``stop_errors`` in order (``None`` means success) and with + success once that list is used up. + """ - def __init__(self, stop_error: "str | None") -> None: - self.stop_error = stop_error + def __init__(self, stop_errors: "list[str | None]") -> None: + self.stop_errors = list(stop_errors) self.members: "list[str | None]" = [] async def call(self, msg: Message) -> Message: # outgoing messages carry serial 0 until a real bus assigns one, so # replies are built explicitly rather than derived from the request self.members.append(msg.member) - if msg.member == "StopDiscovery" and self.stop_error is not None: - return Message( - message_type=MessageType.ERROR, - reply_serial=1, - error_name=self.stop_error, - signature="s", - body=["Operation already in progress"], - ) + if msg.member == "StopDiscovery" and self.stop_errors: + error = self.stop_errors.pop(0) + if error is not None: + return Message( + message_type=MessageType.ERROR, + reply_serial=1, + error_name=error, + signature="s", + body=["Operation already in progress"], + ) return Message(message_type=MessageType.METHOD_RETURN, reply_serial=1) -def make_manager(stop_error: "str | None") -> "tuple[BlueZManager, FakeBus]": +def make_manager(stop_errors: "list[str | None]") -> "tuple[BlueZManager, FakeBus]": manager = BlueZManager() - bus = FakeBus(stop_error) + bus = FakeBus(stop_errors) manager._bus = bus # type: ignore[assignment] manager._properties[ADAPTER_PATH] = {defs.ADAPTER_INTERFACE: {}} return manager, bus -async def test_stop_tolerates_in_progress() -> None: +async def _scan_and_stop(manager: BlueZManager) -> None: + stop = await manager.active_scan(ADAPTER_PATH, NO_FILTERS, _ADV, _REMOVED) + await stop() + + +def _records(caplog: pytest.LogCaptureFixture, level: int) -> "list[str]": + return [r.getMessage() for r in caplog.records if r.levelno == level] + + +async def test_stop_tolerates_in_progress(caplog: pytest.LogCaptureFixture) -> None: """ BlueZ answers StopDiscovery with InProgress when the kernel has already stopped scanning; the discovery session is gone by then, so stop() must - return normally and leave the manager's bookkeeping clean. + return normally, leave the manager's bookkeeping clean, and leave a + record in the log. """ - manager, bus = make_manager(defs.BLUEZ_ERROR_IN_PROGRESS) + manager, bus = make_manager([defs.BLUEZ_ERROR_IN_PROGRESS]) - stop = await manager.active_scan(ADAPTER_PATH, NO_FILTERS, _ADV, _REMOVED) - await stop() + with caplog.at_level(logging.INFO, logger=LOGGER): + await _scan_and_stop(manager) assert bus.members == ["SetDiscoveryFilter", "StartDiscovery", "StopDiscovery"] assert manager._advertisement_callbacks[ADAPTER_PATH] == [] assert manager._device_removed_callbacks == [] + assert any( + "InProgress" in m and ADAPTER_PATH in m for m in _records(caplog, logging.INFO) + ) + assert not _records(caplog, logging.WARNING) + + +async def test_second_consecutive_in_progress_raises( + caplog: pytest.LogCaptureFixture, +) -> None: + """ + A single rejection is a race with the kernel's scan timeout and the next + stop succeeds. Two in a row means bluetoothd's discovery state is stuck + and no scan on the adapter reaches the kernel, so the second one must be + reported as an error, with a warning that says what to do. + """ + manager, bus = make_manager( + [defs.BLUEZ_ERROR_IN_PROGRESS, defs.BLUEZ_ERROR_IN_PROGRESS] + ) + + with caplog.at_level(logging.INFO, logger=LOGGER): + await _scan_and_stop(manager) + with pytest.raises(BleakDBusError) as info: + await _scan_and_stop(manager) + + assert info.value.dbus_error == defs.BLUEZ_ERROR_IN_PROGRESS + assert bus.members.count("StopDiscovery") == 2 + # the callbacks were still removed before the failing stop + assert manager._advertisement_callbacks[ADAPTER_PATH] == [] + assert manager._device_removed_callbacks == [] + warnings = _records(caplog, logging.WARNING) + assert len(warnings) == 1 + assert "twice in a row" in warnings[0] and ADAPTER_PATH in warnings[0] + + +async def test_a_clean_stop_resets_the_count(caplog: pytest.LogCaptureFixture) -> None: + """A successful stop between two rejections means they were two separate + races, not a stuck adapter; neither may raise.""" + manager, bus = make_manager( + [defs.BLUEZ_ERROR_IN_PROGRESS, None, defs.BLUEZ_ERROR_IN_PROGRESS] + ) + + with caplog.at_level(logging.INFO, logger=LOGGER): + await _scan_and_stop(manager) + await _scan_and_stop(manager) + await _scan_and_stop(manager) + + assert bus.members.count("StopDiscovery") == 3 + # only the successful stop goes on to reset the discovery filter + assert bus.members.count("SetDiscoveryFilter") == 3 + 1 + assert len(_records(caplog, logging.INFO)) == 2 + assert not _records(caplog, logging.WARNING) async def test_stop_still_raises_other_errors() -> None: - manager, _ = make_manager(defs.BLUEZ_ERROR_FAILED) + manager, _ = make_manager([defs.BLUEZ_ERROR_FAILED]) stop = await manager.active_scan(ADAPTER_PATH, NO_FILTERS, _ADV, _REMOVED) with pytest.raises(BleakDBusError) as info: From e6392d183349c6a0a8c0484278a2c5f10b7f94cd Mon Sep 17 00:00:00 2001 From: Clint Goudie-Nice <3596299+cgoudie@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:45:40 -0600 Subject: [PATCH 7/7] tests: suppress private member access in the #2021 test the way the other tests do pyright strict on Linux flags the test's reads of the manager's private callback and property stores. Read them once through helpers annotated with the repository's usual pyright ignore comment. --- tests/backends/bluezdbus/test_issue_2021.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/backends/bluezdbus/test_issue_2021.py b/tests/backends/bluezdbus/test_issue_2021.py index 0ddaab3b9..b9249d88a 100644 --- a/tests/backends/bluezdbus/test_issue_2021.py +++ b/tests/backends/bluezdbus/test_issue_2021.py @@ -71,7 +71,8 @@ def make_manager(stop_errors: "list[str | None]") -> "tuple[BlueZManager, FakeBu manager = BlueZManager() bus = FakeBus(stop_errors) manager._bus = bus # type: ignore[assignment] - manager._properties[ADAPTER_PATH] = {defs.ADAPTER_INTERFACE: {}} + properties = manager._properties # pyright: ignore[reportPrivateUsage] + properties[ADAPTER_PATH] = {defs.ADAPTER_INTERFACE: {}} return manager, bus @@ -80,6 +81,15 @@ async def _scan_and_stop(manager: BlueZManager) -> None: await stop() +def _assert_callbacks_removed(manager: BlueZManager) -> None: + """stop() removes the session's callbacks before it talks to BlueZ, so + they must be gone whether or not the StopDiscovery call succeeded.""" + adv = manager._advertisement_callbacks # pyright: ignore[reportPrivateUsage] + removed = manager._device_removed_callbacks # pyright: ignore[reportPrivateUsage] + assert adv[ADAPTER_PATH] == [] + assert removed == [] + + def _records(caplog: pytest.LogCaptureFixture, level: int) -> "list[str]": return [r.getMessage() for r in caplog.records if r.levelno == level] @@ -97,8 +107,7 @@ async def test_stop_tolerates_in_progress(caplog: pytest.LogCaptureFixture) -> N await _scan_and_stop(manager) assert bus.members == ["SetDiscoveryFilter", "StartDiscovery", "StopDiscovery"] - assert manager._advertisement_callbacks[ADAPTER_PATH] == [] - assert manager._device_removed_callbacks == [] + _assert_callbacks_removed(manager) assert any( "InProgress" in m and ADAPTER_PATH in m for m in _records(caplog, logging.INFO) ) @@ -125,9 +134,7 @@ async def test_second_consecutive_in_progress_raises( assert info.value.dbus_error == defs.BLUEZ_ERROR_IN_PROGRESS assert bus.members.count("StopDiscovery") == 2 - # the callbacks were still removed before the failing stop - assert manager._advertisement_callbacks[ADAPTER_PATH] == [] - assert manager._device_removed_callbacks == [] + _assert_callbacks_removed(manager) warnings = _records(caplog, logging.WARNING) assert len(warnings) == 1 assert "twice in a row" in warnings[0] and ADAPTER_PATH in warnings[0]