From e1707eb9ff8f7c1f995ebc3615f430ea1a268ce0 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 7 Jun 2026 04:01:45 +0000 Subject: [PATCH 1/3] fix: tolerate divergent discovery-cache dicts during expiry The cache-expiry pass is driven by the timestamps dict, but deleted matching entries from the ad-datas dict with an unconditional del. A corrupt or shape-divergent stored blob (address in timestamps, missing from ad-datas) raised KeyError, aborting the entire discovery-cache load and forcing a slow cold adapter start for every device. Use pop(address, None) so one stale entry is dropped instead of taking down the whole load. --- src/habluetooth/storage.py | 6 ++++- tests/test_storage.py | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/habluetooth/storage.py b/src/habluetooth/storage.py index 0080a7fe..261cf234 100644 --- a/src/habluetooth/storage.py +++ b/src/habluetooth/storage.py @@ -116,8 +116,12 @@ def expire_stale_scanner_discovered_device_advertisement_data( ) expire.append(address) for address in expire: + # ``timestamps`` drives expiry, so its key is always present, but a + # divergent/corrupt blob may have an address here that is missing + # from the companion dicts. Use ``pop`` with a default so one stale + # entry can never raise ``KeyError`` and abort the whole load. del timestamps[address] - del discovered_device_advertisement_datas[address] + discovered_device_advertisement_datas.pop(address, None) discovered_device_raw.pop(address, None) if not timestamps: expired_scanners.append(scanner) diff --git a/tests/test_storage.py b/tests/test_storage.py index b5d8cfe1..4ec6152c 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -340,6 +340,59 @@ def test_expire_future_discovered_device_advertisement_data( ) +def test_expire_stale_scanner_with_divergent_dicts(): + """ + Expire a timestamp with no matching ad/raw entry without raising KeyError. + + The timestamps dict drives expiry; if a corrupt blob has an address there + that is missing from the companion dicts, expiry must drop the entry rather + than abort the whole load. + """ + now = time.time() + data = { + "myscanner": DiscoveredDeviceAdvertisementDataDict( + { + "connectable": True, + "discovered_device_advertisement_datas": { + "AA:BB:CC:DD:EE:FF": { + "advertisement_data": { + "local_name": "Test Device", + "manufacturer_data": {"76": "0215aabbccddeeff"}, + "rssi": -50, + "service_data": { + "0000180d-0000-1000-8000-00805f9b34fb": "00000000" + }, + "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], + "tx_power": 50, + "platform_data": ["Test Device", ""], + }, + "device": { + "address": "AA:BB:CC:DD:EE:FF", + "details": {"details": "test"}, + "name": "Test Device", + }, # type: ignore[typeddict-item] + }, + }, + "discovered_device_raw": {}, + # "CC:DD:EE:FF:AA:BB" is stale and present only in timestamps — + # the ad-datas dict above has no matching entry. + "discovered_device_timestamps": { + "AA:BB:CC:DD:EE:FF": now, + "CC:DD:EE:FF:AA:BB": now - 101, + }, + "expire_seconds": 100, + } + ), + } + # Must not raise KeyError despite the divergent dicts. + expire_stale_scanner_discovered_device_advertisement_data(data) + assert "myscanner" in data + timestamps = data["myscanner"]["discovered_device_timestamps"] + assert "CC:DD:EE:FF:AA:BB" not in timestamps + assert "AA:BB:CC:DD:EE:FF" in timestamps + assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1 + + def test_discovered_device_advertisement_data_from_dict_corrupt(caplog): """Shape mismatches log a WARNING and discard the cache without a traceback.""" now = time.time() From e37f3637b5af68b7884b3e32522d9483c8544232 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 7 Jun 2026 08:57:27 +0000 Subject: [PATCH 2/3] fix: drop malformed scanner blobs during discovery-cache expiry The expiry pass hard-subscripted the per-scanner top-level keys (expire_seconds / timestamps / ad-datas), so a single corrupt or partial scanner blob raised KeyError and aborted expiry for every other scanner before the cache reached _from_dict. Wrap the per-scanner fetch in try/except and discard just the bad scanner, mirroring the discard-and-rebuild strategy _from_dict already uses for malformed data. --- src/habluetooth/storage.py | 23 +++++++++++++---- tests/test_storage.py | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/habluetooth/storage.py b/src/habluetooth/storage.py index 261cf234..f6022342 100644 --- a/src/habluetooth/storage.py +++ b/src/habluetooth/storage.py @@ -95,11 +95,24 @@ def expire_stale_scanner_discovered_device_advertisement_data( expired_scanners: list[str] = [] for scanner, data in data_by_scanner.items(): expire: list[str] = [] - expire_seconds = data[EXPIRE_SECONDS] - timestamps = data[DISCOVERED_DEVICE_TIMESTAMPS] - discovered_device_advertisement_datas = data[ - DISCOVERED_DEVICE_ADVERTISEMENT_DATAS - ] + try: + expire_seconds = data[EXPIRE_SECONDS] + timestamps = data[DISCOVERED_DEVICE_TIMESTAMPS] + discovered_device_advertisement_datas = data[ + DISCOVERED_DEVICE_ADVERTISEMENT_DATAS + ] + except (KeyError, TypeError): + # A corrupt/partial blob for one scanner may be missing required + # top-level keys (or not be a mapping at all). Drop just that + # scanner and keep going rather than aborting expiry for every + # other scanner — this mirrors the discard-and-rebuild strategy + # ``discovered_device_advertisement_data_from_dict`` already uses + # for malformed cache data. + _LOGGER.warning( + "Discarding malformed discovery cache for scanner %s", scanner + ) + expired_scanners.append(scanner) + continue discovered_device_raw = data.get(DISCOVERED_DEVICE_RAW, {}) for address, timestamp in timestamps.items(): time_diff = now - timestamp diff --git a/tests/test_storage.py b/tests/test_storage.py index 4ec6152c..45da6444 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -393,6 +393,59 @@ def test_expire_stale_scanner_with_divergent_dicts(): assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1 +def test_expire_stale_scanner_with_missing_keys(caplog): + """ + A scanner blob missing required top-level keys is dropped, not fatal. + + ``expire_stale...`` runs across every scanner before the cache is handed + to ``..._from_dict``. A single corrupt/partial scanner blob (missing + ``expire_seconds``/``timestamps``/``ad-datas``) must not raise ``KeyError`` + and abort expiry for the healthy scanners; the bad scanner is discarded + and the good one survives. + """ + now = time.time() + good_scanner = DiscoveredDeviceAdvertisementDataDict( + { + "connectable": True, + "discovered_device_advertisement_datas": { + "AA:BB:CC:DD:EE:FF": { + "advertisement_data": { + "local_name": "Test Device", + "manufacturer_data": {"76": "0215aabbccddeeff"}, + "rssi": -50, + "service_data": { + "0000180d-0000-1000-8000-00805f9b34fb": "00000000" + }, + "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], + "tx_power": 50, + "platform_data": ["Test Device", ""], + }, + "device": { + "address": "AA:BB:CC:DD:EE:FF", + "details": {"details": "test"}, + "name": "Test Device", + }, # type: ignore[typeddict-item] + }, + }, + "discovered_device_raw": {}, + "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now}, + "expire_seconds": 100, + } + ) + data = { + # Missing "discovered_device_timestamps" (and others) entirely. + "badscanner": {"connectable": True}, # type: ignore[typeddict-item] + "goodscanner": good_scanner, + } + # Must not raise despite the malformed scanner blob. + expire_stale_scanner_discovered_device_advertisement_data(data) + # The malformed scanner is dropped, the healthy one is preserved intact. + assert "badscanner" not in data + assert "goodscanner" in data + assert "AA:BB:CC:DD:EE:FF" in data["goodscanner"]["discovered_device_timestamps"] + assert "Discarding malformed discovery cache for scanner badscanner" in caplog.text + + def test_discovered_device_advertisement_data_from_dict_corrupt(caplog): """Shape mismatches log a WARNING and discard the cache without a traceback.""" now = time.time() From 8e6b7d21ed1dfbcf77660f7ec52d2f7617395ccc Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 7 Jun 2026 20:21:36 +0000 Subject: [PATCH 3/3] test: annotate divergent-dict test data to satisfy strict mypy The regression test's data dict was inferred as dict[str, object], which made the typeddict-item ignore unused, broke the arg type to expire_stale_scanner_discovered_device_advertisement_data, and made the 'in' membership check operate on object. Annotate it explicitly as dict[str, DiscoveredDeviceAdvertisementDataDict] so the malformed-scanner literal is checked against the TypedDict (using the ignore) and the healthy scanner indexes cleanly. --- tests/test_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_storage.py b/tests/test_storage.py index 45da6444..c19b26ef 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -432,7 +432,7 @@ def test_expire_stale_scanner_with_missing_keys(caplog): "expire_seconds": 100, } ) - data = { + data: dict[str, DiscoveredDeviceAdvertisementDataDict] = { # Missing "discovered_device_timestamps" (and others) entirely. "badscanner": {"connectable": True}, # type: ignore[typeddict-item] "goodscanner": good_scanner,