Repository: pymodbus-dev/pymodbus

Issue:
Pymodbus doesn't correctly handle Reassembled TCP segments
### Versions

- Python: Python: 3.11.4
- OS: Ubuntu 20.04
- Pymodbus: pymodbus-3.7.0.dev0  (git commit 81df5b1cde349dddd32ac3c1fd97ec4b475c2512) as well as 3.5.4
- Modbus Hardware (if used): SMA STP 6000TL-20 (version 02.51.02.R)

### Pymodbus Specific
- Client: tcp - async

### Description

When trying to read registers from the SMA STP 6000TL Inverter, the Modbus Response is split over 2 frames where the second frame only contains the last required byte. pymodbus doesn't seem to reassemble the modbus frames and instead tries parsing the first frame as a modbus response which it discards because it doesn't pass the Frame check. It then tries reading the second frame (consisting of only one byte) as a response and throws an exception because it can't decode it.

pymodbus should probably check the length of the tcp packet and only run the frame check once the complete packet has arrived. I've attached the client code, pymodbus logs and a wireshark capture of the modbus traffic.

### Code and Logs
[20240104_tcp_reassembly.pcapng.gz](https://github.com/pymodbus-dev/pymodbus/files/13828939/20240104_tcp_reassembly.pcapng.gz)

```python
import asyncio

import pymodbus
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.constants import Endian
from pymodbus.exceptions import ModbusException
from pymodbus.payload import BinaryPayloadDecoder
from pymodbus.pdu import ExceptionResponse

pymodbus.pymodbus_apply_logging_config("DEBUG")


async def main():
    async with AsyncModbusTcpClient(
        host="127.0.0.1", port=5502, reconnect_delay=0
    ) as mbus:
        response = await mbus.read_holding_registers(42109, 4, slave=1)
        decoder = BinaryPayloadDecoder.fromRegisters(
            response.registers, byteorder=Endian.BIG, wordorder=Endian.BIG
        )
        _serial = decoder.decode_32bit_uint()
        _susy_id = decoder.decode_16bit_uint()
        unit_id = decoder.decode_16bit_uint()


if __name__ == "__main__":
    asyncio.run(main())
```
Logs:
```
2024-01-04 11:16:16,385 DEBUG logging:103 Connecting to 127.0.0.1:5502.
2024-01-04 11:16:16,385 DEBUG logging:103 Connecting comm
2024-01-04 11:16:16,385 DEBUG logging:103 Connected to comm
2024-01-04 11:16:16,385 DEBUG logging:103 callback_connected called
2024-01-04 11:16:16,385 DEBUG logging:103 send: 0x0 0x1 0x0 0x0 0x0 0x6 0x1 0x3 0xa4 0x7d 0x0 0x4
2024-01-04 11:16:16,385 DEBUG logging:103 Adding transaction 1
2024-01-04 11:16:16,635 DEBUG logging:103 recv: 0x0 0x1 0x0 0x0 0x0 0xb 0x1 0x3 0x8 0x0 0xb5 0x12 0x2f 0x37 0x21 0x0 old_data:  addr=None
2024-01-04 11:16:16,635 DEBUG logging:103 Processing: 0x0 0x1 0x0 0x0 0x0 0xb 0x1 0x3 0x8 0x0 0xb5 0x12 0x2f 0x37 0x21 0x0
2024-01-04 11:16:16,635 DEBUG logging:103 Frame check failed, ignoring!!
2024-01-04 11:16:16,635 DEBUG logging:103 Resetting frame - Current Frame in buffer - 0x0 0x1 0x0 0x0 0x0 0xb 0x1 0x3 0x8 0x0 0xb5 0x12 0x2f 0x37 0x21 0x0
2024-01-04 11:16:16,719 DEBUG logging:103 recv: 0x3 old_data:  addr=None
2024-01-04 11:16:16,719 DEBUG logging:103 Processing: 0x3
2024-01-04 11:16:16,719 DEBUG logging:103 Factory Response[ReadHoldingRegistersResponse': 3]
2024-01-04 11:16:16,719 ERROR logging:115 General exception: index out of range
2024-01-04 11:16:16,719 DEBUG logging:103 Resetting frame - Current Frame in buffer - 0x3
Fatal error: protocol.data_received() call failed.
protocol: <pymodbus.client.tcp.AsyncModbusTcpClient object at 0x7fa70e325f70>
transport: <_SelectorSocketTransport fd=6 read=polling write=<idle, bufsize=0>>
Traceback (most recent call last):
  File "/usr/lib/python3.8/asyncio/selector_events.py", line 862, in _read_ready__data_received
    self._protocol.data_received(data)
  File "/home/simon/tools/pymodbus/pymodbus/transport/transport.py", line 318, in data_received
    self.datagram_received(data, None)
  File "/home/simon/tools/pymodbus/pymodbus/transport/transport.py", line 352, in datagram_received
    cut = self.callback_data(self.recv_buffer, addr=addr)
  File "/home/simon/tools/pymodbus/pymodbus/client/base.py", line 186, in callback_data
    self.framer.processIncomingPacket(data, self._handle_response, slave=0)
  File "/home/simon/tools/pymodbus/pymodbus/framer/base.py", line 139, in processIncomingPacket
    self.frameProcessIncomingPacket(single, callback, slave, **kwargs)
  File "/home/simon/tools/pymodbus/pymodbus/framer/socket_framer.py", line 136, in frameProcessIncomingPacket
    self._process(callback, tid, error=True)
  File "/home/simon/tools/pymodbus/pymodbus/framer/socket_framer.py", line 154, in _process
    raise ModbusIOException("Unable to decode request")
pymodbus.exceptions.ModbusIOException: Modbus Error: [Input/Output] Unable to decode request
2024-01-04 11:16:16,721 DEBUG logging:103 Connection lost comm due to Modbus Error: [Input/Output] Unable to decode request
2024-01-04 11:16:16,721 DEBUG logging:103 Getting transaction 1
Traceback (most recent call last):
  File "bug_reproducer.py", line 27, in <module>
    asyncio.run(main())
  File "/usr/lib/python3.8/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/usr/lib/python3.8/asyncio/base_events.py", line 616, in run_until_complete
    return future.result()
  File "bug_reproducer.py", line 17, in main
    response = await mbus.read_holding_registers(42109, 4, slave=1)
  File "/home/simon/tools/pymodbus/pymodbus/client/base.py", line 167, in async_execute
    resp = await asyncio.wait_for(
  File "/usr/lib/python3.8/asyncio/tasks.py", line 494, in wait_for
    return fut.result()
pymodbus.exceptions.ConnectionException: Modbus Error: [Connection] Connection lost during request
```


Discussion:
We do check the length of the packet, but when a frame is split the each dap have it own length.

This seems to be bug in our transport layer, I will take a look later.
Thanks for a very complete documentation, that helps a lot isolating the bug. I will use the data to create a specific test case, and then solve it.

Resolving patch:
diff --git a/pymodbus/framer/socket_framer.py b/pymodbus/framer/socket_framer.py
--- a/pymodbus/framer/socket_framer.py
+++ b/pymodbus/framer/socket_framer.py
@@ -52,20 +52,21 @@ def checkFrame(self):
 
         Return true if we were successful.
         """
-        if self.isFrameReady():
-            (
-                self._header["tid"],
-                self._header["pid"],
-                self._header["len"],
-                self._header["uid"],
-            ) = struct.unpack(">HHHB", self._buffer[0 : self._hsize])
-
-            # someone sent us an error? ignore it
-            if self._header["len"] < 2:
-                self.advanceFrame()
-            # we have at least a complete message, continue
-            elif len(self._buffer) - self._hsize + 1 >= self._header["len"]:
-                return True
+        if not self.isFrameReady():
+            return False
+        (
+            self._header["tid"],
+            self._header["pid"],
+            self._header["len"],
+            self._header["uid"],
+        ) = struct.unpack(">HHHB", self._buffer[0 : self._hsize])
+
+        # someone sent us an error? ignore it
+        if self._header["len"] < 2:
+            self.advanceFrame()
+        # we have at least a complete message, continue
+        elif len(self._buffer) - self._hsize + 1 >= self._header["len"]:
+            return True
         # we don't have enough of a message yet, wait
         return False
 
@@ -128,23 +129,15 @@ def frameProcessIncomingPacket(self, single, callback, slave, tid=None, **kwargs
         The processed and decoded messages are pushed to the callback
         function to process and send.
         """
-        while True:
-            if not self.isFrameReady():
-                if len(self._buffer):
-                    # Possible error ???
-                    if self._header["len"] < 2:
-                        self._process(callback, tid, error=True)
-                break
-            if not self.checkFrame():
-                Log.debug("Frame check failed, ignoring!!")
-                self.resetFrame()
-                continue
-            if not self._validate_slave_id(slave, single):
-                header_txt = self._header["uid"]
-                Log.debug("Not a valid slave id - {}, ignoring!!", header_txt)
-                self.resetFrame()
-                continue
-            self._process(callback, tid)
+        if not self.checkFrame():
+            Log.debug("Frame check failed, ignoring!!")
+            return
+        if not self._validate_slave_id(slave, single):
+            header_txt = self._header["uid"]
+            Log.debug("Not a valid slave id - {}, ignoring!!", header_txt)
+            self.resetFrame()
+            return
+        self._process(callback, tid)
 
     def _process(self, callback, tid, error=False):
         """Process incoming packets irrespective error condition."""


Test patch:
diff --git a/test/test_framers.py b/test/test_framers.py
--- a/test/test_framers.py
+++ b/test/test_framers.py
@@ -8,9 +8,12 @@
 from pymodbus.client.base import ModbusBaseClient
 from pymodbus.exceptions import ModbusIOException
 from pymodbus.factory import ClientDecoder
-from pymodbus.framer.ascii_framer import ModbusAsciiFramer
-from pymodbus.framer.binary_framer import ModbusBinaryFramer
-from pymodbus.framer.rtu_framer import ModbusRtuFramer
+from pymodbus.framer import (
+    ModbusAsciiFramer,
+    ModbusBinaryFramer,
+    ModbusRtuFramer,
+    ModbusSocketFramer,
+)
 from pymodbus.transport import CommType
 from pymodbus.utilities import ModbusTransactionState
 
@@ -26,6 +29,10 @@ def fixture_rtu_framer():
     """RTU framer."""
     return ModbusRtuFramer(ClientDecoder())
 
+@pytest.fixture(name="socket_framer")
+def fixture_socket_framer():
+    """Socket framer."""
+    return ModbusSocketFramer(ClientDecoder())
 
 @pytest.fixture(name="ascii_framer")
 def fixture_ascii_framer():
@@ -359,3 +366,24 @@ def test_decode_ascii_data(ascii_framer, data):
         assert data.get("fcode") == 1
     else:
         assert not data
+
+def test_recv_split_packet():
+    """Test receive packet."""
+    response_ok = False
+
+    def _handle_response(_reply):
+        """Handle response."""
+        nonlocal response_ok
+        response_ok = True
+
+    message = bytearray(b"\x00\x01\x00\x00\x00\x0b\x01\x03\x08\x00\xb5\x12\x2f\x37\x21\x00\x03")
+    for i in range(0, len(message)):
+        part1 = message[:i]
+        part2 = message[i:]
+        response_ok = False
+        framer = ModbusSocketFramer(ClientDecoder())
+        if i:
+            framer.processIncomingPacket(part1, _handle_response, slave=0)
+            assert not response_ok, "Response should not be accepted"
+        framer.processIncomingPacket(part2, _handle_response, slave=0)
+        assert response_ok, "Response is valid, but not accepted"