From e55b506286dc1e41a2c8dc8437c89b3f4ef1f0b3 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 16 Oct 2026 22:56:54 +0530 Subject: [PATCH] fix(bindings): reset a BlueZ controller the kernel lost track of On Linux 6.18 with BlueZ 5.87 a Raspberry Pi 4 stopped accepting any new advertisement ("Failed to register advertisement") while BlueZ reported every slot free, and the state outlived the service: each replacement container failed the same way until the controller was reset. The kernel clears its advertising flag on every LE connection complete, assuming the controller stopped. A controller only stops for a link in which it is the peripheral. When advertising is on while our central's dial is pending (a discovery restart re-enables it), the dial completes in the central role, the controller keeps advertising, and the kernel believes it stopped. From then on every LE Set Advertising Parameters is answered Command Disallowed. Captured in the HCI trace on the Pi. - BlePeripheral resets the adapter once (Adapter1.Powered off and on, which sends an HCI Reset) when BlueZ refuses an advertisement that way and reports a free slot, then starts again. Once only: the reset drops every link on the adapter. - bluez_restarts also re-registers a running role when an adapter powers back on. BlueZ drops every discovery session on power-off, so without it the central in the same process went deaf after the reset. Verified on a wedged Pi: the start reset the adapter once and was advertising a second later, with the controller accepting the advertising parameters again. --- CHANGELOG.md | 9 ++ .../offline_protocol_sdk/ble_peripheral.py | 129 ++++++++++++++++- .../offline_protocol_sdk/bluez_restarts.py | 73 +++++++--- bindings/python/tests/test_ble_peripheral.py | 133 ++++++++++++++++++ bindings/python/tests/test_bluez_restarts.py | 25 ++++ docs/bridges/README.md | 2 +- docs/bridges/python.md | 45 +++++- 7 files changed, 394 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b48461..4fe70ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,15 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Fixed +- **Python: a Linux box that could no longer advertise recovers on its own.** + Linux loses track of the controller's advertising when a central-role + connection completes while advertising is on: the controller keeps + advertising, the kernel believes it stopped, and every later advertisement + is refused with "Failed to register advertisement" until the controller is + reset, across service restarts. When BlueZ refuses an advertisement that + way while reporting free slots, the peripheral now powers the adapter off + and on once (which resets the controller) and starts again, and both roles + register again whenever an adapter powers back on. - **Python: Bluetooth LE no longer takes bluetoothd down, and survives it restarting.** On BlueZ 5.87 bluetoothd exits the moment a discovery filter naming a service UUID is set, and the central set one at diff --git a/bindings/python/offline_protocol_sdk/ble_peripheral.py b/bindings/python/offline_protocol_sdk/ble_peripheral.py index ce525318..ba73e335 100644 --- a/bindings/python/offline_protocol_sdk/ble_peripheral.py +++ b/bindings/python/offline_protocol_sdk/ble_peripheral.py @@ -306,6 +306,112 @@ async def _unregister_bluez_application(server: Any) -> None: logger.debug("unexporting the GATT application failed", exc_info=True) +# BlueZ's answer to a RegisterAdvertisement the kernel refused. A full set of +# advertising slots is answered differently ("Maximum advertisements +# reached"), before the kernel is asked. +_ADVERTISEMENT_REFUSED = "Failed to register advertisement" +_BLUEZ_ADAPTER_IFACE = "org.bluez.Adapter1" +_BLUEZ_ADVERTISING_IFACE = "org.bluez.LEAdvertisingManager1" + +# Seconds to let a re-powered adapter settle before registering again. +_ADAPTER_RESET_SETTLE = 1.0 + + +async def _reset_wedged_advertising() -> bool: + """Power-cycle an adapter whose controller refuses every advertisement. + + Returns whether it did. Best effort, and never raises. + + Linux loses track of the controller's advertising: the kernel clears its + "advertising" flag on every LE connection complete, assuming the + controller stopped advertising, which a controller only does for a link + in which it is the peripheral. When advertising is enabled while our + central's dial is pending (a discovery restart re-enables it), the dial + completes in the central role and the controller keeps advertising while + the kernel believes it stopped. From then on the kernel never disables it, + every LE Set Advertising Parameters is answered Command Disallowed, and + every new advertisement is refused with "Failed to register + advertisement", for this process and every later one, until the + controller is reset. Seen on Raspberry Pi 4s (Linux 6.18, BlueZ 5.87): a + replaced service could not advertise again, which looked like + advertisements left behind holding the slots, while BlueZ reported all of + them free. + + Powering the adapter off and on re-initialises the controller with an + HCI Reset, which ends the stale advertising. It also drops every link the + adapter holds, for every process using it, which is why it runs only for + this refusal, and only when BlueZ reports a free slot: with none free the + refusal is about the slots, and a reset would not help. + """ + try: + from dbus_next import BusType, Message, MessageType, Variant # type: ignore[import-not-found] + from dbus_next.aio import MessageBus # type: ignore[import-not-found] + except Exception: + return False + bus: Any = None + try: + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + reply = await bus.call(Message( + destination=_BLUEZ_SERVICE, + path="/", + interface="org.freedesktop.DBus.ObjectManager", + member="GetManagedObjects", + )) + if reply.message_type != MessageType.METHOD_RETURN: + return False + adapter = _advertising_adapter(reply.body[0]) + if adapter is None: + return False + path, free_slots = adapter + if free_slots < 1: + return False + logger.warning( + "the controller refuses every advertisement while BlueZ has %d free; " + "power-cycling %s to reset it", free_slots, path, + ) + for powered in (False, True): + reply = await bus.call(Message( + destination=_BLUEZ_SERVICE, + path=path, + interface="org.freedesktop.DBus.Properties", + member="Set", + signature="ssv", + body=[_BLUEZ_ADAPTER_IFACE, "Powered", Variant("b", powered)], + )) + if reply.message_type != MessageType.METHOD_RETURN: + logger.warning("powering %s %s failed: %s", path, + "on" if powered else "off", reply.error_name) + return False + await asyncio.sleep(_ADAPTER_RESET_SETTLE) + return True + except Exception: + logger.debug("resetting the adapter failed", exc_info=True) + return False + finally: + if bus is not None: + try: + bus.disconnect() + except Exception: + logger.debug("closing the adapter reset bus failed", exc_info=True) + + +def _advertising_adapter(objects: Any) -> tuple[str, int] | None: + """The first adapter that advertises, and its free advertising slots. + + The first in path order, as bless picks it: ``hci0`` before ``hci1``. + """ + for path in sorted(objects): + interfaces = objects[path] + if _BLUEZ_ADAPTER_IFACE not in interfaces: + continue + manager = interfaces.get(_BLUEZ_ADVERTISING_IFACE) + if manager is None: + continue + free = manager.get("SupportedInstances") + return path, int(getattr(free, "value", free) or 0) + return None + + def _central_id_from_device_path(path: Any) -> str | None: """``/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF`` -> ``AA:BB:CC:DD:EE:FF``.""" if not isinstance(path, str): @@ -536,7 +642,7 @@ def __init__( # restart drops the GATT application and the advertisement, and # nothing on the bus tells the process that registered them. self._registration = StaysRegistered( - self._start_advertising, self._stop_advertising, self._emit_diagnostic + self._bring_up, self._stop_advertising, self._emit_diagnostic ) # Central id -> the address its hello proved. A link bound here feeds # its fragments to the core under that address, which is what lets a @@ -609,6 +715,27 @@ async def stop(self) -> None: """Stop advertising and disconnect all centrals.""" await self._registration.stop() + async def _bring_up(self) -> None: + """Start advertising, resetting a wedged controller once (Linux). + + See :func:`_reset_wedged_advertising`. Once, not in a loop: a refusal + that survives a controller reset has another cause, and resetting + again would only drop every link on the adapter again. + """ + try: + await self._start_advertising() + except TransportError as exc: + if sys.platform != "linux" or _ADVERTISEMENT_REFUSED not in str(exc): + raise + if not await _reset_wedged_advertising(): + raise + self._emit_diagnostic( + "warning", + "the controller refused the advertisement; reset the adapter, starting again", + {"error": str(exc)}, + ) + await self._start_advertising() + async def _start_advertising(self) -> None: if self._state == TransportState.RUNNING: raise TransportError("BLE peripheral is already running") diff --git a/bindings/python/offline_protocol_sdk/bluez_restarts.py b/bindings/python/offline_protocol_sdk/bluez_restarts.py index 3d04676d..3a3dc12c 100644 --- a/bindings/python/offline_protocol_sdk/bluez_restarts.py +++ b/bindings/python/offline_protocol_sdk/bluez_restarts.py @@ -15,6 +15,14 @@ ``on_restart`` once the name has a new owner, which is when registering again can succeed. +An adapter powered off and on again is the same loss for the central: BlueZ +drops every discovery session when the adapter powers off, and the scan does +not come back with the power. So ``on_restart`` is also called when an +adapter's ``Powered`` turns true. The peripheral powers the adapter off and on +itself to reset a controller that refuses every advertisement (see +``ble_peripheral._reset_wedged_advertising``); without this the central in +the same process stopped hearing anyone from then on. + Linux only, and best effort: where there is no system bus to watch (a test host, a container without the grant) it logs and stays inert, which is the behaviour that existed before it. @@ -32,10 +40,15 @@ logger = logging.getLogger(__name__) _MATCH = "type='signal',sender='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.bluez'" +_POWER_MATCH = ( + "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.Properties'," + "member='PropertiesChanged',arg0='org.bluez.Adapter1'" +) class BlueZRestarts: - """Counts bluetoothd's exits and calls ``on_restart`` when it is back.""" + """Counts bluetoothd's exits and calls ``on_restart`` when it is back, or + when an adapter powers back on.""" def __init__(self, on_restart: Callable[[], None]) -> None: self._on_restart = on_restart @@ -57,16 +70,17 @@ async def start(self) -> None: from dbus_fast.aio import MessageBus bus = await MessageBus(bus_type=BusType.SYSTEM).connect() - await bus.call( - Message( - destination="org.freedesktop.DBus", - path="/org/freedesktop/DBus", - interface="org.freedesktop.DBus", - member="AddMatch", - signature="s", - body=[_MATCH], + for rule in (_MATCH, _POWER_MATCH): + await bus.call( + Message( + destination="org.freedesktop.DBus", + path="/org/freedesktop/DBus", + interface="org.freedesktop.DBus", + member="AddMatch", + signature="s", + body=[rule], + ) ) - ) except Exception as exc: logger.info("bluetoothd restarts are not watched here: %s", exc) return @@ -95,6 +109,10 @@ def stop(self) -> None: logger.debug("closing the bluetoothd watch failed", exc_info=True) def _observe(self, message: Any) -> None: + if _powered_on(message): + logger.warning("the Bluetooth adapter powered on again; what was registered with it is gone") + self._call_back() + return if message.member != "NameOwnerChanged" or not message.body or message.body[0] != "org.bluez": return _, old_owner, new_owner = message.body @@ -104,10 +122,26 @@ def _observe(self, message: Any) -> None: logger.warning("bluetoothd left the bus; what was registered with it is gone") elif new_owner: self._owned.set() - try: - self._on_restart() - except Exception: - logger.exception("handling a bluetoothd restart failed") + self._call_back() + + def _call_back(self) -> None: + try: + self._on_restart() + except Exception: + logger.exception("handling a bluetoothd restart failed") + + +def _powered_on(message: Any) -> bool: + """Whether ``message`` says an adapter's ``Powered`` turned true.""" + if message.member != "PropertiesChanged" or not message.body: + return False + if message.body[0] != "org.bluez.Adapter1" or len(message.body) < 2: + return False + changed = message.body[1] + if not isinstance(changed, dict) or "Powered" not in changed: + return False + powered = changed["Powered"] + return bool(getattr(powered, "value", powered)) class StaysRegistered: @@ -120,8 +154,9 @@ class StaysRegistered: "Message recipient disconnected from message bus without replying") is retried once the new bluetoothd is up. A start refused for any other reason is raised at once, as before. - - A role that is running when bluetoothd restarts is torn down and - brought up again, so it is registered with the new process. + - A role that is running when bluetoothd restarts, or when an adapter + powers on again, is torn down and brought up again, so it is + registered with the process and the adapter as they are now. Only one of the two ever drives the role. A start cut short by an exit is followed, about 150 ms later, by the successor claiming the name; that @@ -225,7 +260,7 @@ def _restarted(self) -> None: self._recovery = asyncio.ensure_future(self._register_again()) async def _register_again(self) -> None: - self._emit("warning", "bluetoothd restarted; registering with it again", None) + self._emit("warning", "bluetoothd restarted or the adapter powered on again; registering again", None) await self._tear_down() # A bluetoothd that has just started can refuse a registration for a # while (its adapter still powering up, or "InProgress" for a @@ -241,11 +276,11 @@ async def _register_again(self) -> None: error = exc await self._tear_down() else: - self._emit("info", "registered with the restarted bluetoothd", None) + self._emit("info", "registered with bluetoothd again", None) return # Nothing tries again after this until bluetoothd restarts once more # or the owner restarts the role: the error is the operator's signal. - self._emit("error", "could not register with the restarted bluetoothd", {"error": str(error)}) + self._emit("error", "could not register with bluetoothd again", {"error": str(error)}) def _weakly(fn: Callable[..., Any]) -> Callable[..., Any]: diff --git a/bindings/python/tests/test_ble_peripheral.py b/bindings/python/tests/test_ble_peripheral.py index 4a6dd577..3e2becaa 100644 --- a/bindings/python/tests/test_ble_peripheral.py +++ b/bindings/python/tests/test_ble_peripheral.py @@ -1018,3 +1018,136 @@ async def test_a_server_still_setting_up_is_cancelled(self, monkeypatch): server.setup_task.cancel.assert_called_once() server.app.unregister.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# A controller left advertising behind the kernel's back refuses every new +# advertisement until it is reset. The start resets the adapter once for +# that refusal, and only that one. +# --------------------------------------------------------------------------- + +REFUSED = TransportError("BLE peripheral start failed: Failed to register advertisement") + + +class TestWedgedControllerReset: + def started(self, mock_protocol, monkeypatch, outcomes, reset=True, platform="linux"): + from offline_protocol_sdk import ble_peripheral as module + + monkeypatch.setattr(module.sys, "platform", platform) + resets = AsyncMock(return_value=reset) + monkeypatch.setattr(module, "_reset_wedged_advertising", resets) + peripheral = BlePeripheral(mock_protocol, device_id="coordinator") + peripheral._start_advertising = AsyncMock(side_effect=outcomes) + return peripheral, resets + + @pytest.mark.asyncio + async def test_the_refusal_resets_the_adapter_and_starts_again(self, mock_protocol, monkeypatch): + peripheral, resets = self.started(mock_protocol, monkeypatch, [REFUSED, None]) + await peripheral._bring_up() + resets.assert_awaited_once() + assert peripheral._start_advertising.await_count == 2 + + @pytest.mark.asyncio + async def test_a_refusal_that_survives_the_reset_is_raised(self, mock_protocol, monkeypatch): + """Once: a second reset would only drop every link on the adapter again.""" + peripheral, resets = self.started(mock_protocol, monkeypatch, [REFUSED, REFUSED]) + with pytest.raises(TransportError, match="Failed to register advertisement"): + await peripheral._bring_up() + resets.assert_awaited_once() + + @pytest.mark.asyncio + async def test_no_reset_no_second_start(self, mock_protocol, monkeypatch): + peripheral, _ = self.started(mock_protocol, monkeypatch, [REFUSED], reset=False) + with pytest.raises(TransportError, match="Failed to register advertisement"): + await peripheral._bring_up() + peripheral._start_advertising.assert_awaited_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize("error, platform", [ + (TransportError("BLE peripheral start failed: Maximum advertisements reached"), "linux"), + (TransportError("BLE peripheral start failed: org.bluez.Error.NotReady"), "linux"), + (TransportError("BLE peripheral start failed: Failed to parse advertisement."), "linux"), + (REFUSED, "darwin"), + ]) + async def test_any_other_failure_is_raised_untouched(self, mock_protocol, monkeypatch, error, platform): + peripheral, resets = self.started(mock_protocol, monkeypatch, [error], platform=platform) + with pytest.raises(TransportError) as raised: + await peripheral._bring_up() + assert raised.value is error + resets.assert_not_awaited() + + @pytest.mark.asyncio + async def test_start_goes_through_the_reset(self, mock_protocol, monkeypatch): + """The registration drives _bring_up, not _start_advertising: a start + and a recovery after a bluetoothd restart both get the reset.""" + peripheral, resets = self.started(mock_protocol, monkeypatch, [REFUSED, None]) + monkeypatch.setattr(peripheral._registration, "_watch", MagicMock(start=AsyncMock(), watching=False)) + await peripheral.start() + resets.assert_awaited_once() + + +class _AdapterBus: + """A system bus holding one adapter, recording what is set on it.""" + + def __init__(self, free_slots, set_fails=False): + self.free_slots = free_slots + self.set_fails = set_fails + self.powered: list[bool] = [] + self.disconnected = False + + async def connect(self): + return self + + async def call(self, message): + from dbus_next import MessageType, Variant + + if message.member == "GetManagedObjects": + return SimpleNamespace(message_type=MessageType.METHOD_RETURN, body=[{ + "/org/bluez": {"org.bluez.AgentManager1": {}}, + "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF": {"org.bluez.Device1": {}}, + "/org/bluez/hci0": { + "org.bluez.Adapter1": {}, + "org.bluez.LEAdvertisingManager1": { + "SupportedInstances": Variant("y", self.free_slots), + }, + }, + }]) + assert message.path == "/org/bluez/hci0" + assert message.body[:2] == ["org.bluez.Adapter1", "Powered"] + if self.set_fails: + return SimpleNamespace(message_type=MessageType.ERROR, error_name="org.bluez.Error.Failed") + self.powered.append(message.body[2].value) + return SimpleNamespace(message_type=MessageType.METHOD_RETURN, body=[]) + + def disconnect(self): + self.disconnected = True + + +@pytest.mark.skipif(sys.platform == "win32", reason="dbus_next is the BlueZ backend's dependency") +class TestResetWedgedAdvertising: + async def reset(self, monkeypatch, bus): + pytest.importorskip("dbus_next") + from offline_protocol_sdk import ble_peripheral as module + + monkeypatch.setattr("dbus_next.aio.MessageBus", lambda bus_type: bus) + monkeypatch.setattr(module, "_ADAPTER_RESET_SETTLE", 0) + return await module._reset_wedged_advertising() + + @pytest.mark.asyncio + async def test_powers_the_adapter_off_then_on(self, monkeypatch): + bus = _AdapterBus(free_slots=5) + assert await self.reset(monkeypatch, bus) is True + assert bus.powered == [False, True] + assert bus.disconnected + + @pytest.mark.asyncio + async def test_no_free_slot_means_the_slots_refused_and_nothing_is_reset(self, monkeypatch): + bus = _AdapterBus(free_slots=0) + assert await self.reset(monkeypatch, bus) is False + assert bus.powered == [] + assert bus.disconnected + + @pytest.mark.asyncio + async def test_a_refused_power_change_is_no_reset(self, monkeypatch): + bus = _AdapterBus(free_slots=5, set_fails=True) + assert await self.reset(monkeypatch, bus) is False diff --git a/bindings/python/tests/test_bluez_restarts.py b/bindings/python/tests/test_bluez_restarts.py index 34b9e59b..cdca98a6 100644 --- a/bindings/python/tests/test_bluez_restarts.py +++ b/bindings/python/tests/test_bluez_restarts.py @@ -85,6 +85,31 @@ def test_other_names_and_signals_are_ignored(self): watch._observe(SimpleNamespace(member="PropertiesChanged", body=["org.bluez", ":1.2", ""])) assert watch.exits == 0 and calls == [] + def test_an_adapter_powering_on_calls_back_without_counting_an_exit(self): + """BlueZ drops every discovery session when the adapter powers off, + and the peripheral power-cycles it to reset a wedged controller: the + central in the same process must register again when it is back.""" + calls = [] + watch = BlueZRestarts(lambda: calls.append(1)) + + def powered(value, interface="org.bluez.Adapter1"): + return SimpleNamespace( + member="PropertiesChanged", + body=[interface, {"Powered": SimpleNamespace(value=value)}, []], + ) + + watch._observe(powered(False)) + assert calls == [] + watch._observe(powered(True)) + assert calls == [1] and watch.exits == 0 + # Another interface's Powered, or an adapter change without it, is not one. + watch._observe(powered(True, interface="org.bluez.Device1")) + watch._observe(SimpleNamespace( + member="PropertiesChanged", + body=["org.bluez.Adapter1", {"Discovering": SimpleNamespace(value=True)}, []], + )) + assert calls == [1] + @pytest.mark.asyncio async def test_waiting_for_the_owner_returns_when_the_name_is_claimed(self): watch = BlueZRestarts(lambda: None) diff --git a/docs/bridges/README.md b/docs/bridges/README.md index e7ce84bb..51981c4d 100644 --- a/docs/bridges/README.md +++ b/docs/bridges/README.md @@ -661,7 +661,7 @@ found by the first application to update. |---------|------| | Swift | The manual Objective-C bridge kept in step with every `@objc` method; secure storage backed by Keychain; a live-instance check before emitting; the telemetry session boundary inside a background task (C12); a Network-framework peer-stream manager that announces a peer only under the address its preamble proved, one per address (S8) | | Kotlin | Secure storage backed by Keystore; no blocking work on the main looper; awareness that platform callbacks arrive on binder threads; the telemetry session boundary from an `Application.ActivityLifecycleCallbacks` watcher, never `onHostPause` (C12); a peer-stream manager, over a Wi-Fi Direct group and the Wi-Fi network, that announces a peer only under the address its preamble proved, one per address, by the lower-opened rule (K8) | -| Python | Nothing platform-specific; it is the thinnest binding and therefore the best place to smoke-test an ABI change; a re-entrant lock on the generated callback handle map, installed at import, because the collector can free a core object inside a callback lookup and the core's drop then asks for that lock again (P10); the host platform for telemetry from `platform`; a BLE peripheral that serves the address and the core-built identity assertion, and a central that verifies before it announces (P8); a peripheral that binds each central to the address its hello proved, and a central that writes its hello before its first write (P15); a central that scans unfiltered on BlueZ, and both roles registering again when bluetoothd restarts (P16); a peer-stream manager that announces a host only under the address its preamble proved, and keeps one announced stream per address (P9); a gateway-daemon client that announces a session only once the gateway bound it to this device's address, and settles a frame only on the gateway's verdict, never on the write (P11) | +| Python | Nothing platform-specific; it is the thinnest binding and therefore the best place to smoke-test an ABI change; a re-entrant lock on the generated callback handle map, installed at import, because the collector can free a core object inside a callback lookup and the core's drop then asks for that lock again (P10); the host platform for telemetry from `platform`; a BLE peripheral that serves the address and the core-built identity assertion, and a central that verifies before it announces (P8); a peripheral that binds each central to the address its hello proved, and a central that writes its hello before its first write (P15); a central that scans unfiltered on BlueZ, and both roles registering again when bluetoothd restarts or the adapter powers on (P16, P17); a peripheral that resets, once, a controller the kernel lost track of and that refuses every advertisement (P17); a peer-stream manager that announces a host only under the address its preamble proved, and keeps one announced stream per address (P9); a gateway-daemon client that announces a session only once the gateway bound it to this device's address, and settles a frame only on the gateway's verdict, never on the write (P11) | | TypeScript | Config normalization, event typing kept in step with the core, no assumption that a native method exists in an older binary, and no telemetry lifecycle code of its own | | C | Nothing the library can do for it: the transports, storage and lifecycle are all the host's; callbacks callable from any thread that never call back into the engine and never unwind; every string the library hands out freed with `offline_protocol_string_free`; `os_major` supplied for telemetry, `os` never (K1 to K3 in [c.md](c.md)) | diff --git a/docs/bridges/python.md b/docs/bridges/python.md index cd77f458..55f4407d 100644 --- a/docs/bridges/python.md +++ b/docs/bridges/python.md @@ -561,13 +561,56 @@ Three details, each of which a simpler version got wrong: registered another copy of the service and each recovery leaked a system-bus connection. - **A recovery that runs out of tries stays down.** After the last try fails - the role reports "could not register with the restarted bluetoothd" at + the role reports "could not register with bluetoothd again" at error level, and nothing tries again until bluetoothd restarts once more or the owner restarts the role. The watch is Linux only and best effort: with no system bus to watch it logs and stays inert, which is the behaviour before it existed. +## P17. On BlueZ a controller left advertising behind the kernel's back is reset once + +**Invariant:** a peripheral whose advertisement the kernel refused while BlueZ +reports free advertising slots resets the adapter once and starts again, and +both roles register again once the adapter is powered back on. + +The kernel loses track of the controller's advertising. Linux clears its own +"advertising" flag on every LE connection complete, assuming the controller +stopped advertising, but a controller only stops for a link in which it is the +peripheral. When advertising is on while our central's dial is pending (a +discovery restart re-enables it in the window), the dial completes in the +central role, the controller keeps advertising, and the kernel believes it +stopped. From then on the kernel never disables it, every `LE Set Advertising +Parameters` is answered `Command Disallowed`, and every new advertisement is +refused with "Failed to register advertisement". The state outlives the +process: on two Raspberry Pi 4s (Linux 6.18, BlueZ 5.87) a replaced service +could not advertise again, which looked like advertisements left behind by the +replaced container holding the slots, while BlueZ reported all of them free. +The trace that showed it is in the HCI log: a central-role `LE Connection +Complete` with advertising enabled, then `Command Disallowed` on every +advertising and random address command, and no `LE Set Advertise Enable` +again. + +- **The reset.** Powering the adapter off and on re-initialises the controller + with an HCI Reset, which ends the stale advertising. + `ble_peripheral._reset_wedged_advertising` does it through BlueZ's own + `Adapter1.Powered`, so it needs no more than the Bluetooth grant the + peripheral already has. +- **Only for this refusal, and only once.** The reset drops every link the + adapter holds, for every process using it. It runs only for "Failed to + register advertisement", only when `SupportedInstances` (the free slots) + is above zero, and only once per start: with no slot free the refusal is + about the slots, and a refusal that survives a reset has another cause. +- **The central follows the power.** BlueZ drops every discovery session when + the adapter powers off, and the scan does not come back with the power. + `bluez_restarts` treats `Powered` turning true on `org.bluez.Adapter1` like a + new bluetoothd, so a running central registers again. Without it the + central in the same process stopped hearing anyone after the peripheral's + reset, and after any power toggle by anyone else. + +The kernel's flag is the defect, and a kernel without it never takes this +path: the refusal never comes. + ## Testing ```bash