Skip to content

send() discards write()'s return value; a short write silently truncates an SMP message #130

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.

_SerialTransportBase.send() discards the return value of pyserial's write():

if self._framing is None:
    self._conn.write(data)          # returns bytes written -- ignored
else:
    for frame in self._framing.encode(data):
        self._conn.write(frame)     # same

write() is documented to return the number of bytes written. If it ever returns less than len(data), the SMP message goes out truncated, with no error raised anywhere — and for the raw transport that means the server waits forever for a tail that will never arrive.

This is reachable, not theoretical

pyserial's socket:// handler treats a zero write_timeout as non-blocking: it issues a single socket.send() and returns that count without looping.

d = to_bytes(data)
tx_len = length = len(d)
timeout = Timeout(self._write_timeout)
while tx_len > 0:
    try:
        n = self._socket.send(d)
        if timeout.is_non_blocking:
            # Zero timeout indicates non-blocking - simply return the
            # number of bytes of data actually written
            return n

serial/urlhandler/protocol_socket.py (pyserial 3.5)

Any remainder is silently dropped. A local serial port masks this because serialposix.write() loops properly, but the transport should not depend on which backend it happens to be bound to.

Note on scope — this is NOT the cause of the #128 flake

I initially diagnosed this as the cause of the mps2_an385.serial_recovery_raw flake and was wrong: setting a non-zero write_timeout alone still failed 2/40 runs. The flake's actual cause was UART RX overrun from an unpaced write burst. This remains a genuine latent hazard on its own merits, which is why it is filed separately rather than claimed in #128.

Suggested fix

Assert the full write, or loop until it completes:

written = self._conn.write(data)
if written != len(data):
    raise SMPTransportDisconnected(f"short write: {written} of {len(data)} B")

Worth doing alongside #56, since a truncated request is exactly the condition a retransmit policy has to detect.

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