Skip to content

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

Description

@TheArkadiuszGrzelka

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions