mirror of
https://github.com/meshtastic/python.git
synced 2026-07-31 09:06:28 -04:00
close() went straight to shutdown(SHUT_RDWR) + close(). Doing that while either side still has unread data makes the stack send RST rather than FIN, and the device's FromRadio stream means there is essentially always unread data. Winsock discards data that was received but not yet read when an RST arrives, so an admin message written moments earlier is thrown away before the device reads it. Linux delivers the buffered bytes before reporting ECONNRESET, which is why this only surfaced on Windows: every one-shot TCP write there (--set, --seturl, --sendtext) reported success and did nothing. writeConfig() does not wait for an ack, so close() runs microseconds after sendall(). The device cannot compensate; it polls every 5ms and the RST beats its next read. Send FIN first with shutdown(SHUT_WR), which lets the device consume what we wrote, wait briefly for the reader thread to drain and exit, then tear down exactly as before. The full shutdown is kept so a reader still blocked in recv() is unblocked for the join in StreamInterface.close(). The wait is bounded: the device is not obliged to close just because we half-closed. Fixes #957
171 lines
6.1 KiB
Python
171 lines
6.1 KiB
Python
"""Meshtastic unit tests for tcp_interface.py"""
|
|
|
|
import re
|
|
import socket
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from ..protobuf import config_pb2
|
|
from ..tcp_interface import TCPInterface
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface(capsys):
|
|
"""Test that we can instantiate a TCPInterface"""
|
|
with patch("socket.socket") as mock_socket:
|
|
iface = TCPInterface(hostname="localhost", noProto=True)
|
|
iface.localNode.localConfig.lora.CopyFrom(config_pb2.Config.LoRaConfig())
|
|
iface.myConnect()
|
|
iface.showInfo()
|
|
iface.localNode.showInfo()
|
|
out, err = capsys.readouterr()
|
|
assert re.search(r"Owner: None \(None\)", out, re.MULTILINE)
|
|
assert re.search(r"Nodes", out, re.MULTILINE)
|
|
assert re.search(r"Preferences", out, re.MULTILINE)
|
|
assert re.search(r"Channels", out, re.MULTILINE)
|
|
assert re.search(r"Primary channel URL", out, re.MULTILINE)
|
|
assert err == ""
|
|
assert mock_socket.called
|
|
iface.close()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface_exception():
|
|
"""Test that we can instantiate a TCPInterface"""
|
|
|
|
def throw_an_exception():
|
|
raise ValueError("Fake exception.")
|
|
|
|
with patch(
|
|
"meshtastic.tcp_interface.TCPInterface._socket_shutdown"
|
|
) as mock_shutdown:
|
|
mock_shutdown.side_effect = throw_an_exception
|
|
with patch("socket.socket") as mock_socket:
|
|
iface = TCPInterface(hostname="localhost", noProto=True)
|
|
iface.myConnect()
|
|
iface.close()
|
|
assert mock_socket.called
|
|
assert mock_shutdown.called
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface_without_connecting():
|
|
"""Test that we can instantiate a TCPInterface with connectNow as false"""
|
|
with patch("socket.socket"):
|
|
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
|
|
assert iface.socket is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface_close_shutdowns_socket_before_super_close():
|
|
"""Close should unblock socket reads before waiting on StreamInterface.close()."""
|
|
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
|
|
sock = MagicMock()
|
|
iface.socket = sock
|
|
call_order = []
|
|
|
|
with patch.object(TCPInterface, "_socket_shutdown", autospec=True) as mock_shutdown:
|
|
with patch(
|
|
"meshtastic.stream_interface.StreamInterface.close", autospec=True
|
|
) as mock_super_close:
|
|
mock_shutdown.side_effect = lambda _self: call_order.append("shutdown")
|
|
mock_super_close.side_effect = lambda _self: call_order.append("super_close")
|
|
|
|
iface.close()
|
|
|
|
assert call_order == ["shutdown", "super_close"]
|
|
sock.close.assert_called_once()
|
|
assert iface.socket is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface_close_half_closes_before_shutdown():
|
|
"""Close should send FIN and let the device drain before forcing the link down.
|
|
|
|
Going straight to shutdown(SHUT_RDWR)/close() with data still unread makes the
|
|
stack send RST, and Winsock then discards data the device had received but not
|
|
yet read, silently losing writes such as `--set`.
|
|
"""
|
|
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
|
|
sock = MagicMock()
|
|
iface.socket = sock
|
|
call_order = []
|
|
|
|
with patch.object(TCPInterface, "_socket_shutdown", autospec=True) as mock_shutdown:
|
|
with patch.object(
|
|
TCPInterface, "_wait_for_reader_exit", autospec=True
|
|
) as mock_wait:
|
|
with patch(
|
|
"meshtastic.stream_interface.StreamInterface.close", autospec=True
|
|
) as mock_super_close:
|
|
sock.shutdown.side_effect = lambda how: call_order.append(f"shutdown_{how}")
|
|
mock_wait.side_effect = lambda _self, _t: call_order.append("wait")
|
|
mock_shutdown.side_effect = lambda _self: call_order.append("full_shutdown")
|
|
mock_super_close.side_effect = lambda _self: call_order.append("super_close")
|
|
|
|
iface.close()
|
|
|
|
assert call_order == [
|
|
f"shutdown_{socket.SHUT_WR}",
|
|
"wait",
|
|
"full_shutdown",
|
|
"super_close",
|
|
]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface_close_survives_half_close_failure():
|
|
"""A peer that already vanished must not break close()."""
|
|
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
|
|
sock = MagicMock()
|
|
sock.shutdown.side_effect = OSError("already gone")
|
|
iface.socket = sock
|
|
|
|
with patch("meshtastic.stream_interface.StreamInterface.close", autospec=True):
|
|
iface.close() # must not raise
|
|
|
|
sock.close.assert_called_once()
|
|
assert iface.socket is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface_reconnect():
|
|
"""Test that _reconnect correctly reconnects"""
|
|
with patch("socket.socket") as mock_socket:
|
|
with patch("time.sleep"):
|
|
iface = TCPInterface(hostname="localhost", noProto=True)
|
|
old_socket = iface.socket
|
|
assert old_socket is not None
|
|
|
|
iface._reconnect()
|
|
|
|
assert old_socket.close.called
|
|
# We expect socket class to be instantiated at least twice (init + reconnect)
|
|
assert mock_socket.call_count >= 2
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface_writeBytes_reconnects():
|
|
"""Test that _writeBytes reconnects and re-raises on OSError."""
|
|
with patch("socket.socket"):
|
|
iface = TCPInterface(hostname="localhost", noProto=True)
|
|
iface.socket.sendall.side_effect = OSError("Broken pipe")
|
|
|
|
with patch.object(iface, "_reconnect") as mock_reconnect:
|
|
with pytest.raises(OSError, match="Broken pipe"):
|
|
iface._writeBytes(b"some data")
|
|
mock_reconnect.assert_called_once()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_TCPInterface_readBytes_reconnects():
|
|
"""Test that _readBytes calls _reconnect on empty bytes"""
|
|
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
|
|
iface.socket = MagicMock()
|
|
iface.socket.recv.return_value = b""
|
|
|
|
with patch.object(iface, "_reconnect") as mock_reconnect:
|
|
iface._readBytes(10)
|
|
mock_reconnect.assert_called_once()
|