From 91e00da93571d0872bc95a0a6084675f033df626 Mon Sep 17 00:00:00 2001 From: tinegachris Date: Fri, 14 Aug 2026 22:31:10 +0300 Subject: [PATCH] Drop the socket when a send fails, so the client can reconnect After a peer resets the connection, send() let the OSError escape with self.socket still set. Since connected is "self.socket is not None" and connect() returns True early on that same test, the client reported itself connected to a dead socket and could not be revived, automatically or manually, for the life of the process. send() now closes the socket and raises ConnectionException when the write fails, so the object's state matches reality. BlockingIOError and InterruptedError are re-raised untouched: recv() leaves the socket non-blocking, so those are transient and say nothing about the connection. No reconnection policy is added, and connect() already calls self.close() in its own OSError handler. --- pymodbus/client/tcp.py | 8 +++++++- test/client/test_client_sync.py | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py index 029825950..cfc92fc8c 100644 --- a/pymodbus/client/tcp.py +++ b/pymodbus/client/tcp.py @@ -219,7 +219,13 @@ def send(self, request, addr: tuple | None = None): if not self.socket: raise ConnectionException(str(self)) if request: - return self.socket.send(request) + try: + return self.socket.send(request) + except (BlockingIOError, InterruptedError): + raise + except OSError: + self.close() + raise ConnectionException(str(self)) from None return 0 def recv(self, size: int | None) -> bytes: diff --git a/test/client/test_client_sync.py b/test/client/test_client_sync.py index 959ef8c15..2f6c7a3a4 100755 --- a/test/client/test_client_sync.py +++ b/test/client/test_client_sync.py @@ -150,6 +150,30 @@ def test_tcp_client_send(self): assert not client.send(b"") assert client.send(b"1234") == 4 + def test_tcp_client_send_drops_socket_on_os_error(self): + """Test that a socket the OS tore down is not left in place as connected.""" + client = ModbusTcpClient("127.0.0.1") + mock_socket = mock.MagicMock() + mock_socket.send.side_effect = BrokenPipeError(32, "Broken pipe") + client.socket = mock_socket + with pytest.raises(ConnectionException): + client.send(b"1234") + assert not client.connected + assert client.socket is None + + def test_tcp_client_send_keeps_socket_on_transient_error(self): + """Test that a transient write error leaves a healthy socket in place.""" + client = ModbusTcpClient("127.0.0.1") + mock_socket = mock.MagicMock() + mock_socket.send.side_effect = BlockingIOError( + 11, "Resource temporarily unavailable" + ) + client.socket = mock_socket + with pytest.raises(BlockingIOError): + client.send(b"1234") + assert client.connected + assert client.socket is mock_socket + @mock.patch("pymodbus.client.tcp.time") @mock.patch("pymodbus.client.tcp.select") def test_tcp_client_recv(self, mock_select, mock_time):