diff --git a/main.py b/main.py index 22ffbd2..137a037 100644 --- a/main.py +++ b/main.py @@ -379,9 +379,13 @@ def main() -> int: try: print(f"Waiting for port {args.port} to open...", file=sys.stderr) - if not is_port_open("localhost", args.port, timeout=60.0): + if not is_port_open("localhost", args.port, timeout=60.0, container=container): print("\nContainer logs:", file=sys.stderr) print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) + if container.status in ("exited", "dead"): + raise RuntimeError( + f"Posit Connect container exited before port {args.port} opened." + ) raise RuntimeError("Posit Connect did not start within 60 seconds.") print("Waiting for HTTP server to start...", file=sys.stderr) @@ -420,20 +424,41 @@ def main() -> int: container.stop() -def is_port_open(host: str, port: int, timeout: float = 30.0) -> bool: +def is_port_open( + host: str, + port: int, + timeout: float = 30.0, + poll_interval: float = 0.25, + container=None, +) -> bool: """ - Check if a TCP port on a given host is open. + Wait until a TCP port on a given host accepts connections. + + Retries until the timeout because a published Docker port may not be + bound yet right after the container starts. :param host: Hostname or IP address to check. :param port: Port number to check. - :param timeout: Timeout in seconds (default 3.0). - :return: True if the port is open (accepting connections), False otherwise. + :param timeout: Maximum seconds to wait (default 30). + :param poll_interval: Seconds between connection attempts (default 0.25). + :param container: Optional docker container; stop waiting if it exits. + :return: True if the port opened before the timeout, False otherwise. """ - try: - with socket.create_connection((host, port), timeout=timeout): - return True - except (socket.timeout, ConnectionRefusedError, OSError): - return False + deadline = time.monotonic() + timeout + while True: + if container is not None: + container.reload() + if container.status in ("exited", "dead"): + return False + remaining = deadline - time.monotonic() + try: + with socket.create_connection((host, port), timeout=max(remaining, 0.1)): + return True + except (socket.timeout, ConnectionRefusedError, OSError): + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(poll_interval, remaining)) def extract_server_version(logs: str) -> str | None: diff --git a/test_main.py b/test_main.py index 41525dc..a3f4529 100644 --- a/test_main.py +++ b/test_main.py @@ -170,6 +170,46 @@ def test_extract_server_version_dev(): assert main.extract_server_version(logs) == "2025.11.0-dev+29-gd0db52662c" +def test_is_port_open_retries_until_port_opens(): + # Docker Desktop refuses connections briefly after the container starts + refused = ConnectionRefusedError("Connection refused") + with patch.object( + main.socket, "create_connection", side_effect=[refused, refused, MagicMock()] + ) as create_connection: + result = main.is_port_open("localhost", 3939, timeout=5.0, poll_interval=0.01) + + assert result is True + assert create_connection.call_count == 3 + + +def test_is_port_open_times_out_on_closed_port(): + # Bind and close a socket to get a port that nothing is listening on + with main.socket.socket() as s: + s.bind(("localhost", 0)) + port = s.getsockname()[1] + + start = main.time.monotonic() + result = main.is_port_open("localhost", port, timeout=0.5, poll_interval=0.05) + elapsed = main.time.monotonic() - start + + assert result is False + assert 0.4 <= elapsed < 5.0 + + +def test_is_port_open_stops_when_container_exits(): + mock_container = MagicMock() + mock_container.status = "exited" + + with patch.object(main.socket, "create_connection") as create_connection: + result = main.is_port_open( + "localhost", 3939, timeout=30.0, container=mock_container + ) + + assert result is False + mock_container.reload.assert_called_once() + create_connection.assert_not_called() + + def test_local_image_usage(): mock_args = Mock() mock_args.version = "2024.08.0" @@ -471,6 +511,15 @@ def test_post_bootstrap_raises_on_other_errors(): test_extract_server_version_dev() print("✓ test_extract_server_version_dev passed") + test_is_port_open_retries_until_port_opens() + print("✓ test_is_port_open_retries_until_port_opens passed") + + test_is_port_open_times_out_on_closed_port() + print("✓ test_is_port_open_times_out_on_closed_port passed") + + test_is_port_open_stops_when_container_exits() + print("✓ test_is_port_open_stops_when_container_exits passed") + test_local_image_usage() print("✓ test_local_image_usage passed")