Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")

Expand Down
Loading