From b0149337f56cc4be95b886ffd2baded86bd0eb6e Mon Sep 17 00:00:00 2001 From: Markus Jellitsch Date: Fri, 25 Sep 2026 23:51:51 +0200 Subject: [PATCH 1/4] fix - ensure big handles are removed once big sync is terminated --- bumble/device.py | 12 ++++++-- tests/device_test.py | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/bumble/device.py b/bumble/device.py index 9240fe93..666f4a23 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -1160,6 +1160,7 @@ async def terminate(self) -> None: logger.error('BIG Sync %d is not active.', self.big_handle) return + self.device._remove_big_sync(self) await self.device.send_sync_command( hci.HCI_LE_BIG_Terminate_Sync_Command(big_handle=self.big_handle) ) @@ -5933,14 +5934,19 @@ def on_big_sync_establishment( self.bis_links[bis_link.handle] = bis_link big_sync.emit(BigSync.Event.ESTABLISHMENT) + def _remove_big_sync(self, big_sync: BigSync) -> None: + if self.big_syncs.pop(big_sync.big_handle, None) is None: + return + for bis_link in big_sync.bis_links: + self.bis_links.pop(bis_link.handle, None) + @host_event_handler def on_big_sync_lost(self, big_handle: int, reason: int) -> None: - if not (big_sync := self.big_syncs.pop(big_handle, None)): + if not (big_sync := self.big_syncs.get(big_handle)): logger.warning('BIG %d not found', big_handle) return - for bis_link in big_sync.bis_links: - self.bis_links.pop(bis_link.handle, None) + self._remove_big_sync(big_sync) big_sync.state = BigSync.State.TERMINATED big_sync.emit(BigSync.Event.TERMINATION, reason) diff --git a/tests/device_test.py b/tests/device_test.py index ab40eeb2..c53c68bf 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -1387,6 +1387,74 @@ async def test_big_and_big_sync(): assert len(big_sync.bis_links) == 2 await big_sync.terminate() + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_big_sync_and_terminate_255_times(): + # Regression: each BigSync.terminate() must remove the big handle and its + # bis link entries from the device. If those entries leak, the IN_BIG_SYNC + # / IN_BIS handles wrap around every 255 syncs and the 255th re-sync fails. + two_devices = TwoDevices() + for dev in two_devices.devices: + await dev.power_on() + + adv_set = await two_devices.devices[0].create_advertising_set( + advertising_parameters=AdvertisingParameters( + advertising_event_properties=AdvertisingEventProperties( + is_connectable=False, is_scannable=False + ), + primary_advertising_interval_min=20, + primary_advertising_interval_max=40, + ), + periodic_advertising_parameters=PeriodicAdvertisingParameters( + periodic_advertising_interval_min=100, periodic_advertising_interval_max=200 + ), + auto_start=True, + ) + await adv_set.start_periodic() + + big = await two_devices.devices[0].create_big( + advertising_set=adv_set, + parameters=BigParameters( + num_bis=2, + sdu_interval=10000, + max_sdu=100, + max_transport_latency=40, + rtn=2, + ), + ) + + pa_sync = await two_devices.devices[1].create_periodic_advertising_sync( + advertiser_address=two_devices.devices[0].random_address, + sid=0, + ) + established = asyncio.Event() + pa_sync.on('establishment', established.set) + if pa_sync.state != PeriodicAdvertisingSync.State.ESTABLISHED: + await asyncio.wait_for(established.wait(), _TIMEOUT) + + device_2 = two_devices.devices[1] + for i in range(255): + big_sync = await device_2.create_big_sync( + pa_sync, + BigSyncParameters(big_sync_timeout=1000, bis=[1, 2]), + ) + assert len(big_sync.bis_links) == 2 + await big_sync.terminate() + + # Each sync must have used a fresh handle and left no stale entries. If + # the terminate cleanup is missing, the IN_BIG_SYNC handle would hit the + # 255 limit (wrap-around) on the last iteration and create_big_sync would + # fail or reuse a handle that is still registered. + assert big_sync.big_handle not in device_2.big_syncs + assert all( + bis_link.handle not in device_2.bis_links for bis_link in big_sync.bis_links + ) + + # No stale entries should have accumulated across all 255 syncs. + assert device_2.big_syncs == {} + assert device_2.bis_links == {} await big.terminate() From f3b61f48112097a3a857fecf7774e471651eac3d Mon Sep 17 00:00:00 2001 From: Markus Jellitsch Date: Sat, 26 Sep 2026 00:04:25 +0200 Subject: [PATCH 2/4] remove comments --- tests/device_test.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/device_test.py b/tests/device_test.py index c53c68bf..3035c2f8 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -1392,9 +1392,6 @@ async def test_big_and_big_sync(): # ----------------------------------------------------------------------------- @pytest.mark.asyncio async def test_big_sync_and_terminate_255_times(): - # Regression: each BigSync.terminate() must remove the big handle and its - # bis link entries from the device. If those entries leak, the IN_BIG_SYNC - # / IN_BIS handles wrap around every 255 syncs and the 255th re-sync fails. two_devices = TwoDevices() for dev in two_devices.devices: await dev.power_on() From 7201a5f37e7e34a5d545e99c96e611941044a8e9 Mon Sep 17 00:00:00 2001 From: Markus Jellitsch Date: Tue, 29 Sep 2026 16:28:32 +0200 Subject: [PATCH 3/4] release BIG handle after BIG sync has terminated --- bumble/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumble/device.py b/bumble/device.py index 666f4a23..962dad15 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -1160,10 +1160,10 @@ async def terminate(self) -> None: logger.error('BIG Sync %d is not active.', self.big_handle) return - self.device._remove_big_sync(self) await self.device.send_sync_command( hci.HCI_LE_BIG_Terminate_Sync_Command(big_handle=self.big_handle) ) + self.device._remove_big_sync(self) self.state = BigSync.State.TERMINATED From 69b8e38ef41c11c68a13803b7adcf55840dac28b Mon Sep 17 00:00:00 2001 From: Markus Jellitsch Date: Tue, 29 Sep 2026 16:38:01 +0200 Subject: [PATCH 4/4] remove test_bis_sync_and_terminate_255_times. Enhance test_big_and_big_sync --- tests/device_test.py | 81 +++++++++++--------------------------------- 1 file changed, 20 insertions(+), 61 deletions(-) diff --git a/tests/device_test.py b/tests/device_test.py index 3035c2f8..ca812b43 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -1388,70 +1388,29 @@ async def test_big_and_big_sync(): await big_sync.terminate() - -# ----------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_big_sync_and_terminate_255_times(): - two_devices = TwoDevices() - for dev in two_devices.devices: - await dev.power_on() - - adv_set = await two_devices.devices[0].create_advertising_set( - advertising_parameters=AdvertisingParameters( - advertising_event_properties=AdvertisingEventProperties( - is_connectable=False, is_scannable=False - ), - primary_advertising_interval_min=20, - primary_advertising_interval_max=40, - ), - periodic_advertising_parameters=PeriodicAdvertisingParameters( - periodic_advertising_interval_min=100, periodic_advertising_interval_max=200 - ), - auto_start=True, - ) - await adv_set.start_periodic() - - big = await two_devices.devices[0].create_big( - advertising_set=adv_set, - parameters=BigParameters( - num_bis=2, - sdu_interval=10000, - max_sdu=100, - max_transport_latency=40, - rtn=2, - ), + # Terminating must clean up the BIG sync handle and its BIS links from the + # device, so that none are left behind as stale entries. + assert not two_devices.devices[1].big_syncs + assert not two_devices.devices[1].bis_links + + # next_big_handle() always picks the lowest unused handle (starting from + # DEVICE_MIN_BIG_HANDLE), so a re-sync must be able to allocate the same + # lowest handle again after the previous sync was cleaned up. + big_sync_2 = await two_devices.devices[1].create_big_sync( + pa_sync, + BigSyncParameters(big_sync_timeout=1000, bis=[1, 2]), ) - - pa_sync = await two_devices.devices[1].create_periodic_advertising_sync( - advertiser_address=two_devices.devices[0].random_address, - sid=0, + assert len(big_sync_2.bis_links) == 2 + assert big_sync_2.big_handle == big_sync.big_handle + assert big_sync_2.big_handle in two_devices.devices[1].big_syncs + assert all( + bis_link.handle in two_devices.devices[1].bis_links + for bis_link in big_sync_2.bis_links ) - established = asyncio.Event() - pa_sync.on('establishment', established.set) - if pa_sync.state != PeriodicAdvertisingSync.State.ESTABLISHED: - await asyncio.wait_for(established.wait(), _TIMEOUT) - - device_2 = two_devices.devices[1] - for i in range(255): - big_sync = await device_2.create_big_sync( - pa_sync, - BigSyncParameters(big_sync_timeout=1000, bis=[1, 2]), - ) - assert len(big_sync.bis_links) == 2 - await big_sync.terminate() - - # Each sync must have used a fresh handle and left no stale entries. If - # the terminate cleanup is missing, the IN_BIG_SYNC handle would hit the - # 255 limit (wrap-around) on the last iteration and create_big_sync would - # fail or reuse a handle that is still registered. - assert big_sync.big_handle not in device_2.big_syncs - assert all( - bis_link.handle not in device_2.bis_links for bis_link in big_sync.bis_links - ) + await big_sync_2.terminate() + assert not two_devices.devices[1].big_syncs + assert not two_devices.devices[1].bis_links - # No stale entries should have accumulated across all 255 syncs. - assert device_2.big_syncs == {} - assert device_2.bis_links == {} await big.terminate()