Repository: avocado-framework/avocado

Issue:
utils: Improve how we get a free port (without brute force)
**Is your feature request related to a problem? Please describe.**
Currently, `utils.find_free_port()` is brute forcing for a free port. According to @huth about QEMU tests: _"..this takes > 70 seconds for each and every tests from tests/avocado/boot_linux.py to get canceled."_

**Describe the solution you'd like**
Implement an improved version of this method.

**Describe alternatives you've considered**
Maybe something like:

```python
with socket() as s: 
    s.bind(('',0)) 
    return s.getsockname()[1]
```  

**Additional information**
https://www.mail-archive.com/qemu-devel@nongnu.org/msg871809.html



Discussion:
@huth, if you could describe what in your system is blocking this method, would be great. So far, I'm not able to reproduce this.
I thought it might be related to the firewall or SELinux, but disabling those didn't cure the issue. So I've had a closer look now, and it seems to me like that is_port_free() function in avocado/utils/network/ports.py is completely flawed. Seems like it is always trying all protocols and families instead of returning on the first working combination - and AF_INET6 is not configured on my system, so it is finally running into an error.

So please try to disable all inet6 addresses on your system, I think you should then be able to reproduce this issue.

I can make it work here with the following patch:

```
diff --git a/avocado/utils/network/ports.py b/avocado/utils/network/ports.py
--- a/avocado/utils/network/ports.py
+++ b/avocado/utils/network/ports.py
@@ -60,7 +60,7 @@ def is_port_free(port, address):
                     if localhost:
                         return False
                 sock.close()
-        return True
+                return True
     finally:
         if sock is not None:
             sock.close()
```

... not sure whether that's a real good solution here, though ... my Python-foo is really poor, but aren't there some more issues in this function? Is the socket really closed in all cases (i.e. also the spots where the code "returns False")?
By the way, I wonder what happens if you only have IPv6 addresses on your system, but no IPv4 addresses at all? ... I guess it's also completely failing in that case...
As mentioned on the QEMU list, this whole problem space would be dramatically simplified if the code for finding free ports was entirely deleted, and the callers changed to rely on the kernel auto-allocating a free port. The issue with IPv6 would simply go away, as would the inherent race conditions in this code when running  tests in parallel.
Another issue: the code in is_port_free() seems to assume that UDP and TCP share the same port number space. That's complete non-sense, if a TCP port is already in use, you can still open the UDP port with the same number, and vice versa. I think all the related code (especially the unit test in selftests/unit/test_utils_network.py ) need a major rewrite ... :-(
Thanks @huth for the detailed description. I understand the problem now and regardless of approach on QEMU side, indeed deserves a rewrite here.

Resolving patch:
diff --git a/avocado/utils/network/ports.py b/avocado/utils/network/ports.py
--- a/avocado/utils/network/ports.py
+++ b/avocado/utils/network/ports.py
@@ -18,6 +18,7 @@
 
 import random
 import socket
+import warnings
 
 from avocado.utils.data_structures import Borg
 
@@ -27,46 +28,46 @@
 PROTOCOLS = (socket.SOCK_STREAM, socket.SOCK_DGRAM)
 
 
-def is_port_free(port, address):
+def is_port_available(port, address, family=socket.AF_INET,
+                      protocol=socket.SOCK_STREAM):
+    """Return True if the given port is available for use.
+
+    :param port: Port value to check.
+    :type port: int
+    :param address: Address to use this port.
+    :type address: str
+    :param family: Default is socket.AF_INET. Accepted values are:
+                   socket.AF_INET or socket.AF_INET6.
+    :type type: socket.AddressFamily.AF_*
+    :param protocol: Protocol type. Default is socket.SOCK_STREAM (TCP).
+                     Accepted values are: socket.SOCK_STREAM or
+                     socket.SOCK_DGRAM.
+    :type type: socket.AddressFamily.SOCK_*
     """
-    Return True if the given port is available for use.
+    try:
+        with socket.socket(family, protocol) as sock:
+            sock.bind((address, port))
+    except PermissionError:
+        # Permission denied, can't be sure
+        return False
+    except OSError:
+        # Address already in use or cannot assign requested address
+        return False
+    return True
 
-    Currently we only check for TCP/UDP connections on IPv4/6
 
-    :param port: Port number
-    :param address: Socket address to bind or connect
+def is_port_free(port, address):
     """
-    if address == "localhost" or not address:
-        localhost = True
-        protocols = PROTOCOLS
-    else:
-        localhost = False
-        # sock.connect always connects for UDP
-        protocols = (socket.SOCK_STREAM, )
-    sock = None
-    try:
-        for family in FAMILIES:
-            for protocol in protocols:
-                try:
-                    sock = socket.socket(family, protocol)
-                    if localhost:
-                        sock.bind(("", port))
-                    else:
-                        sock.connect((address, port))
-                        return False
-                except socket.error as exc:
-                    if exc.errno in (93, 94):   # Unsupported combinations
-                        continue
-                    if localhost:
-                        return False
-                sock.close()
-        return True
-    finally:
-        if sock is not None:
-            sock.close()
-
-
-def find_free_port(start_port=1024, end_port=65535, address="localhost", sequent=False):
+    This method is deprecated. Please use is_port_available().
+    """
+    warnings.warn("deprecated, use is_port_available() instead.",
+                  DeprecationWarning)
+    return is_port_available(port, address)
+
+
+def find_free_port(start_port=1024, end_port=65535, address="localhost",
+                   sequent=False, family=socket.AF_INET,
+                   protocol=socket.SOCK_STREAM):
     """
     Return a host free port in the range [start_port, end_port].
 
@@ -74,15 +75,25 @@ def find_free_port(start_port=1024, end_port=65535, address="localhost", sequent
     :param end_port: ender of candidate port range, defaults to 65535
     :param address: Socket address to bind or connect
     :param sequent: Find port sequentially, random order if it's False
+    :param family: Default is socket.AF_INET. Accepted values are:
+                   socket.AF_INET or socket.AF_INET6.
+    :type family: socket.AddressFamily.AF_*
+    :param protocol: Protocol type. Default is socket.SOCK_STREAM (TCP).
+                     Accepted values are: socket.SOCK_STREAM or
+                     socket.SOCK_DGRAM.
+    :type protocol: socket.AddressFamily.SOCK_*
     :rtype: int or None if no free port found
     """
-    ports = find_free_ports(start_port, end_port, 1, address, sequent)
+    ports = find_free_ports(start_port, end_port, 1, address, sequent,
+                            family, protocol)
     if ports:
         return ports[0]
     return None
 
 
-def find_free_ports(start_port, end_port, count, address="localhost", sequent=False):
+def find_free_ports(start_port, end_port, count, address="localhost",
+                    sequent=False, family=socket.AF_INET,
+                    protocol=socket.SOCK_STREAM):
     """
     Return count of host free ports in the range [start_port, end_port].
 
@@ -91,6 +102,13 @@ def find_free_ports(start_port, end_port, count, address="localhost", sequent=Fa
     :param count: Initial number of ports known to be free in the range.
     :param address: Socket address to bind or connect
     :param sequent: Find port sequentially, random order if it's False
+    :param family: Default is socket.AF_INET. Accepted values are:
+                   socket.AF_INET or socket.AF_INET6.
+    :type family: socket.AddressFamily.AF_*
+    :param protocol: Protocol type. Default is socket.SOCK_STREAM (TCP).
+                     Accepted values are: socket.SOCK_STREAM or
+                     socket.SOCK_DGRAM.
+    :type protocol: socket.AddressFamily.SOCK_*
     """
     ports = []
 
@@ -98,7 +116,7 @@ def find_free_ports(start_port, end_port, count, address="localhost", sequent=Fa
     if not sequent:
         random.shuffle(port_range)
     for i in port_range:
-        if is_port_free(i, address):
+        if is_port_available(i, address, family, protocol):
             ports.append(i)
         if len(ports) >= count:
             break


Test patch:
diff --git a/selftests/unit/utils/test_network.py b/selftests/unit/utils/test_network.py
--- a/selftests/unit/utils/test_network.py
+++ b/selftests/unit/utils/test_network.py
@@ -1,4 +1,3 @@
-import socket
 import unittest.mock
 
 from avocado.utils.network import ports
@@ -56,45 +55,18 @@ def get_all_local_addrs():
 
 class FreePort(unittest.TestCase):
 
-    @unittest.skipUnless(HAS_NETIFACES,
-                         "netifaces library not available")
-    def test_is_port_free(self):
+    def test_is_port_available(self):
         port = ports.find_free_port(sequent=False)
-        self.assertTrue(ports.is_port_free(port, "localhost"))
-        ipv4_addrs, ipv6_addrs = get_all_local_addrs()
-        good = []
-        bad = []
-        skip = []
-        sock = None
-        for family in ports.FAMILIES:
-            if family == socket.AF_INET:
-                addrs = ipv4_addrs
-            else:
-                addrs = ipv6_addrs
-            for addr in addrs:
-                for protocol in ports.PROTOCOLS:
-                    try:
-                        sock = socket.socket(family, protocol)
-                        sock.bind((addr, port))
-                        if ports.is_port_free(port, "localhost"):
-                            bad.append(f"{family}, {protocol}, {addr}: "
-                                       f"reports free")
-                        else:
-                            good.append(f"{family}, {protocol}, {addr}")
-                    except Exception as exc:
-                        if getattr(exc, 'errno', None) in (-2, 2, 22, 94):
-                            skip.append(f"{family}, {protocol}, {addr}: "
-                                        f"Not supported: {exc}")
-                        else:
-                            bad.append(f"{family}, {protocol}, {addr}: "
-                                       f"Failed to bind: {exc}")
-                    finally:
-                        if sock is not None:
-                            sock.close()
-        self.assertFalse(bad, "Following combinations failed:\n%s\n\n"
-                         "Following combinations passed:\n%s\n\n"
-                         "Following combinations were skipped:\n%s"
-                         % ("\n".join(bad), "\n".join(good), "\n".join(skip)))
+        result = ports.is_port_available(port, 'localhost')
+        self.assertTrue(result)
+
+    def test_find_free_port(self):
+        port = ports.find_free_port(sequent=False)
+        self.assertEqual(type(port), int)
+
+    def test_find_free_ports(self):
+        port = ports.find_free_ports(1000, 2000, 10)
+        self.assertEqual(type(port), list)
 
 
 if __name__ == "__main__":