google / google/bumble

A single stray byte on an H:4 serial link hangs power_on() forever (no parser resync, no reset timeout)

Open
#956 5 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
555
Forks
138
Avg merge
3d 15h
Merged PRs (30d)
9

Description

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:

```python
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:

```python
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`:

```python
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.

Contributor guide

Open the contributing guide

Research direction

Start with the deterministic reproduction, then read PacketParser.feed_data() and StreamPacketSource.data_received() in transport/common.py, followed by Host.reset() and _send_command() in host.py and open_serial_transport() in transport/serial.py. Confirm how a stray byte affects the bundled packet and pending reset response. Done means the parser can recover within the same chunk and reset startup has a bounded failure path, with focused tests for both behaviors.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
embedded-iot, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.