Repository: jaraco/portend

Issue:
Race condition in `free` and `occupied`
In `portend.free`, if the full duration of the timeout elapses between [L112](https://github.com/jaraco/portend/blob/077939464de072bb0caf1d88ac607381e012779e/portend.py#L112) and [L114](https://github.com/jaraco/portend/blob/077939464de072bb0caf1d88ac607381e012779e/portend.py#L114), a `Timeout` error will be thrown even if the port has been free the whole time. This can happen when debugging; you can reproduce it as follows on Python 3.7:

```bash
python -m pdb -c 'break portend.py:114' -c 'continue' -c 'import time; time.sleep(1.0)' -c 'continue' -c 'quit' -m portend --timeout=0.5 localhost:12345 free
```

I think I might have also observed it under other circumstances, but don't have a minimal repro to show at the moment. `portend.occupied` has an analogous problem.

Comments:

Patch:
diff --git a/portend.py b/portend.py
index b29cf84..10201e4 100644
--- a/portend.py
+++ b/portend.py
@@ -111,17 +111,17 @@ def free(host, port, timeout=float('Inf')):
 
 	timer = timing.Timer(timeout)
 
-	while not timer.expired():
+	while True:
 		try:
 			# Expect a free port, so use a small timeout
 			Checker(timeout=0.1).assert_free(host, port)
 			return
 		except PortNotFree:
+			if timer.expired():
+				raise Timeout("Port {port} not free on {host}.".format(**locals()))
 			# Politely wait.
 			time.sleep(0.1)
 
-	raise Timeout("Port {port} not free on {host}.".format(**locals()))
-
 
 wait_for_free_port = free
 
@@ -145,17 +145,17 @@ def occupied(host, port, timeout=float('Inf')):
 
 	timer = timing.Timer(timeout)
 
-	while not timer.expired():
+	while True:
 		try:
 			Checker(timeout=.5).assert_free(host, port)
+			if timer.expired():
+				raise Timeout("Port {port} not bound on {host}.".format(**locals()))
 			# Politely wait
 			time.sleep(0.1)
 		except PortNotFree:
 			# port is occupied
 			return
 
-	raise Timeout("Port {port} not bound on {host}.".format(**locals()))
-
 
 wait_for_occupied_port = occupied
 
diff --git a/setup.cfg b/setup.cfg
index 2beab7b..467b48a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -30,6 +30,7 @@ testing =
 	pytest >= 3.5, !=3.7.3
 	pytest-checkdocs
 	pytest-flake8
+	pytest-mock
 
 	# local
 


Test patch:
diff --git a/test_portend.py b/test_portend.py
index 14706b9..6ce392f 100644
--- a/test_portend.py
+++ b/test_portend.py
@@ -2,6 +2,7 @@ import socket
 import contextlib
 
 import pytest
+from tempora import timing
 
 import portend
 
@@ -46,6 +47,11 @@ def nonlistening_addr(request):
 	return sa
 
 
+@pytest.fixture
+def immediate_timeout(mocker):
+	mocker.patch.object(timing.Timer, 'expired').return_value(True)
+
+
 class TestChecker:
 	def test_check_port_listening(self, listening_addr):
 		with pytest.raises(portend.PortNotFree):
@@ -53,3 +59,13 @@ class TestChecker:
 
 	def test_check_port_nonlistening(self, nonlistening_addr):
 		portend.Checker().assert_free(nonlistening_addr)
+
+	def test_free_with_immediate_timeout(
+		self, nonlistening_addr, immediate_timeout):
+		host, port = nonlistening_addr[:2]
+		portend.free(host, port, timeout=1.0)
+
+	def test_occupied_with_immediate_timeout(
+		self, listening_addr, immediate_timeout):
+		host, port = listening_addr[:2]
+		portend.occupied(host, port, timeout=1.0)