Skip to content

Drop the serial port on a transport error, so the client can reconnect - #3008

Merged
janiversen merged 1 commit into
pymodbus-dev:devfrom
tinegachris:fix/serial-client-drop-port-on-transport-error
Aug 25, 2026
Merged

Drop the serial port on a transport error, so the client can reconnect#3008
janiversen merged 1 commit into
pymodbus-dev:devfrom
tinegachris:fix/serial-client-drop-port-on-transport-error

Conversation

@tinegachris

@tinegachris tinegachris commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Problem

ModbusSerialClient reports itself connected to a port the operating system has torn down, and can never be revived automatically, and not by a caller reconnecting manually.

This is the same defect #3000 fixed on ModbusTcpClient. ModbusSerialClient does not inherit from it, so it was not covered, and it has the same three pieces: connected is self.socket is not None, connect() returns True early on that same test, and the I/O path lets OSError escape without clearing self.socket. The per-request connect() in execute() therefore becomes a no-op, and every later request fails identically for the life of the process.

Unguarded surfaces:

Method Surface What escapes
send self._in_waiting() raw OSError
send self.socket.read(waitingbytes) SerialException
send self.socket.write(request) SerialException
recv self._wait_for_data()_in_waiting() raw OSError
recv self.socket.read(size) SerialException

_in_waiting() is reached first on both paths — every send() calls it before writing and every
recv() reaches it via _wait_for_data(). In pyserial, it is a bare fcntl.ioctl(self.fd, TIOCINQ, ...) with no error handling at all, so it raises a raw OSError rather than a SerialException. Wrapping only the write call, the shape of #3000, would not catch it.

pyserial's read() detects this condition and raises SerialException with the message "device reports readiness to read but returned no data (device disconnected or multiple access on port?)", but pymodbus discards that signal.

Reproduction

Verified against dev (2aa31032). Each of the five surfaces above was driven with what the real
transport raises on a dead port, and in every case the exception escaped send/recv raw and the
port was left in place:

surface                                   what escapes         result
send  (a) _in_waiting -> ioctl            OSError raw          socket=set  connected=True
send  (b) read() buffer cleanup           SerialException raw  socket=set  connected=True
send  (c) write()                         SerialException raw  socket=set  connected=True
recv  (d) _wait_for_data -> _in_waiting   OSError raw          socket=set  connected=True
recv  (e) read()                          SerialException raw  socket=set  connected=True

End to end through the public API it is worse than "every request fails": a raw OSError escapes
read_holding_registers()
, which callers written against the documented ConnectionException /
ModbusIOException contract will not catch.

start: connected = True
  read 1: OSError: [Errno 5] Input/output error
     connected=True  connect()=True  socket=set
  read 2: OSError: [Errno 5] Input/output error
     connected=True  connect()=True  socket=set
  read 3: OSError: [Errno 5] Input/output error
     connected=True  connect()=True  socket=set
On Linux a pty pair reproduces it with no adapter needed
"""Reproduction: ModbusSerialClient keeps a torn-down port and reports itself connected."""
import os
import time

from pymodbus.client import ModbusSerialClient

master, slave = os.openpty()
port = os.ttyname(slave)

client = ModbusSerialClient(port=port, baudrate=19200, timeout=1, retries=1)
print("connect():", client.connect(), " connected:", client.connected)

os.close(master)
os.close(slave)
time.sleep(0.2)

ADU = b"\x01\x03\x00\x00\x00\x02\xc4\x0b"
for attempt in range(1, 4):
    for name, call in (("send", lambda: client.send(ADU)), ("recv", lambda: client.recv(4))):
        try:
            call()
            print(f"  {name} {attempt}: ok")
        except Exception as e:
            print(f"  {name} {attempt}: {type(e).__name__}: {e}")
    print(
        f"     connected={client.connected}  connect()={client.connect()}  "
        f"socket={'set' if client.socket else 'None'}"
    )
    time.sleep(0.1)

Output on pymodbus 3.13.0 / pyserial 3.5, Linux, Python 3.11 — a raw OSError on both
operations, forever:

connect(): True  connected: True
  send 1: OSError: [Errno 5] Input/output error
  recv 1: OSError: [Errno 5] Input/output error
     connected=True  connect()=True  socket=set
  send 2: OSError: [Errno 5] Input/output error
  recv 2: OSError: [Errno 5] Input/output error
     connected=True  connect()=True  socket=set

Change

send() and recv() close the port and raise ConnectionException when an operation fails with
OSError.

No reconnection policy is added — no retry, no delay. This only makes the object's state match
reality after a failed operation, so the manual path works. connect() already calls self.close() in its own exception handler, so this is the existing pattern in the same class.

BlockingIOError and InterruptedError are re-raised rather than treated as a dead port, for the
same reason #3000 excluded them.

Existing behaviour is unchanged for a normal send (returns the byte count), an absent socket (raises ConnectionException), and an empty request (returns 0).

A silent slave does not cost the bus its port

One ModbusSerialClient serves every device on the bus, so the obvious concern — and the one raised in #2269 — is whether one unresponsive slave now drops the port for all of them. It does not, and the discriminator is exact: a device that does not answer raises ModbusIOException, which is not an OSError and never reaches the new handler. pyserial's read() also breaks out of its own loop on timeout and returns short rather than raising. A timeout is a statement about one device; an OSError is a statement about the port.

On narrowing the exception

Narrowing to except ConnectionError is not an option here, unlike on the TCP client.
serial.SerialException subclasses OSError but not ConnectionError, and the surface that fires
first, _in_waiting(), raises a bare OSError. It would catch nothing. Flagging it before it is
proposed.

Tests

Four tests added to TestSyncClientSerial — both branches of both methods, so the new code is fully
covered:

  • test_serial_client_send_drops_socket_on_os_error
  • test_serial_client_send_keeps_socket_on_transient_error
  • test_serial_client_recv_drops_socket_on_os_error
  • test_serial_client_recv_keeps_socket_on_transient_error

ModbusSerialClient reported itself connected to a port the OS had torn down.
send() and recv() let OSError escape with self.socket still set, and since
connected is "self.socket is not None" and connect() returns True early on
that same test, the client could not be revived, automatically or manually,
for the life of the process.

This is the same defect pymodbus-dev#3000 fixed on ModbusTcpClient. The serial client
does not inherit from it, so it was not covered, and it has no benign
variant: a serial port has no EOF, so the read path is stranded just as
thoroughly as the write path. There are also five unguarded surfaces rather
than one, and the first one reached, _in_waiting(), is a bare fcntl.ioctl in
pyserial that raises a raw OSError rather than a SerialException.

send() and recv() now close the port and raise ConnectionException when an
operation fails. BlockingIOError and InterruptedError are re-raised
untouched, as in pymodbus-dev#3000, since neither says the transport is dead.

No reconnection policy is added, and connect() already calls self.close() in
its own exception handler. A device that simply does not answer raises
ModbusIOException, which is not an OSError, so a silent slave can never cost
the bus its port.

@janiversen janiversen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks.

@janiversen
janiversen merged commit b4ce31d into pymodbus-dev:dev Aug 25, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants