From cc454708f0d76ae4a05d1658ac17e81007dc029a Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 10:03:31 -0400 Subject: [PATCH 1/4] fix: Poll for Connect's port until the timeout instead of checking once is_port_open() made a single TCP connect right after the container started. On Docker Desktop the published port can take a fraction of a second to bind, so the connect was refused instantly and with-connect reported "did not start within 60 seconds" in milliseconds. Retry the connection every 0.25 seconds until the deadline. While waiting, check the container status and stop early if it has exited, reporting that the container exited instead of a timeout. Fixes #57. Co-Authored-By: Claude Opus 5.5 --- main.py | 44 ++++++++++++++++++++++++++++++++++---------- test_main.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index 22ffbd2..4879cfc 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,40 @@ 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): + if time.monotonic() + poll_interval >= deadline: + return False + time.sleep(poll_interval) def extract_server_version(logs: str) -> str | None: diff --git a/test_main.py b/test_main.py index 41525dc..1a42d70 100644 --- a/test_main.py +++ b/test_main.py @@ -88,6 +88,46 @@ def test_valid_license_http_server_starts(): mock_container.stop.assert_not_called() +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_get_docker_tag_latest(): assert main.get_docker_tag("latest") == ("ghcr.io/posit-dev/connect", "latest") @@ -428,6 +468,12 @@ def test_post_bootstrap_raises_on_other_errors(): test_valid_license_http_server_starts() print("✓ test_valid_license_http_server_starts 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_image_and_version_exclusive() print("✓ test_image_and_version_exclusive passed") From e1b31b6f5f95db63fb2bf909d4676970a3fee0f5 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 10:05:25 -0400 Subject: [PATCH 2/4] fix: Keep polling for the port through the final partial interval Co-Authored-By: Claude Opus 5.5 --- main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 4879cfc..137a037 100644 --- a/main.py +++ b/main.py @@ -455,9 +455,10 @@ def is_port_open( with socket.create_connection((host, port), timeout=max(remaining, 0.1)): return True except (socket.timeout, ConnectionRefusedError, OSError): - if time.monotonic() + poll_interval >= deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: return False - time.sleep(poll_interval) + time.sleep(min(poll_interval, remaining)) def extract_server_version(logs: str) -> str | None: From 31b50e9269497e1e7494963486bc50cacc467311 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 10:08:20 -0400 Subject: [PATCH 3/4] test: Move is_port_open tests next to the other helper tests Avoids a merge conflict with #72, which adds tests after test_valid_license_http_server_starts. Co-Authored-By: Claude Opus 5.5 --- test_main.py | 112 +++++++++++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/test_main.py b/test_main.py index 1a42d70..68e9a0b 100644 --- a/test_main.py +++ b/test_main.py @@ -88,46 +88,6 @@ def test_valid_license_http_server_starts(): mock_container.stop.assert_not_called() -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_get_docker_tag_latest(): assert main.get_docker_tag("latest") == ("ghcr.io/posit-dev/connect", "latest") @@ -210,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" @@ -468,22 +468,6 @@ def test_post_bootstrap_raises_on_other_errors(): test_valid_license_http_server_starts() print("✓ test_valid_license_http_server_starts 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_image_and_version_exclusive() - print("✓ test_image_and_version_exclusive passed") - - test_image_without_tag() - print("✓ test_image_without_tag passed") - - test_image_with_tag() - print("✓ test_image_with_tag passed") - test_get_docker_tag_latest() print("✓ test_get_docker_tag_latest passed") @@ -516,6 +500,22 @@ 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_image_and_version_exclusive() + print("✓ test_image_and_version_exclusive passed") + + test_image_without_tag() + print("✓ test_image_without_tag passed") + + test_image_with_tag() + print("✓ test_image_with_tag passed") + test_local_image_usage() print("✓ test_local_image_usage passed") From b80e3ba05ff20ce7bbf0e093b8e0e37e117aaef6 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 10:08:55 -0400 Subject: [PATCH 4/4] test: Restore registrations that the previous commit moved by mistake Co-Authored-By: Claude Opus 5.5 --- test_main.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/test_main.py b/test_main.py index 68e9a0b..a3f4529 100644 --- a/test_main.py +++ b/test_main.py @@ -468,6 +468,16 @@ def test_post_bootstrap_raises_on_other_errors(): test_valid_license_http_server_starts() print("✓ test_valid_license_http_server_starts passed") + + test_image_and_version_exclusive() + print("✓ test_image_and_version_exclusive passed") + + test_image_without_tag() + print("✓ test_image_without_tag passed") + + test_image_with_tag() + print("✓ test_image_with_tag passed") + test_get_docker_tag_latest() print("✓ test_get_docker_tag_latest passed") @@ -500,23 +510,16 @@ 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_image_and_version_exclusive() - print("✓ test_image_and_version_exclusive passed") - - test_image_without_tag() - print("✓ test_image_without_tag passed") - - test_image_with_tag() - print("✓ test_image_with_tag passed") - - test_local_image_usage() print("✓ test_local_image_usage passed")