From ad24518b71228544e6b8c78a050550f283199950 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 7 Sep 2026 15:50:06 +0300 Subject: [PATCH] Refresh selected BlueZ path before K1 GATT operations --- .../2026-09-07-k1-bluez-selection-r11.md | 62 +++++++ docs/audits/2026-09-07-k1-node-preview-r10.md | 9 + plugins/xgrids-k1/packaging/build_deb.py | 2 +- .../device_plugins/xgrids_k1/ble/scanner.py | 57 +++++- .../xgrids_k1/ble/wifi_provisioning.py | 22 +-- tests/test_ble_scanner.py | 18 -- tests/test_bluez_gatt_selection.py | 170 ++++++++++++++++++ tests/test_wifi_provisioning.py | 18 +- 8 files changed, 303 insertions(+), 55 deletions(-) create mode 100644 docs/audits/2026-09-07-k1-bluez-selection-r11.md create mode 100644 tests/test_bluez_gatt_selection.py diff --git a/docs/audits/2026-09-07-k1-bluez-selection-r11.md b/docs/audits/2026-09-07-k1-bluez-selection-r11.md new file mode 100644 index 0000000..e35fc36 --- /dev/null +++ b/docs/audits/2026-09-07-k1-bluez-selection-r11.md @@ -0,0 +1,62 @@ +# K1 BlueZ selection R11 + +## Observed failure + +The owner reported another Bluetooth failure after installing R10. Read-only +dpkg verification confirms Node 0.8.8 and K1 0.1.7+private.1. The bounded +service-journal error identifies network.provision, _provision_wifi_impl, +BleakClient.connect, add_device_watcher and BlueZManager._check_device. +The failure occurs before GATT contract validation or the Wi-Fi write. +The screenshot's statement that Wi-Fi was not sent is consistent with this +trace. A new screenshot alone does not prove cache clearing or preview acceptance. + +[Bleak's Linux client implementation](https://bleak.readthedocs.io/en/stable/_modules/bleak/backends/bluezdbus/client.html) +retains the D-Bus path of a supplied BLEDevice and skips discovery when that +path is already set. A Python object retained through the enrollment form +does not keep the BlueZ object alive. R10 addressed only status reads, leaving +provisioning with the same stale-path failure. + +## Change and boundaries + +One plugin-local ensure_device_for_gatt preflight now serves both status +reads and provisioning. On Linux it first checks the exact selected BlueZ +path. When absent, it observes the same address on the same adapter once, +for at most eight seconds. It requires that exact native path to exist again +before allowing the original BLEDevice into the single GATT connection. +Neither device selection nor capture identity is replaced. This also preserves +the validated capture handed to the facade's existing session admission. + +The preflight requires an active status-read or wifi-provision arbiter owner +and checks that owner after each await. Invalidated captures are rejected +again immediately before GATT. Public discovery generations are unchanged. +Wrong address, different adapter, missing object and owner loss fail before +the baseline read or write. There is no retry after a failed connection or +write, no pairing, notification subscription, alternate device, fallback +network scan or new credential source. The existing durable write-dispatch +fence, reviewed GATT contract, baseline read and frame zeroing remain intact. +CoreBluetooth behavior is unchanged. + +The optional K1 package advances to 0.1.8+private.1. Node stays on the installed +0.8.8; its binary and UI need no rebuild. Rerun live acquisition, recorded +review and LAB profiles, preview framing and UI are unchanged by R11. + +## Validation + +131 focused checks pass across Bluetooth selection, provisioning, scanner, +runtime arbitration, Node bridge and package lifecycle. The 22 native-cache +cases exercise both read and provision with a present/vanished/missing path, +wrong address or adapter, a second disappearance, owner/capture invalidation, +connect failure, write failure and unchanged macOS behavior. They verify +at most one connect and one write, dispatch fencing, original capture handoff +and unchanged public generation. + +Eight media tests pass separately with UDP loopback permission. The first +sandboxed combined run timed out in the native WebRTC loopback test; the +same media code passes with the required local socket access. Scoped Ruff and +git diff --check pass. No agent-issued physical K1 commands or Bluetooth scans +were used. Hardware connection and R10 live-preview acceptance remain pending +an owner UI run after clearing the browser cache. + +Release hashes and installed readback follow when available. The prior Ops +publication and remote full-journal-copy approval blocks remain unresolved; +this report is local and contains no raw device identifiers or credentials. diff --git a/docs/audits/2026-09-07-k1-node-preview-r10.md b/docs/audits/2026-09-07-k1-node-preview-r10.md index 674f3af..df6e7ef 100644 --- a/docs/audits/2026-09-07-k1-node-preview-r10.md +++ b/docs/audits/2026-09-07-k1-node-preview-r10.md @@ -116,3 +116,12 @@ acceptance directory was rejected as well; that journal was not copied. The owner was asked for explicit permission for an additive report and that bounded private journal copy. Existing private Fleet/screenshots are retained; no publication, permission workaround or alternative remote destination was used. + +## Installation readback and provisioning follow-up + +After the owner's next screenshot, read-only dpkg confirms Node 0.8.8 and +optional K1 0.1.7+private.1 installed. This closes the installation gate only. +The new failure is in network.provision before the first GATT connection: +Bleak's add_device_watcher calls the manager's missing-device check. R10's +status-read-only refresh did not cover this path. The shared native-object +preflight and its remaining physical acceptance are recorded in the R11 audit. diff --git a/plugins/xgrids-k1/packaging/build_deb.py b/plugins/xgrids-k1/packaging/build_deb.py index 7b0a0f0..37c42dc 100644 --- a/plugins/xgrids-k1/packaging/build_deb.py +++ b/plugins/xgrids-k1/packaging/build_deb.py @@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402 from debian import package # noqa: E402 from runtime_payload import files as runtime_files # noqa: E402 -VERSION = "0.1.7" +VERSION = "0.1.8" RESOURCES = ( "plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/plugin.manifest.json", diff --git a/src/k1link/device_plugins/xgrids_k1/ble/scanner.py b/src/k1link/device_plugins/xgrids_k1/ble/scanner.py index a76f4ba..3d12f50 100644 --- a/src/k1link/device_plugins/xgrids_k1/ble/scanner.py +++ b/src/k1link/device_plugins/xgrids_k1/ble/scanner.py @@ -17,6 +17,7 @@ from uuid import UUID from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from bleak.exc import BleakDeviceNotFoundError from k1link.artifacts import utc_now_iso from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import ( @@ -838,16 +839,60 @@ async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDev return None -async def status_read_device_is_current(device: BLEDevice) -> bool: - """Check a BlueZ handle before the one explicit status-read connection. +async def ensure_device_for_gatt(device: BLEDevice, *, timeout_seconds: float) -> None: + """Restore a vanished BlueZ path before the one admitted GATT connection. - BlueZ may remove an unpaired object after a completed scan session. This - cache check neither scans nor connects and never changes macOS selection. + A BLEDevice stores a D-Bus path, not a native object lease. After the form + has been filled in, BlueZ may have removed that path. Observe the same + address on the same adapter once, then require that exact path to exist. + Keep the original selection/capture; no session pin, public scan generation, + GATT connection or write is created here. CoreBluetooth needs no refresh. """ details = getattr(device, "details", None) if not sys.platform.startswith("linux") or not isinstance(details, dict): - return True - return await _retrieve_bluez_device(device.address, details) is not None + return + path = details.get("path", "") + address = device.address + match = re.fullmatch(r"/org/bluez/(hci[0-9]+)/dev_[0-9A-F_]+", path) + if (match is None + or not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", address) + or path.rsplit("/", 1)[-1] != "dev_" + address.upper().replace(":", "_") + or not math.isfinite(timeout_seconds) or timeout_seconds <= 0): + raise BleakDeviceNotFoundError(address, "Invalid selected BlueZ transport") + + owner_epoch = ble_runtime_owner_epoch_for_current_loop() + operation_kind = ble_runtime_snapshot()["active_operation_kind"] + + def require_owner() -> None: + runtime = ble_runtime_snapshot() + if (owner_epoch is None + or ble_runtime_owner_epoch_for_current_loop() != owner_epoch + or runtime["owner_epoch"] != owner_epoch + or not runtime["owner_loop_bound"] or runtime["poisoned"] + or operation_kind not in {"status-read", "wifi-provision"} + or runtime["active_operation_kind"] != operation_kind): + raise BleakDeviceNotFoundError(address, "BLE operation owner changed") + + require_owner() + current = await _retrieve_bluez_device(address, details) + require_owner() + if current is not None: + return + + candidate = await BleakScanner.find_device_by_address( + address, + timeout=min(timeout_seconds, 8.0), + bluez={"adapter": match[1]}, + ) + require_owner() + if (candidate is None or candidate.address.casefold() != address.casefold() + or not isinstance(candidate.details, dict) + or candidate.details.get("path") != path): + raise BleakDeviceNotFoundError(address, "Selected BlueZ transport unavailable") + current = await _retrieve_bluez_device(address, details) + require_owner() + if current is None: + raise BleakDeviceNotFoundError(address, "Selected BlueZ transport disappeared") async def _retrieve_corebluetooth_device( diff --git a/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py b/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py index fc6b774..2e2a707 100644 --- a/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py +++ b/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py @@ -23,10 +23,10 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import ( demote_connected_device_handle_after_gatt_failure, discover_known_device_capture_for_status_read, discovered_device_selection, + ensure_device_for_gatt, mark_captured_device_gatt_validated, retrieve_connected_device_capture, retrieve_known_device_capture_for_status_read, - status_read_device_is_current, ) PROFILE_ID = "xgrids-k1-fw3-wifi-v1" @@ -391,18 +391,10 @@ async def _read_wifi_status_impl( "Exact BLE device is unavailable; run an explicit recovery or scan.", ) - if not await status_read_device_is_current(device): - # One explicit read may refresh the exact vanished BlueZ object - # before GATT. No failed connect/write is retried; public discovery - # generations, the pinned target and macOS behavior are unchanged. - progress.operation_stage = "exact-uuid-scan" - active_captured_device = await discover_known_device_capture_for_status_read( - device_macos_uuid, timeout_seconds=min(timeout_seconds, 8.0), - ) - device = (captured_device_handle(active_captured_device) - if active_captured_device is not None else None) - if device is None: - raise BleakDeviceNotFoundError(device_macos_uuid, "Exact BLE device unavailable") + await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds) + if (active_captured_device is not None + and captured_device_handle(active_captured_device) is not device): + raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated") progress.operation_stage = "connect" async with BleakClient(device, timeout=timeout_seconds, pair=False) as client: progress.operation_stage = "gatt-contract" @@ -627,6 +619,10 @@ async def _provision_wifi_impl( "Device was not rediscovered; keep the K1 powered and nearby.", ) + await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds) + if (active_captured_device is not None + and captured_device_handle(active_captured_device) is not device): + raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated") operation_stage = "connect" progress.operation_stage = operation_stage async with BleakClient(device, timeout=timeout_seconds, pair=False) as client: diff --git a/tests/test_ble_scanner.py b/tests/test_ble_scanner.py index 11bca05..8fa1c5b 100644 --- a/tests/test_ble_scanner.py +++ b/tests/test_ble_scanner.py @@ -1702,21 +1702,3 @@ def test_exact_session_invalidation_does_not_clear_new_scan_handle() -> None: ) is not None asyncio.run(scenario()) - - -@pytest.mark.parametrize("platform,present", [("linux", True), ("linux", False), ("darwin", False)]) -def test_status_read_native_cache_check_is_linux_only(monkeypatch, platform, present): - from types import SimpleNamespace - - calls = [] - device = BLEDevice("AA:BB:CC:DD:EE:FF", "synthetic", {"path": "/synthetic/bluez"}) - - async def retrieve(address, details): - calls.append((address, details)) - return device if present else None - - monkeypatch.setattr(scanner_module, "sys", SimpleNamespace(platform=platform)) - monkeypatch.setattr(scanner_module, "_retrieve_bluez_device", retrieve) - assert asyncio.run(scanner_module.status_read_device_is_current(device)) is ( - present or platform == "darwin") - assert len(calls) == (1 if platform == "linux" else 0) diff --git a/tests/test_bluez_gatt_selection.py b/tests/test_bluez_gatt_selection.py new file mode 100644 index 0000000..fab83fe --- /dev/null +++ b/tests/test_bluez_gatt_selection.py @@ -0,0 +1,170 @@ +"""Native cache loss between owner selection and one admitted GATT operation.""" + +import asyncio +from types import SimpleNamespace + +import pytest +from bleak.backends.device import BLEDevice +from bleak.exc import BleakDeviceNotFoundError, BleakError + +from k1link.device_plugins.xgrids_k1.ble import scanner +from k1link.device_plugins.xgrids_k1.ble import wifi_provisioning as wifi +from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import ( + bind_ble_runtime_owner_loop, + configure_ble_runtime_process_lease, + reset_ble_runtime_arbiter_for_tests, +) + + +@pytest.fixture(autouse=True) +def isolated_owner(tmp_path): + scanner.reset_runtime_handles_for_tests() + reset_ble_runtime_arbiter_for_tests() + configure_ble_runtime_process_lease(tmp_path) + yield + scanner.reset_runtime_handles_for_tests() + reset_ble_runtime_arbiter_for_tests() + + +@pytest.mark.parametrize("operation", ["read", "provision"]) +@pytest.mark.parametrize("native", [ + "macos", "present", "vanished", "absent", "wrong-address", "wrong-adapter", + "vanished-again", "owner-changed", "invalidated", "connect-failed", "write-failed", +]) +def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, native): + address = "AA:BB:CC:DD:EE:FF" + path = "/org/bluez/hci7/dev_AA_BB_CC_DD_EE_FF" + device = BLEDevice(address, "synthetic", {"path": path}) + events = [] + capture = None + validations = [] + + async def retrieve(selected_address, details): + assert selected_address == address and details["path"] == path + events.append("cache") + present = native == "present" or ( + events.count("cache") == 2 and native != "vanished-again" + ) + return device if present else None + + async def find(selected_address, *, timeout, bluez): + assert selected_address == address + assert 0 < timeout <= 8 + assert bluez == {"adapter": "hci7"} + events.append("scan") + assert events.count("scan") == 1 + if native == "absent": + return None + if native == "owner-changed": + monkeypatch.setattr(scanner, "ble_runtime_owner_epoch_for_current_loop", lambda: -1) + if native == "invalidated": + scanner.demote_connected_device_handle_after_gatt_failure(capture) + return BLEDevice( + "AA:BB:CC:DD:EE:00" if native == "wrong-address" else address, + "synthetic", + {"path": path.replace("hci7", "hci8") if native == "wrong-adapter" else path}, + ) + + service = SimpleNamespace(uuid=wifi.SERVICE_UUID) + write = SimpleNamespace( + uuid=wifi.WRITE_CHARACTERISTIC_UUID, service_uuid=wifi.SERVICE_UUID, + properties=["write"], + ) + status = SimpleNamespace( + uuid=wifi.STATUS_CHARACTERISTIC_UUID, service_uuid=wifi.SERVICE_UUID, + properties=["read"], + ) + services = SimpleNamespace( + get_service=lambda uuid: service if uuid == service.uuid else None, + get_characteristic=lambda uuid: {write.uuid: write, status.uuid: status}.get(uuid), + ) + + class Client: + name = "synthetic" + is_connected = True + + def __init__(self, selected, **_kwargs): + # Restore the native path without replacing the owner capture. + assert selected is device + self.services = services + + async def __aenter__(self): + events.append("connect") + if native == "connect-failed": + raise BleakError("synthetic native connection failure") + return self + + async def __aexit__(self, *_args): + events.append("disconnect") + + async def read_gatt_char(self, characteristic): + assert characteristic is status + events.append("read") + value = bytearray(52) + value[0] = 11 + value[1:12] = b"WIFI_CLIENT" + value[33] = 4 + value[34:38] = bytes((10, 255, 254, 77)) + return value + + async def write_gatt_char(self, characteristic, _frame, *, response): + assert characteristic is write and response is True + assert events[-2:] == ["read", "dispatch"] + events.append("write") + if native == "write-failed": + raise BleakError("synthetic unconfirmed write") + + monkeypatch.setattr(scanner, "sys", SimpleNamespace( + platform="darwin" if native == "macos" else "linux", + )) + monkeypatch.setattr(scanner, "_retrieve_bluez_device", retrieve) + monkeypatch.setattr(scanner.BleakScanner, "find_device_by_address", find) + monkeypatch.setattr(wifi, "BleakClient", Client) + + async def scenario(): + nonlocal capture + owner_epoch = bind_ble_runtime_owner_loop() + capture = scanner.CapturedDiscoveredDevice( + device=device, macos_uuid=address, owner_epoch=owner_epoch, + ) + scanner.pin_connected_device_handle(capture, device_session_id="synthetic-session") + generation = scanner._runtime_handle_generation # noqa: SLF001 + kwargs = dict(captured_device=capture, timeout_seconds=1) + action = ( + wifi.read_wifi_status_once(address, on_gatt_validated=validations.append, **kwargs) + if operation == "read" else + wifi.provision_wifi_once( + address, "SyntheticNet", "x" * 13, write_mode="with_response", + on_write_dispatch=lambda *_args: events.append("dispatch"), **kwargs, + ) + ) + fails_before_connect = native in { + "absent", "wrong-address", "wrong-adapter", "vanished-again", + "owner-changed", "invalidated", + } + if fails_before_connect or native == "connect-failed" or ( + native == "write-failed" and operation == "provision" + ): + with pytest.raises(BleakError) as raised: + await action + if fails_before_connect: + assert isinstance(raised.value, BleakDeviceNotFoundError) + assert "connect" not in events + if operation == "provision": + assert raised.value.device_write_attempted is (native == "write-failed") + assert events.count("write") == (native == "write-failed") + else: + result = await action + assert result.get("outcome", "lan_address_observed") == "lan_address_observed" + assert scanner.connected_device_capture( + address, device_session_id="synthetic-session", + ).device is device + if operation == "read": + assert validations == [capture] + assert events.count("write") == (operation == "provision") + assert scanner._runtime_handle_generation == generation # noqa: SLF001 + + asyncio.run(scenario()) + assert events.count("scan") == (native not in {"macos", "present"}) + assert events.count("connect") <= 1 + assert events.count("write") <= 1 diff --git a/tests/test_wifi_provisioning.py b/tests/test_wifi_provisioning.py index 0545091..b5440f6 100644 --- a/tests/test_wifi_provisioning.py +++ b/tests/test_wifi_provisioning.py @@ -134,10 +134,8 @@ def test_parse_wifi_status_rejects_short_frame() -> None: parse_wifi_status(bytes(50)) -@pytest.mark.parametrize("current_handle", [True, False]) def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address( monkeypatch: pytest.MonkeyPatch, - current_handle: bool, ) -> None: value = bytearray(54) value[0] = 11 @@ -202,19 +200,6 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address( ), ) monkeypatch.setattr(wifi_module, "BleakClient", FakeClient) - fresh_handle, capture, scans = object(), object(), [] - - async def is_current(_device): - return current_handle - - async def refresh_exact(address, **_kwargs): - scans.append(address) - return capture - - monkeypatch.setattr(wifi_module, "status_read_device_is_current", is_current) - monkeypatch.setattr(wifi_module, "discover_known_device_capture_for_status_read", refresh_exact) - monkeypatch.setattr(wifi_module, "captured_device_handle", lambda value: fresh_handle) - monkeypatch.setattr(wifi_module, "mark_captured_device_gatt_validated", lambda *_a, **_k: True) result = asyncio.run(read_wifi_status_once("synthetic-corebluetooth-uuid")) @@ -225,8 +210,7 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address( assert result["max_write_without_response_size"] == 244 assert result["mtu_size"] == 256 assert result["status"]["ipv4"] == "10.255.254.77" - assert connected == [retained_handle if current_handle else fresh_handle] - assert scans == ([] if current_handle else ["synthetic-corebluetooth-uuid"]) + assert connected == [retained_handle] def test_read_wifi_status_recovery_keeps_fresh_retained_handle(