Repository: pymodbus-dev/pymodbus

Issue:
ModbusUdpClient can get into a state where it returns responses from earlier requests
### Versions

- Python: 3.10.6
- OS: Ubuntu 22.04 LTS
- Pymodbus: 3.2.2
- Modbus Hardware (if used): -

### Pymodbus Specific

Client: udp - sync

### Description

After a duplicate UDP packet (and possible late data), stale data in network buffers will be attached to next request resulting in corrupted reads. This error state will persist and you will always get data from previous request until socket is reset.

I believe this is due to:
https://github.com/pymodbus-dev/pymodbus/blob/dev/pymodbus/transaction.py#L170
https://github.com/pymodbus-dev/pymodbus/blob/dev/pymodbus/transaction.py#L215-L222
Where when a stale response is received, it is matched against an earlier request, instead of the current. 
With the side effect of leaving the current response for the next request.

I've verified with Wireshark that the that that things are sent correctly over network.

I would expect for a sync client, that the response matches the current request unless there is an error (which shouldn't affect future requests).

It could be possible that this could affect other clients in some cases, but I have not looked into that.

### Code and Logs

```python
>>> from pymodbus.client import ModbusUdpClient
>>> s = ModbusUdpClient("2001:db8:100::1")
>>> s.connect()
True
>>> s.read_input_registers(2080,2,1).registers 
[5, 27314]
>>> for i in range(100): s.read_input_registers(2048,7,1).registers
```
CTRL+C here yields a high probability that there is an outstanding UDP request in the receive buffer for the client.
```
s.read_input_registers(2080,2,1).registers 
[14128, 12337, 12337, 12337, 12597, 13617, 22272]
>>> s.read_input_registers(2080,2,1).registers 
[5, 47802]
```


Discussion:
I wrote a test-case for this. Not a good test, but it shows the issue in a reproducible way:
https://github.com/pymodbus-dev/pymodbus/compare/dev...wirehell:pymodbus:udp-issue?expand=1

Output:
```
===================================================================================== FAILURES =====================================================================================
_______________________________________________________________ TestSynchronousClient.test_udp_client_recv_duplicate _______________________________________________________________
[gw0] linux -- Python 3.10.6 /home/kenny/src/pymodbus-dev/bin/python3

self = <test.test_client_sync.TestSynchronousClient object at 0x7ffff51a9540>

    def test_udp_client_recv_duplicate(self):
        """Test the udp client receive method"""
        client = ModbusUdpClient("127.0.0.1")
    
        client.socket = mockSocket2()
        client.socket.mock_prepare_receive(b"\x00\x01\x00\x00\x00\x05\x01\x04\x02\x00\x03");            # Response 1
        reply1 = client.read_input_registers(0x820, 1, 1)
        client.socket.mock_prepare_receive(b"\x00\x01\x00\x00\x00\x05\x01\x04\x02\x00\x03");            # Duplicate response 1
        client.socket.mock_prepare_receive(b"\x00\x02\x00\x00\x00\x07\x01\x04\x04\x00\x03\xf6\x3e")     # Response 2
        reply2 = client.read_input_registers(0x820, 2, 1)
        reply3 = client.read_input_registers(0x820, 100, 1)
    
        print(reply1.registers)
        print(reply2.registers)
        print(reply3.registers)
        print(reply1.transaction_id)
        print(reply2.transaction_id)
        print(reply3.transaction_id)
    
>       assert 1 == 0
E       assert 1 == 0

test/test_client_sync.py:102: AssertionError
------------------------------------------------------------------------------- Captured stdout call -------------------------------------------------------------------------------
[3]
[3]
[3, 63038]
1
1
2
============================================================================= short test summary info ==============================================================================
FAILED test/test_client_sync.py::TestSynchronousClient::test_udp_client_recv_duplicate - assert 1 == 0

```
Looks like you stumbled over a problem the code did not foresee. I have not looked at the code that handles transactions but if my memory serves me well, we do not check for duplicates.
At a glance, it looks like the async client does the right thing. 
Have you considered implementing the sync clients as blocking calls to the async clients? 
I think that would simplify things, i.e. less code to maintain, most of the transaction.py could be removed. 
Drawbacks would be that retries seems to be missing in async case, so that would need to be added. 
There may be other things that I'm not aware of as well since I've only looked at a part of the code and don't have much project context. 
The PR clean client class is just about that, but the main problem is mixin.py which only have one set of calls used from both async and sync.
Thanks to your test case (slightly modified) I can now reproduce the problem.

However I am unsure about one thing, you state that the async client works, this makes me wonder because the transaction code is shared.

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
@@ -137,7 +137,7 @@ def decode_data(self, data):
             }
         return {}
 
-    def processIncomingPacket(self, data, callback, slave, **kwargs):
+    def processIncomingPacket(self, data, callback, slave, tid: int = None, **kwargs):
         """Process new packet pattern.
 
         This takes in a new request packet, adds it to the current
@@ -148,12 +148,6 @@ def processIncomingPacket(self, data, callback, slave, **kwargs):
 
         The processed and decoded messages are pushed to the callback
         function to process and send.
-
-        :param data: The new packet data
-        :param callback: The function to send results to
-        :param slave: Process if slave id matches, ignore otherwise (could be a
-               list of slave ids (server) or single slave id(client/server)
-        :param kwargs:
         """
         if not isinstance(slave, (list, tuple)):
             slave = [slave]
@@ -165,7 +159,7 @@ def processIncomingPacket(self, data, callback, slave, **kwargs):
                 if len(self._buffer):
                     # Possible error ???
                     if self._header["len"] < 2:
-                        self._process(callback, error=True)
+                        self._process(callback, tid, error=True)
                 break
             if not self.checkFrame():
                 Log.debug("Frame check failed, ignoring!!")
@@ -176,9 +170,9 @@ def processIncomingPacket(self, data, callback, slave, **kwargs):
                 Log.debug("Not a valid slave id - {}, ignoring!!", header_txt)
                 self.resetFrame()
                 continue
-            self._process(callback)
+            self._process(callback, tid)
 
-    def _process(self, callback, error=False):
+    def _process(self, callback, tid, error=False):
         """Process incoming packets irrespective error condition."""
         data = self.getRawFrame() if error else self.getFrame()
         if (result := self.decoder.decode(data)) is None:
@@ -187,7 +181,10 @@ def _process(self, callback, error=False):
             raise InvalidMessageReceivedException(result)
         self.populateResult(result)
         self.advanceFrame()
-        callback(result)  # defer or push to a thread?
+        if tid and tid != result.transaction_id:
+            self.resetFrame()
+        else:
+            callback(result)  # defer or push to a thread?
 
     def resetFrame(self):
         """Reset the entire message frame.
diff --git a/pymodbus/transaction.py b/pymodbus/transaction.py
--- a/pymodbus/transaction.py
+++ b/pymodbus/transaction.py
@@ -217,7 +217,10 @@ def execute(self, request):  # pylint: disable=too-complex
                         tid=request.transaction_id,
                     )
                     self.client.framer.processIncomingPacket(
-                        response, addTransaction, request.slave_id
+                        response,
+                        addTransaction,
+                        request.slave_id,
+                        tid=request.transaction_id,
                     )
                     if not (response := self.getTransaction(request.transaction_id)):
                         if len(self.transactions):


Test patch:
diff --git a/test/test_client_sync.py b/test/test_client_sync.py
--- a/test/test_client_sync.py
+++ b/test/test_client_sync.py
@@ -84,37 +84,12 @@ def test_udp_client_recv_duplicate(self):
         test_msg = b"\x00\x01\x00\x00\x00\x05\x01\x04\x02\x00\x03"
         client = ModbusUdpClient("127.0.0.1")
         client.socket = mockSocket(copy_send=False)
-
-        # test normal receive
-        client.socket.mock_prepare_receive(test_msg)
-        reply_ok = client.read_input_registers(0x820, 1, 1)
-        assert not reply_ok.isError()
-        reply_timeout = client.read_input_registers(0x820, 1, 1)
-        assert reply_timeout.isError()
-        client.close()
-
-        # test duplicate receive
-        client = ModbusUdpClient("127.0.0.1")
-        client.socket = mockSocket(copy_send=False)
         client.socket.mock_prepare_receive(test_msg)
         client.socket.mock_prepare_receive(test_msg)
         reply_ok = client.read_input_registers(0x820, 1, 1)
         assert not reply_ok.isError()
-        # ERROR hanging transaction --> reply_timeout = client.read_input_registers(0x820, 1, 1)
-        # ERROR hanging transaction --> assert reply_timeout.isError()
-        client.close()
-
-        # test duplicate receive with garbage
-        client = ModbusUdpClient("127.0.0.1")
-        client.socket = mockSocket(copy_send=False)
-        client.socket.mock_prepare_receive(test_msg)
-        client.socket.mock_prepare_receive(test_msg + b"\xf6\x3e")
-        reply_ok = client.read_input_registers(0x820, 1, 1)
-        assert not reply_ok.isError()
-        # ERROR hanging transaction --> reply_timeout = client.read_input_registers(0x820, 1, 1)
-        # ERROR hanging transaction --> assert reply_timeout.isError()
-        # ERROR hanging transaction --> reply_timeout = client.read_input_registers(0x820, 1, 1)
-        # ERROR hanging transaction --> assert reply_timeout.isError()
+        reply_none = client.read_input_registers(0x40, 10, 1)
+        assert reply_none.isError()
         client.close()
 
     def test_udp_client_repr(self):