Drop the socket when a send fails, so the client can reconnect - #3000
Merged
janiversen merged 1 commit intoAug 15, 2026
Merged
Conversation
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.
janiversen
approved these changes
Aug 15, 2026
janiversen
left a comment
Collaborator
There was a problem hiding this comment.
LGTM, thanks.
That explains an old bug we never got pinpointed (solved differently).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After a peer resets the connection,
ModbusTcpClientkeeps a dead socket and reports itselfconnected, so it can never be revived — not automatically, and not by a caller reconnecting manually.
send()callsself.socket.send(request)without handlingOSError, so the exception escapes andself.socketis left set. Sinceconnectedisself.socket is not Noneandconnect()returnsTrueearly on that same test, the per-requestconnect()inexecute()becomes a no-op and everylater request fails identically for the life of the process.
I understand the sync client does no automatic reconnection (#2320)
and I am not asking for that. The problem is that manual reconnection is also impossible: a
caller cannot re-establish the connection without first calling
close(), andconnectedgivesthem no way to discover that this is needed.
This only affects an abortive close. A clean close surfaces in
recvas EOF,_handle_abrupt_socket_close()raises
ConnectionExceptionand closes the socket itself, and the client recovers. That asymmetrymeans it hits write-heavy clients hardest, since their next operation is a
send.Reproduction
Standalone script — peer closes abortively with SO_LINGER 0
On
dev(5617e05c) the rawOSErrorescapesclient.send()and the client stays "connected" to adead socket forever:
With this change the socket is dropped and
connect()genuinely re-dials (it fails here onlybecause the test server is gone, which is the correct answer):
(Transcript above is Windows/Python 3.12; the same behaviour was originally observed on Linux, where
the errors read
[Errno 104] Connection reset by peerand[Errno 32] Broken pipe.)Seen in production on a controller polling nine devices: after the peer process died and returned,
the read-dominated devices recovered on their own while the one write-heavy device logged 100 broken
pipes, no
ConnectionException, and stayed unusable until the application was restarted.Change
send()closes the socket and raisesConnectionExceptionwhen the write fails withOSError.This adds no reconnection policy — no retry, no delay. It only makes the object's state match
reality after a failed write, so the manual path works.
connect()already callsself.close()inits own
OSErrorhandler, so this is the existing pattern in the same class.Existing behaviour of
sendis unchanged for a normal send, an absent socket, and an empty request.ModbusTlsClientsubclassesModbusTcpClientand does not overridesend, so this covers the TLSclient too.
BlockingIOErrorandInterruptedErrorare deliberately re-raised rather than treated as a deadconnection.
recv()sets the socket non-blocking and never restores it, so from the first readonward a momentarily full send buffer can raise
BlockingIOError— anOSError, but not aconnection failure — and closing on it would drop a healthy connection under load.
InterruptedError(EINTR) is transient for the same reason.
Three things for your call
OSErrortoConnectionExceptionfor consistency with whatsend/recvalready raisewhen the socket is absent, and with the contract
execute()declares (:raises ConnectionException:).Happy to close and re-raise the original instead if you prefer not to change the exception type.
OSErrorminus the two transient cases. CatchingConnectionErroronly would be narrowerand would exclude them automatically, at the cost of missing
EBADF/ENOTCONN. Tell me which youprefer.
recv()has the same shape and noOSErrorhandling. I left it alone to keep this focused — gladto extend the patch if you want it covered.
Tests
Two tests added to
TestSyncClientTcp:test_tcp_client_send_drops_socket_on_os_error— fails ondevtoday withBrokenPipeErrorescaping
send, passes with this change.test_tcp_client_send_keeps_socket_on_transient_error— pins theBlockingIOErrorcarve-out so atransient write error still leaves a healthy socket in place.
Ran
check_ci.shlocally: codespell, ruff (check and format) and pylint (10.00/10) clean, and zubanclean over
pymodbusandtest. Full suite 1934 passed, 17 skipped, with no new failures andcoverage unchanged from
dev. My local run was on Windows, wherezubanadditionally reportspre-existing errors under
examples/contrib— the[tool.mypy] exclude = '/contrib/'regex does notmatch backslash paths — and
test/global/test_logging.pyerrors in teardown because the log file isstill open. Both are unrelated to this change and do not reproduce on your Linux CI.