Skip to content

Commit e6f0712

Browse files
authored
Merge pull request #958 from meshtastic/tcp-graceful-close
Half-close TCP connections so the last write is not lost
2 parents 82220ba + f623732 commit e6f0712

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

‎meshtastic/tcp_interface.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
from meshtastic.stream_interface import StreamInterface
1212

1313
DEFAULT_TCP_PORT = 4403
14+
15+
# How long close() gives the device to consume what we last wrote before forcing
16+
# the connection down. See close() for why this exists.
17+
GRACEFUL_CLOSE_TIMEOUT = 0.25
18+
1419
logger = logging.getLogger(__name__)
1520

1621

@@ -80,13 +85,39 @@ def myConnect(self) -> None:
8085
server_address = (self.hostname, self.portNumber)
8186
self.socket = socket.create_connection(server_address)
8287

88+
def _wait_for_reader_exit(self, timeout: float) -> None:
89+
"""Wait briefly for the reader thread to drain and exit after a half-close.
90+
91+
Returns as soon as it exits, or after timeout: the device is not obliged
92+
to close just because we did.
93+
"""
94+
rx = getattr(self, "_rxThread", None)
95+
if rx is None or rx is threading.current_thread():
96+
return
97+
with contextlib.suppress(Exception):
98+
rx.join(timeout)
99+
83100
def close(self) -> None:
84101
"""Close a connection to the device."""
85102
logger.debug("Closing TCP stream")
86103
# Sometimes the socket read might be blocked in the reader thread.
87104
# Therefore force a shutdown first to unblock reader thread reads.
88105
self._wantExit = True
89106
if self.socket is not None:
107+
# Half-close first. shutdown(SHUT_WR) sends FIN, which tells the
108+
# device we are done writing and lets it consume what we last wrote
109+
# before the connection goes away -- typically the admin message a
110+
# one-shot command such as `--set` just sent.
111+
#
112+
# Going straight to shutdown(SHUT_RDWR) + close() while either side
113+
# still has unread data makes the stack send RST instead. Winsock
114+
# then discards data the peer had already received but not yet read,
115+
# so the write is lost; Linux delivers it before reporting
116+
# ECONNRESET, which is why this only bites on Windows.
117+
with contextlib.suppress(Exception):
118+
self.socket.shutdown(socket.SHUT_WR)
119+
self._wait_for_reader_exit(GRACEFUL_CLOSE_TIMEOUT)
120+
90121
with contextlib.suppress(
91122
Exception
92123
): # Ignore errors in shutdown, because we might have a race with the server

‎meshtastic/tests/test_tcp_interface.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Meshtastic unit tests for tcp_interface.py"""
22

33
import re
4+
import socket
45
from unittest.mock import MagicMock, patch
56

67
import pytest
@@ -78,6 +79,56 @@ def test_TCPInterface_close_shutdowns_socket_before_super_close():
7879
assert iface.socket is None
7980

8081

82+
@pytest.mark.unit
83+
def test_TCPInterface_close_half_closes_before_shutdown():
84+
"""Close should send FIN and let the device drain before forcing the link down.
85+
86+
Going straight to shutdown(SHUT_RDWR)/close() with data still unread makes the
87+
stack send RST, and Winsock then discards data the device had received but not
88+
yet read, silently losing writes such as `--set`.
89+
"""
90+
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
91+
sock = MagicMock()
92+
iface.socket = sock
93+
call_order = []
94+
95+
with patch.object(TCPInterface, "_socket_shutdown", autospec=True) as mock_shutdown:
96+
with patch.object(
97+
TCPInterface, "_wait_for_reader_exit", autospec=True
98+
) as mock_wait:
99+
with patch(
100+
"meshtastic.stream_interface.StreamInterface.close", autospec=True
101+
) as mock_super_close:
102+
sock.shutdown.side_effect = lambda how: call_order.append(f"shutdown_{how}")
103+
mock_wait.side_effect = lambda _self, _t: call_order.append("wait")
104+
mock_shutdown.side_effect = lambda _self: call_order.append("full_shutdown")
105+
mock_super_close.side_effect = lambda _self: call_order.append("super_close")
106+
107+
iface.close()
108+
109+
assert call_order == [
110+
f"shutdown_{socket.SHUT_WR}",
111+
"wait",
112+
"full_shutdown",
113+
"super_close",
114+
]
115+
116+
117+
@pytest.mark.unit
118+
def test_TCPInterface_close_survives_half_close_failure():
119+
"""A peer that already vanished must not break close()."""
120+
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
121+
sock = MagicMock()
122+
sock.shutdown.side_effect = OSError("already gone")
123+
iface.socket = sock
124+
125+
with patch("meshtastic.stream_interface.StreamInterface.close", autospec=True):
126+
iface.close() # must not raise
127+
128+
sock.close.assert_called_once()
129+
assert iface.socket is None
130+
131+
81132
@pytest.mark.unit
82133
def test_TCPInterface_reconnect():
83134
"""Test that _reconnect correctly reconnects"""

0 commit comments

Comments
 (0)