Skip to content

Raw serial transport desyncs permanently when a receive is cancelled #129

Description

@JPHutchins

Warning

LLM Disclosure

This issue was filed by claude-opus-5[1m] on behalf of @JPHutchins, who asked that the findings from the smpclient screaming-goblin kickoff session be recorded as issues for durable context. Found while root-causing the integration flake fixed in #128.

SMPSerialRawTransport._receive_length_prefixed accumulates the incoming message into a local bytearray. When the enclosing SMPClient.request() times out, the receive() coroutine is cancelled and every byte read so far is discarded — but those bytes have already been drained from the OS serial buffer, so they are gone.

The next receive() therefore starts mid-message and reads the middle of the previous response as an SMP header. The unframed raw protocol has no delimiter and no CRC, so there is nothing to resynchronise on: the connection is poisoned for good. One slow response turns into an unbounded cascade of failures.

This is also why retry logic (#56) cannot work on this transport today: a retry after a timeout is guaranteed to read garbage.

The asymmetry

The framed path already gets this right, and the contrast is the fix:

async def _receive_framed(self, framing: SerialFraming) -> bytes:
    while (message := framing.take()) is None:   # `framing` owns the buffer
        ...
        framing.feed(data)

async def _receive_length_prefixed(self) -> bytes:
    message = bytearray()                        # <-- local; lost on cancellation
    while len(message) < smphdr.Header.SIZE:
        await self._poll_read_into(message)

A Cobs() framing survives cancellation because its reassembly buffer lives in the framing object across calls — and it can resync on the 0x00 delimiter with CRC16 to validate. The unframed path is the one where the buffer is still a local.

Suggested direction

Give the unframed path a persistent buffer that survives cancellation, ideally by expressing "no framing" as a SerialFraming implementation that owns the length-prefixed reassembly — then receive() has one code path and _reset_state() already clears it on (re)connect.

Note that even with a persistent buffer, a truncated message (bytes genuinely lost on the wire, see the sibling pacing issue) is undetectable in the unframed protocol: the server is waiting for a tail that will never arrive. Recovering from that needs a flush/timeout-and-resync policy, which is the same machinery #104 asks for.

Related: #56 (retransmit), #104 (flush on bad sequence), #128 (the flake that surfaced this).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions