Compare commits

...

13 Commits

Author SHA1 Message Date
Josh Wu 47fcd8e9ec Merge pull request #944 from zxzxwu/data-length-class
Refactor connection data length to use dataclass
2026-06-29 17:37:57 +08:00
Gilles Boccon-Gibod fe991f67da Merge pull request #947 from greateggsgreg/safe-libusb-callback-scheduling
Bugfix: Ignore libusb callback scheduling after the asyncio loop is closed
2026-06-27 12:46:30 +02:00
Gilles Boccon-Gibod 82a5acc290 Merge pull request #946 from greateggsgreg/hci-num-completed-packets-enabled
Bugfix - Keep HCI Number Of Completed Packets enabled in the host event mask
2026-06-27 12:46:11 +02:00
greateggsgreg 022ccf3d70 Update usb_test.py 2026-06-26 23:25:16 -04:00
greateggsgreg 8a0992c00e Update usb.py 2026-06-26 23:24:41 -04:00
greateggsgreg d7b007f3af Update host_test.py 2026-06-26 23:17:54 -04:00
greateggsgreg 7b0eb93fd8 Update host.py
`Device.power_on()` resets the host and explicitly sends `HCI_Set_Event_Mask`. That explicit mask omitted `HCI_NUMBER_OF_COMPLETED_PACKETS_EVENT`, even though the controller default mask enables it. That disables `HCI_Number_Of_Completed_Packets` events, which are the only path that returns ACL transmit credits to Bumble's data queue:

- The event-mask list is in `bumble/host.py:401`-`bumble/host.py:421`.
- LE buffer sizing reads `HCI_LE_Read_Buffer_Size[_V2]` and stores
  `total_num_le_acl_data_packets` in the queue limit at `bumble/host.py:552`-
  `bumble/host.py:590`.
- `DataPacketQueue` sends only while `_in_flight < max_in_flight` at
  `bumble/host.py:140`-`bumble/host.py:147`.
- Completed-packets credits are consumed by
  `on_hci_number_of_completed_packets_event()` at `bumble/host.py:1169`-
  `bumble/host.py:1175`, which calls `DataPacketQueue.on_packets_completed()`.
- `DataPacketQueue.on_packets_completed()` decrements `_in_flight` and drains
  queued packets at `bumble/host.py:149`-`bumble/host.py:180`.

With `HCI_NUMBER_OF_COMPLETED_PACKETS_EVENT` masked off, `_in_flight` reaches `max_in_flight` and never decreases, so later ACL packets enqueue indefinitely.
2026-06-26 23:16:19 -04:00
Gilles Boccon-Gibod a8851b4759 Merge pull request #939 from google/gbg/update-libusb-package
update libusb-package version
2026-06-25 21:49:06 +02:00
Gilles Boccon-Gibod 7a72b225d4 update libusb-package version 2026-06-25 21:03:08 +02:00
Josh Wu e5e51fd3f2 Refactor connection data length to use dataclass
- Change Connection.data_length from a tuple to a structured LeDataLength dataclass.
- Update apps/bench.py and bumble/device.py to use named attributes instead of tuple indices.

TAG=agy
CONV=484c0d1f-7a90-4920-b8e9-48e1b208535f
2026-06-25 01:14:12 +08:00
TzuWei a889dbae31 Merge pull request #937 from google/l2cap-disconnect-abort-hang
l2cap: Resolve teardown hang on disconnect collision or abort
2026-06-24 17:05:10 +08:00
Josh Wu 36c7694c44 Merge pull request #940 from google/gbg/update-pytest
remove trailing commas in pytest parametrize argname
2026-06-24 16:05:53 +08:00
Gilles Boccon-Gibod 16dd5ae63d remove trailing commas in pytest parametrize argname 2026-06-21 13:31:03 +02:00
8 changed files with 84 additions and 23 deletions
+2 -2
View File
@@ -133,8 +133,8 @@ def print_connection(connection: Connection) -> None:
if connection.transport == PhysicalTransport.LE:
params.append(
'DL=('
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
f'TX:{connection.data_length.max_tx_octets}/{connection.data_length.max_tx_time},'
f'RX:{connection.data_length.max_rx_octets}/{connection.data_length.max_rx_time}'
')'
)
+13 -6
View File
@@ -1804,6 +1804,13 @@ class Connection(utils.CompositeEventEmitter):
subrate_factor: int = 1
continuation_number: int = 0
@dataclass(frozen=True)
class LeDataLength:
max_tx_octets: int
max_tx_time: int
max_rx_octets: int
max_rx_time: int
def __init__(
self,
device: Device,
@@ -1832,7 +1839,7 @@ class Connection(utils.CompositeEventEmitter):
self.authenticated = False
self.sc = False
self.att_mtu = att.ATT_DEFAULT_MTU
self.data_length: tuple[int, int, int, int] = DEVICE_DEFAULT_DATA_LENGTH
self.data_length = self.LeDataLength(*DEVICE_DEFAULT_DATA_LENGTH)
self.gatt_client = gatt_client.Client(self) # Per-connection client
self.gatt_server = (
device.gatt_server
@@ -6794,11 +6801,11 @@ class Device(utils.CompositeEventEmitter):
f'*** Connection Data Length Change: [0x{connection.handle:04X}] '
f'{connection.peer_address} as {connection.role_name}'
)
connection.data_length = (
max_tx_octets,
max_tx_time,
max_rx_octets,
max_rx_time,
connection.data_length = Connection.LeDataLength(
max_tx_octets=max_tx_octets,
max_tx_time=max_tx_time,
max_rx_octets=max_rx_octets,
max_rx_time=max_rx_time,
)
connection.emit(connection.EVENT_CONNECTION_DATA_LENGTH_CHANGE)
+1
View File
@@ -418,6 +418,7 @@ class Host(utils.EventEmitter):
hci.HCI_HARDWARE_ERROR_EVENT,
hci.HCI_FLUSH_OCCURRED_EVENT,
hci.HCI_ROLE_CHANGE_EVENT,
hci.HCI_NUMBER_OF_COMPLETED_PACKETS_EVENT,
hci.HCI_MODE_CHANGE_EVENT,
hci.HCI_RETURN_LINK_KEYS_EVENT,
hci.HCI_PIN_CODE_REQUEST_EVENT,
+22 -8
View File
@@ -37,6 +37,20 @@ from bumble.transport.common import BaseSource, Transport, TransportInitError
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
def _safe_call_soon(loop: asyncio.AbstractEventLoop, callback, *args) -> None:
"""Schedule `callback` on `loop` from the libusb event thread, tolerating the
case where the loop has already been closed during process/transport
teardown. Without this guard, a libusb transfer callback that fires after the
asyncio loop is closed raises 'Event loop is closed' on the C callback
thread, which can escalate to a libusb mutex assertion and crash the process
(SIGABRT). This makes an unclean shutdown a no-op instead of a core dump."""
try:
loop.call_soon_threadsafe(callback, *args)
except RuntimeError:
pass # loop already closed; nothing left to schedule
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
@@ -266,7 +280,7 @@ class UsbPacketSink:
self.packets.put_nowait(packet)
def transfer_callback(self, transfer):
self.loop.call_soon_threadsafe(self.out_transfer_ready.release)
_safe_call_soon(self.loop, self.out_transfer_ready.release)
status = transfer.getStatus()
logger.debug(f"OUT CALLBACK: {status}")
@@ -535,8 +549,8 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
self.dequeue_task = self.loop.create_task(self.dequeue())
def queue_packet(self, packet_type: int, packet_data: bytes) -> None:
self.loop.call_soon_threadsafe(
self.queue.put_nowait, bytes([packet_type]) + packet_data
_safe_call_soon(
self.loop, self.queue.put_nowait, bytes([packet_type]) + packet_data
)
def transfer_callback(self, transfer):
@@ -582,16 +596,16 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
transfer.submit()
except usb1.USBError as error:
logger.warning(f"Failed to re-submit transfer: {error}")
self.loop.call_soon_threadsafe(self.on_transport_lost)
_safe_call_soon(self.loop, self.on_transport_lost)
elif status == usb1.TRANSFER_CANCELLED:
logger.debug(f"IN[{packet_type}] transfer canceled")
self.loop.call_soon_threadsafe(self.done[transfer].set)
_safe_call_soon(self.loop, self.done[transfer].set)
else:
logger.warning(
color(f'!!! IN[{packet_type}] transfer not completed', 'red')
)
self.loop.call_soon_threadsafe(self.done[transfer].set)
self.loop.call_soon_threadsafe(self.on_transport_lost)
_safe_call_soon(self.loop, self.done[transfer].set)
_safe_call_soon(self.loop, self.on_transport_lost)
async def dequeue(self):
while not self.closed:
@@ -704,7 +718,7 @@ class UsbTransport(Transport):
logger.warning(f'!!! Exception while handling events: {error}')
logger.debug('ending USB event loop')
self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
_safe_call_soon(self.loop, self.event_loop_done.set_result, None)
async def close(self):
self.source.close()
+4 -4
View File
@@ -27,7 +27,7 @@ dependencies = [
"grpcio >= 1.62.1; platform_system!='Emscripten'",
"humanize >= 4.6.0; platform_system!='Emscripten'",
"libusb1 >= 2.0.1; platform_system!='Emscripten'",
"libusb-package == 1.0.26.1; platform_system!='Emscripten' and platform_system!='Android'",
"libusb-package == 1.0.26.4; platform_system!='Emscripten' and platform_system!='Android'",
"platformdirs >= 3.10.0; platform_system!='Emscripten'",
"prompt_toolkit >= 3.0.16; platform_system!='Emscripten'",
"prettytable >= 3.6.0; platform_system!='Emscripten'",
@@ -43,9 +43,9 @@ dependencies = [
[project.optional-dependencies]
build = ["build >= 0.7"]
test = [
"pytest >= 8.2",
"pytest-asyncio >= 0.23.5",
"pytest-html >= 3.2.0",
"pytest >= 9.0",
"pytest-asyncio >= 1.4",
"pytest-html >= 4.2",
"coverage >= 6.4",
]
development = [
+14
View File
@@ -74,6 +74,20 @@ async def test_reset(supported_commands: set[int], max_lmp_features_page_number:
)
@pytest.mark.asyncio
async def test_reset_enables_number_of_completed_packets_event() -> None:
controller = Controller('C')
controller.total_num_le_acl_data_packets = 3
host = Host(controller, AsyncPipeSink(controller))
await host.reset()
completed_packets_event_bit = 1 << (hci.HCI_NUMBER_OF_COMPLETED_PACKETS_EVENT - 1)
assert controller.event_mask & completed_packets_event_bit
assert host.le_acl_packet_queue is not None
assert host.le_acl_packet_queue.max_in_flight == 3
# -----------------------------------------------------------------------------
def test_data_packet_queue():
controller = unittest.mock.Mock()
+3 -3
View File
@@ -49,19 +49,19 @@ def test_helpers():
psm = l2cap.L2CAP_Connection_Request.serialize_psm(0x242311)
assert psm == bytes([0x11, 0x23, 0x24])
(offset, psm) = l2cap.L2CAP_Connection_Request.parse_psm(
offset, psm = l2cap.L2CAP_Connection_Request.parse_psm(
bytes([0x00, 0x01, 0x00, 0x44]), 1
)
assert offset == 3
assert psm == 0x01
(offset, psm) = l2cap.L2CAP_Connection_Request.parse_psm(
offset, psm = l2cap.L2CAP_Connection_Request.parse_psm(
bytes([0x00, 0x23, 0x10, 0x44]), 1
)
assert offset == 3
assert psm == 0x1023
(offset, psm) = l2cap.L2CAP_Connection_Request.parse_psm(
offset, psm = l2cap.L2CAP_Connection_Request.parse_psm(
bytes([0x00, 0x11, 0x23, 0x24, 0x44]), 1
)
assert offset == 4
+25
View File
@@ -21,6 +21,31 @@ from bumble import hci
from bumble.transport import usb
def test_safe_call_soon_ignores_closed_loop():
closed_loop = asyncio.new_event_loop()
closed_loop.close()
callback = mock.Mock()
usb._safe_call_soon(closed_loop, callback)
callback.assert_not_called()
@pytest.mark.asyncio
async def test_safe_call_soon_schedules_callback_on_open_loop():
called = asyncio.Event()
args = []
def callback(value):
args.append(value)
called.set()
usb._safe_call_soon(asyncio.get_running_loop(), callback, 'scheduled')
await asyncio.wait_for(called.wait(), timeout=1.0)
assert args == ['scheduled']
@pytest.mark.asyncio
async def test_usb_packet_sink_iso_routing():
# Mock usb1 device and endpoints