Repository: pymodbus-dev/pymodbus

Issue:
calling client.close() does not terminate the client process but causes a reconnect.
<!--
Please use the Pymodbus gitter channel at https://gitter.im/pymodbus_dev/Lobby or Stack Overflow(tag [pymodbus](https://stackoverflow.com/questions/tagged/pymodbus) for
support questions.

Before opening a new issue, make sure you do the following:
    * check that your issue isn't already filed: https://github.com/riptideio/pymodbus/issues
    * check the discussions forum https://github.com/riptideio/pymodbus/discussions
    * prepare a short, runnable example that reproduce the issue with the latest development version of Pymodbus
-->

### Versions

* Python:
* OS:
* Pymodbus:
* Modbus Hardware (if used):

### Pymodbus Specific
* Server: tcp/rtu/ascii - sync/async
* Client: tcp/rtu/ascii - sync/async

### Description

What were you trying, what has happened, what went wrong, and what did you expect?

### Code and Logs

```python
# code and logs here.

# please use the following to format logs when posting them here
import pymodbus
pymodbus.pymodbus_apply_logging_config()
```



Discussion:
This is because the call function is connected by default during the process of sending packets
![image](https://user-images.githubusercontent.com/41426791/212445745-28195c18-3958-4c56-a058-954b7d7b26a5.png)

Of course the client is connected when sending packets, this is done so to ensure an automatic reconnect if the server closes the connection. However the problem described is different, when the app calls client.close() it should not continue.
I assume you are aware that the line you point out, is NOT an actual tcp/ip connect(), but it a method in our classes.
For example, if we call the read_coils() function, it will enter the transaction.py file, as long as we have a configuration client, then calling read_coils(), is to try to connect once, both to judge the connectivity and to connect. So I will judge client.socket before using it, this object exists in our different clients.
![image](https://user-images.githubusercontent.com/41426791/212462301-515b58c0-871c-4fc6-abe8-bef9d59b2f5e.png)
![image](https://user-images.githubusercontent.com/41426791/212462405-a34899a1-6c00-4415-aa75-59daffd36dca.png)

You have lost me, I am not really sure what your idea are. BUT Pull requests are welcome and then we can review the suggested changes.

is_socket_open() is in serial.py not the general for all types, and it is because for serial we need to know if the communication is open (which is NOT the same as being connected).
Well.. I'm just giving an example. Because we use functions that will enter the transaction.py. I think this is why we manually client.close() and then use something like "read_coils()" to execute.
![image](https://user-images.githubusercontent.com/41426791/212463197-85a79960-a5e2-44d2-837e-a1ffd6a12c47.png)

The app (not pymodbus) uses client.close() when it wants to terminate the client, and continue doing other things, so the app expects the client to close connection and communication. The app will not do any more client calls.

The current version does however reconnects to the server after a delay instead of keeping it closed. The workaround at the moment is to do
```
   client.close()
   del client
```
but that is not a good solution.

Resolving patch:
diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py
--- a/pymodbus/client/tcp.py
+++ b/pymodbus/client/tcp.py
@@ -57,9 +57,15 @@ def __init__(
         self.loop = None
         self.connected = False
         self.delay_ms = self.params.reconnect_delay
+        self._reconnect_future = None
 
     async def connect(self):  # pylint: disable=invalid-overridden-method
         """Initiate connection to start client."""
+
+        # if delay_ms was set to 0 by close(), we need to set it back again
+        # so this instance will work
+        self.reset_delay()
+
         # force reconnect if required:
         self.loop = asyncio.get_running_loop()
 
@@ -70,6 +76,11 @@ async def connect(self):  # pylint: disable=invalid-overridden-method
     async def close(self):  # pylint: disable=invalid-overridden-method
         """Stop client."""
 
+        # if there is an unfinished delayed reconnection attempt pending, cancel it
+        if self._reconnect_future:
+            self._reconnect_future.cancel()
+            self._reconnect_future = None
+
         # prevent reconnect:
         self.delay_ms = 0
         if self.connected:
@@ -121,7 +132,7 @@ async def _connect(self):
             txt = f"Failed to connect: {exc}"
             _logger.warning(txt)
             if self.delay_ms > 0:
-                asyncio.ensure_future(self._reconnect())
+                self._launch_reconnect()
         else:
             txt = f"Connected to {self.params.host}:{self.params.port}."
             _logger.info(txt)
@@ -150,7 +161,18 @@ def protocol_lost_connection(self, protocol):
             del self.protocol
             self.protocol = None
         if self.delay_ms > 0:
-            asyncio.ensure_future(self._reconnect())
+            self._launch_reconnect()
+
+    def _launch_reconnect(self):
+        """Launch delayed reconnection coroutine"""
+        if self._reconnect_future:
+            _logger.warning(
+                "Ignoring attempt to launch a delayed reconnection while another is already in progress"
+            )
+        else:
+            # store the future in a member variable so we know we have a pending reconnection attempt
+            # also prevents its garbage collection
+            self._reconnect_future = asyncio.ensure_future(self._reconnect())
 
     async def _reconnect(self):
         """Reconnect."""
@@ -159,6 +181,7 @@ async def _reconnect(self):
         await asyncio.sleep(self.delay_ms / 1000)
         self.delay_ms = min(2 * self.delay_ms, self.params.reconnect_delay_max)
 
+        self._reconnect_future = None
         return await self._connect()
 
 


Test patch:
diff --git a/test/test_client.py b/test/test_client.py
--- a/test/test_client.py
+++ b/test/test_client.py
@@ -538,7 +538,9 @@ async def test_client_reconnect(mock_sleep):
     client = lib_client.AsyncModbusTcpClient(
         "127.0.0.1", protocol_class=mock_protocol_class
     )
-    client.delay_ms = 5000
+
+    # set delay long enough so we have only one connection attempt below
+    client.params.reconnect_delay = 5000
     await client.connect()
 
     run_coroutine(client._reconnect())  # pylint: disable=protected-access