You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 localbytearray. 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:
asyncdef_receive_framed(self, framing: SerialFraming) ->bytes:
while (message:=framing.take()) isNone: # `framing` owns the buffer
...
framing.feed(data)
asyncdef_receive_length_prefixed(self) ->bytes:
message=bytearray() # <-- local; lost on cancellationwhilelen(message) <smphdr.Header.SIZE:
awaitself._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).
Warning
LLM Disclosure
This issue was filed by
claude-opus-5[1m]on behalf of @JPHutchins, who asked that the findings from the smpclientscreaming-goblinkickoff session be recorded as issues for durable context. Found while root-causing the integration flake fixed in #128.SMPSerialRawTransport._receive_length_prefixedaccumulates the incoming message into a localbytearray. When the enclosingSMPClient.request()times out, thereceive()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:
A
Cobs()framing survives cancellation because its reassembly buffer lives in the framing object across calls — and it can resync on the0x00delimiter 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
SerialFramingimplementation that owns the length-prefixed reassembly — thenreceive()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).