Skip to content

Bound the write in the synchronous serial client, so a request cannot hang forever - #3018

Open
tinegachris wants to merge 1 commit into
pymodbus-dev:devfrom
tinegachris:fix/serial-client-bound-the-write
Open

Bound the write in the synchronous serial client, so a request cannot hang forever#3018
tinegachris wants to merge 1 commit into
pymodbus-dev:devfrom
tinegachris:fix/serial-client-bound-the-write

Conversation

@tinegachris

@tinegachris tinegachris commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Problem

ModbusSerialClient.connect() opens the port with a read timeout and nothing else, so pyserial
applies its own default of write_timeout=None, which means wait indefinitely:

self.socket = serial.serial_for_url(
    self.comm_params.host,
    timeout=self.comm_params.timeout_connect,   # read timeout only
    ...
)

send() then writes straight to that port. If the port cannot accept the bytes, because the adapter
has stopped draining its transmit buffer or hardware flow control is asserted,
self.socket.write() never returns and never raises, and the calling thread is lost for the
life of the process.

SerialTransport sets this on the same kind of port, apparently for the same reason:

self.sync_serial.timeout = 0
self.sync_serial.write_timeout = 0

So the asynchronous path is protected and the synchronous one is not.

Two paths inside pyserial's posix write() reach the same dead end, and neither returns nor raises.
A partial write parks in select.select(..., None) with no deadline. A write that cannot place a
single byte raises EAGAIN, which the surrounding except OSError swallows before looping. The
symptom is either a parked thread or one spinning on a core; from the caller's side both are
silence.

The read side is already correct: timeout reaches pyserial as the read timeout and
_wait_for_data() is bounded by it, so a device that answers nothing fails cleanly with a
ModbusIOException. That makes the result counterintuitive. A silent device is handled properly,
while a device that cannot accept bytes hangs forever, and timeout looks like it should cover
both.

This is a different defect from #3008. That one is about an error the OS does report being
mishandled. Here the OS reports nothing at all, so there is no exception for that handler to see.

Reproduction

No hardware needed. A pty stands in for the adapter, nothing reads the far side, and its buffer is
filled, which is the state an adapter leaves behind when it stops draining.

Standalone script
"""Does a synchronous serial write bound, with the port unable to drain?"""
import os
import pty
import threading
import time
import tty

from pymodbus.client import ModbusSerialClient


def make_port():
    """A serial port with no reader on the far side."""
    master, slave = pty.openpty()
    tty.setraw(master)
    tty.setraw(slave)
    os.chmod(os.ttyname(slave), 0o666)
    return master, os.ttyname(slave)


def fill(path):
    """Fill the transmit buffer; report how many bytes it took."""
    writer = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_NOCTTY)
    total = 0
    try:
        while True:
            try:
                total += os.write(writer, b"\x00" * 256)
            except BlockingIOError:
                return total
    finally:
        os.close(writer)


def probe(label, wait_for=20):
    """Fire one send at a port that cannot drain, and report what came back."""
    master, path = make_port()
    client = ModbusSerialClient(port=path, baudrate=19200, bytesize=8, parity="N",
                                stopbits=1, timeout=1, retries=1)
    assert client.connect()
    filled = fill(path)
    print(f"{label}: port write_timeout = {client.socket.write_timeout}  (filled {filled} bytes)")

    out = {}

    def call():
        t0 = time.monotonic()
        try:
            n = client.send(b"\x01\x03\x00\x00\x00\x02\xc4\x0b")
            out["r"] = f"send() returned {n} after {time.monotonic() - t0:.1f}s"
        except Exception as e:
            out["r"] = f"{type(e).__name__} after {time.monotonic() - t0:.1f}s"
        out["sock"] = "None (port closed)" if client.socket is None else "set (port kept)"

    w = threading.Thread(target=call, daemon=True)
    w.start()
    w.join(wait_for)
    print(f"{label}: {out.get('r', f'STILL BLOCKED after {wait_for}s')}")
    print(f"{label}: socket afterwards = {out.get('sock', 'n/a, still blocked')}")
    print(f"{label}: thread alive afterwards = {w.is_alive()}\n")
    os.close(master)


for i in (1, 2, 3):
    probe(f"run {i}")

On dev (8aac385f), pyserial 3.5, Linux 6.18, three runs out of three:

run 1: port write_timeout = None  (filled 20480 bytes)
run 1: STILL BLOCKED after 20s
run 1: thread alive afterwards = True

run 2: STILL BLOCKED after 20s
run 3: STILL BLOCKED after 20s

With this change, same script, three out of three:

patched run 1: port write_timeout = 1  (filled 20480 bytes)
patched run 1: ConnectionException after 1.0s
patched run 1: socket afterwards = set (port kept)
patched run 1: thread alive afterwards = False

The unpatched case is not slow, it is permanent. 20 s is only the patience of the harness.

One caveat if you run it yourself: the write only blocks if the buffer is still full at the instant
it is attempted. An occasional run slips an 8 byte frame through and returns promptly. Re-run it
rather than concluding the defect is absent.

Change

Two parts.

Bound the write. connect() passes write_timeout alongside timeout:

             self.socket = serial.serial_for_url(
                 self.comm_params.host,
                 timeout=self.comm_params.timeout_connect,
+                write_timeout=self.comm_params.timeout_connect,
                 bytesize=self.comm_params.bytesize,

Applied inside connect() rather than at construction, because the client reopens the port itself
when a request finds it closed, so a value set once would be lost on the first reconnection.

write_timeout=0 would not do here. In pyserial that means non-blocking, so write() returns a
short count, send() reports the frame as sent, and a truncated request goes out. That is correct
for SerialTransport, which buffers the remainder itself, but not for the synchronous client.

Do not let the timeout drop the port.

             except (BlockingIOError, InterruptedError):
                 raise
+            except serial.SerialTimeoutException:
+                raise ConnectionException(str(self)) from None
             except OSError:
                 self.close()
                 raise ConnectionException(str(self)) from None

This second part is not cosmetic. serial.SerialTimeoutException subclasses OSError, so without
it the handler added in #3008 would catch a write timeout and close the port. That would
reintroduce serial port closing on a per-request timeout, which is what #3014 deliberately removed,
on the shared-bus reasoning argued in #2269. A write timeout says nothing about the port being
broken, and on RTU the line is shared, so dropping it would disturb slaves that are still healthy.
Keeping ConnectionException preserves the exception type #3008 established.

Only send() needs it. pyserial's read() returns short on a read timeout rather than raising, so
SerialTimeoutException never arises on the receive path.

Nothing changes for a healthy port, where the write completes long before the timeout.

…orever

connect() opened the port with a read timeout only, so pyserial applied its
own default of write_timeout=None, meaning wait indefinitely. If the port
could not accept the bytes, because the adapter had stopped draining its
transmit buffer or hardware flow control was asserted, socket.write() never
returned and never raised, and the calling thread was lost for the life of
the process. SerialTransport already sets write_timeout on the same kind of
port, so only the synchronous client was exposed.

The read side was already bounded, which made this counter-intuitive: a
device that answers nothing fails cleanly with a ModbusIOException, while a
device that cannot accept bytes hangs forever, and timeout looks like it
covers both.

connect() now passes write_timeout alongside timeout. It is applied there
rather than at construction because the client reopens the port itself when
a request finds it closed, so a value set once would be lost on the first
reconnection. write_timeout=0 would not do, since in pyserial that means
non-blocking and send() would report a truncated frame as sent.

send() converts the resulting SerialTimeoutException to ConnectionException
without closing the port. A write timeout says nothing about the port being
broken, and on RTU the line is shared, so dropping it would disturb slaves
that are still healthy. This keeps the exception type pymodbus-dev#3008 established
while respecting pymodbus-dev#3014, which stopped closing a serial port on no response.
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.

1 participant