A single stray byte on a serial (H:4) link can hang Device.power_on() forever.
Three separate behaviours combine into a permanent hang. Each is defensible alone; together they mean one bad byte at the wrong moment blocks startup with no timeout and no recovery.
1. A bad type byte discards the rest of the read chunk, silently
PacketParser.feed_data() (transport/common.py:143) raises InvalidPacketError from the middle of its loop, so any bytes remaining in the current data buffer are never parsed. StreamPacketSource.data_received() (transport/common.py:303-307) catches it and only logs:
def data_received(self, data: bytes) -> None:
try:
self.parser.feed_data(data)
except core.InvalidPacketError:
logger.warning("invalid packet, ignoring data")
Deterministic reproduction, no hardware:
import asyncio, logging
from bumble.transport import common
logging.disable(logging.CRITICAL)
async def main():
got = []
class Sink:
def on_packet(self, p): got.append(p.hex())
valid = bytes([0x04, 0x0E, 0x04, 0x01, 0x03, 0x0C, 0x00]) # HCI Command Complete
src = common.StreamPacketSource(); src.set_packet_sink(Sink())
src.data_received(bytes([0xFF]) + valid) # one stray byte, same read() chunk
print(got) # [] -- the valid packet is gone
src.data_received(valid)
print(got) # recovers only on the NEXT chunk
asyncio.run(main())
The packet bundled with the garbage is lost. There is no attempt to resynchronise within the chunk (e.g. skip one byte and retry framing).
2. HCI_Reset is awaited with no timeout
Host.reset() (host.py:347) does await self.send_sync_command(hci.HCI_Reset_Command()), and _send_command() (host.py:690) awaits with response_timeout=None:
response = await asyncio.wait_for(self.pending_response, timeout=response_timeout)
So if the command-complete is lost, power_on() never returns and never raises.
3. The serial transport never drains the port before parsing
open_serial_transport() (transport/serial.py:60) opens the port and starts feeding the parser without calling reset_input_buffer(). Bytes queued in the tty RX buffer before open() — e.g. a previous process left the controller advertising, or the controller is mid-packet — are delivered to the parser first.
Putting it together
If a stray byte arrives in the same read() as HCI_Reset's command-complete, (3) supplies the byte, (1) throws away the reply along with it, and (2) waits for that reply forever. Symptom: invalid packet, ignoring data repeating, power_on() never completing.
Field measurements
On an nRF52833-DK running samples/bluetooth/hci_uart at 1 Mbaud over the on-board J-Link VCOM, driving bumble.apps.auracast:
- Opening a quiet port: ~7% of attempts hang (1/15).
- Opening right after a previous process was killed while scanning (so the controller is still streaming advertising reports into the UART): 3/12 hang (25%).
We also tested the obvious fix and it does not work, so it may save you time: adding serial.reset_input_buffer() right after the port opens gave 7/12 hangs (58%) against 3/12 without it — Fisher exact two-sided p = 0.21, i.e. not significantly worse, but certainly not better. Plausibly because tcflush mid-stream truncates a packet and leaves the parser awaiting body bytes that were just discarded. Flushing before the parser cannot help while the parser itself cannot resynchronise.
Suggested direction
The durable fix looks like (1): on an unrecognised type byte, skip that byte and re-attempt framing on the remainder of the chunk, rather than discarding it. That makes any start position recoverable, which is what a byte-stream transport needs.
Independently, giving Host.reset() a bounded response_timeout would turn "hangs forever" into a clear, retryable error — valuable even after (1), since a controller can also simply not answer.
We are working around this downstream by bounding and retrying the open, but the retry only succeeds because the garbage is transient.
Happy to send a PR for the parser resynchronisation if that direction is acceptable.
A single stray byte on a serial (H:4) link can hang
Device.power_on()forever.Three separate behaviours combine into a permanent hang. Each is defensible alone; together they mean one bad byte at the wrong moment blocks startup with no timeout and no recovery.
1. A bad type byte discards the rest of the read chunk, silently
PacketParser.feed_data()(transport/common.py:143) raisesInvalidPacketErrorfrom the middle of its loop, so any bytes remaining in the currentdatabuffer are never parsed.StreamPacketSource.data_received()(transport/common.py:303-307) catches it and only logs:Deterministic reproduction, no hardware:
The packet bundled with the garbage is lost. There is no attempt to resynchronise within the chunk (e.g. skip one byte and retry framing).
2.
HCI_Resetis awaited with no timeoutHost.reset()(host.py:347) doesawait self.send_sync_command(hci.HCI_Reset_Command()), and_send_command()(host.py:690) awaits withresponse_timeout=None:So if the command-complete is lost,
power_on()never returns and never raises.3. The serial transport never drains the port before parsing
open_serial_transport()(transport/serial.py:60) opens the port and starts feeding the parser without callingreset_input_buffer(). Bytes queued in the tty RX buffer beforeopen()— e.g. a previous process left the controller advertising, or the controller is mid-packet — are delivered to the parser first.Putting it together
If a stray byte arrives in the same
read()asHCI_Reset's command-complete, (3) supplies the byte, (1) throws away the reply along with it, and (2) waits for that reply forever. Symptom:invalid packet, ignoring datarepeating,power_on()never completing.Field measurements
On an nRF52833-DK running
samples/bluetooth/hci_uartat 1 Mbaud over the on-board J-Link VCOM, drivingbumble.apps.auracast:We also tested the obvious fix and it does not work, so it may save you time: adding
serial.reset_input_buffer()right after the port opens gave 7/12 hangs (58%) against 3/12 without it — Fisher exact two-sided p = 0.21, i.e. not significantly worse, but certainly not better. Plausibly becausetcflushmid-stream truncates a packet and leaves the parser awaiting body bytes that were just discarded. Flushing before the parser cannot help while the parser itself cannot resynchronise.Suggested direction
The durable fix looks like (1): on an unrecognised type byte, skip that byte and re-attempt framing on the remainder of the chunk, rather than discarding it. That makes any start position recoverable, which is what a byte-stream transport needs.
Independently, giving
Host.reset()a boundedresponse_timeoutwould turn "hangs forever" into a clear, retryable error — valuable even after (1), since a controller can also simply not answer.We are working around this downstream by bounding and retrying the open, but the retry only succeeds because the garbage is transient.
Happy to send a PR for the parser resynchronisation if that direction is acceptable.