From 09086ff117d4382044106a677dde2c302301c431 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 14:41:42 -0400 Subject: [PATCH 1/2] feat: Use a free port when 3939 is in use and --port isn't given An explicit --port (or the action's port input) is used as is. Otherwise with-connect uses 3939, or lets Docker assign a free port if 3939 is taken, so several Connect instances can run at once. CONNECT_SERVER has the port. Fixes #30. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- action.yml | 8 ++-- main.py | 109 ++++++++++++++++++++++++++++++++++++------- test_main.py | 127 +++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 221 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index b2ddd03..3af376e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Without `bash -c`, the environment variables would be evaluated before `with-con | `--license` | `./rstudio-connect.lic` | Path to license file. This file must exist and be a valid Connect license. | | `--image` | | Container image to use, including tag (e.g., `posit/connect:2025.12.0`). Overrides `--version`. | | `--config` | | Path to optional rstudio-connect.gcfg configuration file | -| `--port` | `3939` | Port to map the Connect container to. Allows running multiple Connect instances simultaneously. | +| `--port` | `3939` | Port to map the Connect container to. If not given and 3939 is in use, a free port is chosen, so multiple Connect instances can run at once. `CONNECT_SERVER` has the port in use. | | `-e`, `--env` | | Environment variables to pass to the Docker container (format: KEY=VALUE). Can be specified multiple times. | | `--stop` | | Stop a running Connect container by ID, or use `CONTAINER_ID` env var if not specified. | @@ -130,7 +130,7 @@ The GitHub Action supports the following inputs: | `version` | No | `release` | Posit Connect version | | `image` | No | | Container image to use, including tag (e.g., `posit/connect:2025.12.0`). Overrides `version`. | | `config-file` | No | | Path to rstudio-connect.gcfg configuration file | -| `port` | No | `3939` | Port to map the Connect container to | +| `port` | No | `3939` | Port to map the Connect container to. If not given and 3939 is in use, a free port is chosen. The `CONNECT_SERVER` output has the port in use. | | `quiet` | No | `false` | Suppress image pull progress and the Connect logs shown while Connect starts | | `env` | No | | Environment variables to pass to Docker container (one per line, format: KEY=VALUE) | | `command` | No | | Command to run against Connect (omit for start-only mode) | diff --git a/action.yml b/action.yml index 0152a14..3d80d38 100644 --- a/action.yml +++ b/action.yml @@ -19,9 +19,8 @@ inputs: required: false default: 'false' port: - description: 'Port to map the Connect container to' + description: 'Port to map the Connect container to (default: 3939, or a free port if 3939 is in use)' required: false - default: '3939' env: description: 'Environment variables to pass to Docker container (one per line, format: KEY=VALUE)' required: false @@ -68,7 +67,10 @@ runs: fi # Build arguments - ARGS="--version ${{ inputs.version }} --port ${{ inputs.port }}" + ARGS="--version ${{ inputs.version }}" + if [ -n "${{ inputs.port }}" ]; then + ARGS="$ARGS --port ${{ inputs.port }}" + fi if [ -n "${{ inputs.config-file }}" ]; then ARGS="$ARGS --config ${{ inputs.config-file }}" fi diff --git a/main.py b/main.py index 6656cbc..895a634 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ PREVIEW_IMAGE = "ghcr.io/posit-dev/connect-preview" LEGACY_IMAGE = "rstudio/rstudio-connect" VERSION = "release" +DEFAULT_PORT = 3939 # Versions at or after this registry cutover land on ghcr.io/posit-dev. # Older versions only exist on the legacy Docker Hub images. @@ -104,8 +105,7 @@ def parse_args(): parser.add_argument( "--port", type=port_number, - default=3939, - help="Port to map the Connect container to (default: 3939)", + help=f"Port to map the Connect container to (default: {DEFAULT_PORT}, or a free port if {DEFAULT_PORT} is in use)", ) parser.add_argument( "--stop", @@ -430,6 +430,10 @@ def run() -> int: ensure_image(client, base_image, tag, args.version, args.quiet) + # Choose the port after pulling, which can take a while, so it is less + # likely to be taken by the time the container starts + host_port = choose_port(args.port) + mounts = [ docker.types.services.Mount( type="bind", @@ -456,33 +460,34 @@ def run() -> int: "tty": True, "stdin_open": True, "privileged": True, - "ports": {"3939/tcp": args.port}, + "ports": {"3939/tcp": host_port}, "mounts": mounts, "environment": container_env, } if _force_amd64(base_image): create_kwargs["platform"] = "linux/amd64" - # Create and start separately so a container that fails to start - # can be removed instead of left behind - try: - container = client.containers.create(**create_kwargs) - except Exception as e: - raise RuntimeError(f"Failed to create container from {image_name}: {e}") try: - container.start() - except Exception as e: - try: - container.remove(force=True) - except Exception: - pass # Don't hide the start error behind a cleanup error - raise RuntimeError(f"Failed to start container from {image_name}: {e}") + container = start_container(client, create_kwargs, image_name) + except PortInUseError: + if args.port is not None or host_port is None: + raise + # Another process took the default port after we checked it + create_kwargs["ports"] = {"3939/tcp": None} + host_port = None + container = start_container(client, create_kwargs, image_name) - server_url = f"http://localhost:{args.port}" stop_container = True # Echo Connect's startup logs to stderr (stdout is reserved for KEY=value lines) startup_logs = ContainerLogs(container, quiet=args.quiet) try: + if host_port is None: + args.port = published_port(container) + print(f"Port {DEFAULT_PORT} is in use. Using port {args.port}.", file=sys.stderr) + else: + args.port = host_port + server_url = f"http://localhost:{args.port}" + print(f"Waiting for port {args.port} to open...", file=sys.stderr) if not is_port_open("localhost", args.port, timeout=60.0, container=container): startup_logs.dump() @@ -580,6 +585,76 @@ def is_port_open( time.sleep(min(poll_interval, remaining)) +def is_port_free(port: int) -> bool: + """ + Check if a host port is free by trying to bind to it on all interfaces, + which is how Docker publishes container ports. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("", port)) + return True + except OSError: + return False + + +def choose_port(requested: int | None) -> int | None: + """ + Choose the host port for Connect. + + An explicit --port is used as is, so Docker reports an error if it is + taken. Otherwise use DEFAULT_PORT if it's free, or None to have Docker + assign a free port when the container starts. + """ + if requested is not None: + return requested + if is_port_free(DEFAULT_PORT): + return DEFAULT_PORT + return None + + +class PortInUseError(RuntimeError): + """The container couldn't start because its host port is taken.""" + + +def start_container(client, create_kwargs: dict, image_name: str): + """ + Create and start a container, removing it if it fails to start. + + Creating and starting separately means a container that fails to start + can be removed instead of left behind. Raises PortInUseError if the + host port is taken, and RuntimeError for other failures. + """ + try: + container = client.containers.create(**create_kwargs) + except Exception as e: + raise RuntimeError(f"Failed to create container from {image_name}: {e}") + try: + container.start() + except Exception as e: + try: + container.remove(force=True) + except Exception: + pass # Don't hide the start error behind a cleanup error + message = f"Failed to start container from {image_name}: {e}" + # Docker's wording when another container or process has the port + if "port is already allocated" in str(e) or "address already in use" in str(e): + raise PortInUseError(message) + raise RuntimeError(message) + return container + + +def published_port(container) -> int: + """ + Return the host port Docker assigned to the container's port 3939. + """ + container.reload() + bindings = container.ports.get("3939/tcp") or [] + if not bindings: + raise RuntimeError("Docker did not assign a host port to the container") + return int(bindings[0]["HostPort"]) + + def extract_server_version(logs: str) -> str | None: """ Extract the Posit Connect version from container logs. diff --git a/test_main.py b/test_main.py index b998f83..f79a06f 100644 --- a/test_main.py +++ b/test_main.py @@ -4,6 +4,7 @@ import io import json import os +import socket import urllib.error import subprocess import sys @@ -485,6 +486,35 @@ def test_port_range_limits_are_accepted(): assert parse_with_argv("--port", "65535").port == 65535 +def test_port_defaults_to_none(): + assert parse_with_argv().port is None + + +def test_is_port_free(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + s.listen() + port = s.getsockname()[1] + assert main.is_port_free(port) is False + assert main.is_port_free(port) is True + + +def test_choose_port_uses_explicit_port(): + # An explicit port is used even if it's taken, so Docker reports the error + with patch.object(main, "is_port_free", return_value=False): + assert main.choose_port(4000) == 4000 + + +def test_choose_port_uses_default_when_free(): + with patch.object(main, "is_port_free", return_value=True): + assert main.choose_port(None) == main.DEFAULT_PORT + + +def test_choose_port_leaves_it_to_docker_when_default_taken(): + with patch.object(main, "is_port_free", return_value=False): + assert main.choose_port(None) is None + + def test_help_explains_how_to_stop(): code, output = parse_exit("--help") help_text = " ".join(output.split()) @@ -731,14 +761,15 @@ def test_post_bootstrap_raises_on_other_errors(): -def run_with_client(client): +def run_with_client(client, *args, port_free=True): """Run run() against a mock Docker client, expecting a RuntimeError.""" with tempfile.NamedTemporaryFile(suffix=".lic") as license_file: - argv = ["with-connect", "--license", license_file.name, "--quiet"] + argv = ["with-connect", "--license", license_file.name, "--quiet", *args] with ( patch.object(sys, "argv", argv), patch.object(main.docker, "from_env", return_value=client), patch.object(main, "ensure_image"), + patch.object(main, "is_port_free", return_value=port_free), ): try: main.run() @@ -763,7 +794,7 @@ def test_container_create_error_is_reported(): def test_container_start_error_removes_container(): - error = docker.errors.APIError("500 Server Error: port is already allocated") + error = docker.errors.APIError("500 Server Error: OCI runtime create failed") client = MagicMock() container = client.containers.create.return_value container.start.side_effect = error @@ -778,12 +809,12 @@ def test_container_start_error_removes_container(): def test_container_start_error_survives_failed_cleanup(): client = MagicMock() container = client.containers.create.return_value - container.start.side_effect = docker.errors.APIError("port is already allocated") + container.start.side_effect = docker.errors.APIError("OCI runtime create failed") container.remove.side_effect = docker.errors.APIError("removal failed") message = run_with_client(client) - assert "port is already allocated" in message + assert "OCI runtime create failed" in message def test_stop_error_is_reported(): @@ -815,6 +846,65 @@ def test_cleanup_stop_error_does_not_hide_original_error(): assert "did not start within 60 seconds" in message assert "Warning: failed to stop container abc123: daemon went away" in stderr.getvalue() +def port_conflict_client(): + """A mock Docker client whose first container hits a port conflict.""" + taken = MagicMock() + taken.start.side_effect = docker.errors.APIError( + "Bind for 0.0.0.0:3939 failed: port is already allocated" + ) + started = MagicMock() + started.ports = {"3939/tcp": [{"HostIp": "0.0.0.0", "HostPort": "50000"}]} + started.logs.return_value = b"" + client = MagicMock() + client.containers.create.side_effect = [taken, started] + return client, taken, started + + +def test_port_taken_after_check_retries_with_docker_assigned_port(): + client, taken, started = port_conflict_client() + stderr = io.StringIO() + # Stop after the port is chosen by failing the wait for Connect + with patch.object(main, "is_port_open", return_value=False), redirect_stderr(stderr): + message = run_with_client(client) + + assert "did not start within 60 seconds" in message + taken.remove.assert_called_once_with(force=True) + ports = [c.kwargs["ports"] for c in client.containers.create.call_args_list] + assert ports == [{"3939/tcp": 3939}, {"3939/tcp": None}] + assert "Port 3939 is in use. Using port 50000." in stderr.getvalue() + assert "Waiting for port 50000 to open" in stderr.getvalue() + + +def test_default_port_taken_uses_docker_assigned_port(): + client, _, started = port_conflict_client() + client.containers.create.side_effect = [started] + stderr = io.StringIO() + with patch.object(main, "is_port_open", return_value=False), redirect_stderr(stderr): + run_with_client(client, port_free=False) + + assert client.containers.create.call_args.kwargs["ports"] == {"3939/tcp": None} + assert "Using port 50000." in stderr.getvalue() + + +def test_explicit_port_taken_is_reported_without_retry(): + client, taken, _ = port_conflict_client() + + message = run_with_client(client, "--port", "3939") + + assert "port is already allocated" in message + assert client.containers.create.call_count == 1 + taken.remove.assert_called_once_with(force=True) + + +def test_published_port_without_binding_is_an_error(): + container = MagicMock() + container.ports = {} + try: + main.published_port(container) + assert False, "Expected RuntimeError" + except RuntimeError as e: + assert "did not assign a host port" in str(e) + def test_docker_unreachable_is_reported(): error = docker.errors.DockerException("Error while fetching server API version") with patch.object(main.docker, "from_env", side_effect=error): @@ -960,6 +1050,21 @@ def test_installed_command_uses_main(): test_port_range_limits_are_accepted() print("✓ test_port_range_limits_are_accepted passed") + test_port_defaults_to_none() + print("✓ test_port_defaults_to_none passed") + + test_is_port_free() + print("✓ test_is_port_free passed") + + test_choose_port_uses_explicit_port() + print("✓ test_choose_port_uses_explicit_port passed") + + test_choose_port_uses_default_when_free() + print("✓ test_choose_port_uses_default_when_free passed") + + test_choose_port_leaves_it_to_docker_when_default_taken() + print("✓ test_choose_port_leaves_it_to_docker_when_default_taken passed") + test_help_explains_how_to_stop() print("✓ test_help_explains_how_to_stop passed") @@ -1032,6 +1137,18 @@ def test_installed_command_uses_main(): test_cleanup_stop_error_does_not_hide_original_error() print("✓ test_cleanup_stop_error_does_not_hide_original_error passed") + test_port_taken_after_check_retries_with_docker_assigned_port() + print("✓ test_port_taken_after_check_retries_with_docker_assigned_port passed") + + test_default_port_taken_uses_docker_assigned_port() + print("✓ test_default_port_taken_uses_docker_assigned_port passed") + + test_explicit_port_taken_is_reported_without_retry() + print("✓ test_explicit_port_taken_is_reported_without_retry passed") + + test_published_port_without_binding_is_an_error() + print("✓ test_published_port_without_binding_is_an_error passed") + test_docker_unreachable_is_reported() print("✓ test_docker_unreachable_is_reported passed") From 7d663ade93cd4bac4c45441fb6a1e94d31dc54d5 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 14:44:03 -0400 Subject: [PATCH 2/2] refactor: Simplify port selection by dropping the start-time retry Without --port, use 3939 if it's free, and otherwise have Docker assign a free port. This drops choose_port, PortInUseError and the retry after a failed start. Losing the race between the check and the start gives Docker's "port is already allocated" error, which is rare and clear. The check before starting stays: Docker Desktop doesn't report a port held by a host process, so Connect would start and with-connect would talk to the other process. Co-Authored-By: Claude Opus 5.5 --- main.py | 96 +++++++++++++++------------------------------------- test_main.py | 95 +++++++++++++++------------------------------------ 2 files changed, 56 insertions(+), 135 deletions(-) diff --git a/main.py b/main.py index 895a634..9bbb31c 100644 --- a/main.py +++ b/main.py @@ -430,10 +430,6 @@ def run() -> int: ensure_image(client, base_image, tag, args.version, args.quiet) - # Choose the port after pulling, which can take a while, so it is less - # likely to be taken by the time the container starts - host_port = choose_port(args.port) - mounts = [ docker.types.services.Mount( type="bind", @@ -454,46 +450,53 @@ def run() -> int: ) container_env = build_container_env(bootstrap_secret, args.env_vars) + port = args.port + if port is None and is_port_free(DEFAULT_PORT): + port = DEFAULT_PORT create_kwargs = { "image": image_name, "tty": True, "stdin_open": True, "privileged": True, - "ports": {"3939/tcp": host_port}, + # None has Docker assign a free host port + "ports": {"3939/tcp": port}, "mounts": mounts, "environment": container_env, } if _force_amd64(base_image): create_kwargs["platform"] = "linux/amd64" + # Create and start separately so a container that fails to start + # can be removed instead of left behind + try: + container = client.containers.create(**create_kwargs) + except Exception as e: + raise RuntimeError(f"Failed to create container from {image_name}: {e}") try: - container = start_container(client, create_kwargs, image_name) - except PortInUseError: - if args.port is not None or host_port is None: - raise - # Another process took the default port after we checked it - create_kwargs["ports"] = {"3939/tcp": None} - host_port = None - container = start_container(client, create_kwargs, image_name) + container.start() + except Exception as e: + try: + container.remove(force=True) + except Exception: + pass # Don't hide the start error behind a cleanup error + raise RuntimeError(f"Failed to start container from {image_name}: {e}") stop_container = True # Echo Connect's startup logs to stderr (stdout is reserved for KEY=value lines) startup_logs = ContainerLogs(container, quiet=args.quiet) try: - if host_port is None: - args.port = published_port(container) - print(f"Port {DEFAULT_PORT} is in use. Using port {args.port}.", file=sys.stderr) - else: - args.port = host_port - server_url = f"http://localhost:{args.port}" + if port is None: + port = published_port(container) + print(f"Port {DEFAULT_PORT} is in use. Using port {port}.", file=sys.stderr) + server_url = f"http://localhost:{port}" - print(f"Waiting for port {args.port} to open...", file=sys.stderr) - if not is_port_open("localhost", args.port, timeout=60.0, container=container): + print(f"Waiting for port {port} to open...", file=sys.stderr) + if not is_port_open("localhost", port, timeout=60.0, container=container): startup_logs.dump() if container.status in ("exited", "dead"): raise RuntimeError( - f"Posit Connect container exited before port {args.port} opened." + f"Posit Connect container exited before port {port} opened." ) raise RuntimeError("Posit Connect did not start within 60 seconds.") @@ -589,6 +592,9 @@ def is_port_free(port: int) -> bool: """ Check if a host port is free by trying to bind to it on all interfaces, which is how Docker publishes container ports. + + Docker reports a port taken by another container, but Docker Desktop + doesn't report one taken by a process on the host. """ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: @@ -598,52 +604,6 @@ def is_port_free(port: int) -> bool: return False -def choose_port(requested: int | None) -> int | None: - """ - Choose the host port for Connect. - - An explicit --port is used as is, so Docker reports an error if it is - taken. Otherwise use DEFAULT_PORT if it's free, or None to have Docker - assign a free port when the container starts. - """ - if requested is not None: - return requested - if is_port_free(DEFAULT_PORT): - return DEFAULT_PORT - return None - - -class PortInUseError(RuntimeError): - """The container couldn't start because its host port is taken.""" - - -def start_container(client, create_kwargs: dict, image_name: str): - """ - Create and start a container, removing it if it fails to start. - - Creating and starting separately means a container that fails to start - can be removed instead of left behind. Raises PortInUseError if the - host port is taken, and RuntimeError for other failures. - """ - try: - container = client.containers.create(**create_kwargs) - except Exception as e: - raise RuntimeError(f"Failed to create container from {image_name}: {e}") - try: - container.start() - except Exception as e: - try: - container.remove(force=True) - except Exception: - pass # Don't hide the start error behind a cleanup error - message = f"Failed to start container from {image_name}: {e}" - # Docker's wording when another container or process has the port - if "port is already allocated" in str(e) or "address already in use" in str(e): - raise PortInUseError(message) - raise RuntimeError(message) - return container - - def published_port(container) -> int: """ Return the host port Docker assigned to the container's port 3939. diff --git a/test_main.py b/test_main.py index f79a06f..698e638 100644 --- a/test_main.py +++ b/test_main.py @@ -4,7 +4,6 @@ import io import json import os -import socket import urllib.error import subprocess import sys @@ -491,7 +490,7 @@ def test_port_defaults_to_none(): def test_is_port_free(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + with main.socket.socket(main.socket.AF_INET, main.socket.SOCK_STREAM) as s: s.bind(("", 0)) s.listen() port = s.getsockname()[1] @@ -499,22 +498,6 @@ def test_is_port_free(): assert main.is_port_free(port) is True -def test_choose_port_uses_explicit_port(): - # An explicit port is used even if it's taken, so Docker reports the error - with patch.object(main, "is_port_free", return_value=False): - assert main.choose_port(4000) == 4000 - - -def test_choose_port_uses_default_when_free(): - with patch.object(main, "is_port_free", return_value=True): - assert main.choose_port(None) == main.DEFAULT_PORT - - -def test_choose_port_leaves_it_to_docker_when_default_taken(): - with patch.object(main, "is_port_free", return_value=False): - assert main.choose_port(None) is None - - def test_help_explains_how_to_stop(): code, output = parse_exit("--help") help_text = " ".join(output.split()) @@ -846,54 +829,41 @@ def test_cleanup_stop_error_does_not_hide_original_error(): assert "did not start within 60 seconds" in message assert "Warning: failed to stop container abc123: daemon went away" in stderr.getvalue() -def port_conflict_client(): - """A mock Docker client whose first container hits a port conflict.""" - taken = MagicMock() - taken.start.side_effect = docker.errors.APIError( - "Bind for 0.0.0.0:3939 failed: port is already allocated" - ) - started = MagicMock() - started.ports = {"3939/tcp": [{"HostIp": "0.0.0.0", "HostPort": "50000"}]} - started.logs.return_value = b"" +def run_until_port_wait(*args, port_free=True): + """ + Run with a mock Docker client that publishes port 50000, stopping at + the wait for Connect. Returns the client and what was printed to stderr. + """ client = MagicMock() - client.containers.create.side_effect = [taken, started] - return client, taken, started - - -def test_port_taken_after_check_retries_with_docker_assigned_port(): - client, taken, started = port_conflict_client() + container = client.containers.create.return_value + container.ports = {"3939/tcp": [{"HostIp": "0.0.0.0", "HostPort": "50000"}]} + container.logs.return_value = b"" stderr = io.StringIO() - # Stop after the port is chosen by failing the wait for Connect with patch.object(main, "is_port_open", return_value=False), redirect_stderr(stderr): - message = run_with_client(client) + run_with_client(client, *args, port_free=port_free) + return client, stderr.getvalue() - assert "did not start within 60 seconds" in message - taken.remove.assert_called_once_with(force=True) - ports = [c.kwargs["ports"] for c in client.containers.create.call_args_list] - assert ports == [{"3939/tcp": 3939}, {"3939/tcp": None}] - assert "Port 3939 is in use. Using port 50000." in stderr.getvalue() - assert "Waiting for port 50000 to open" in stderr.getvalue() + +def test_default_port_used_when_free(): + client, stderr = run_until_port_wait() + + assert client.containers.create.call_args.kwargs["ports"] == {"3939/tcp": 3939} + assert "Waiting for port 3939 to open" in stderr def test_default_port_taken_uses_docker_assigned_port(): - client, _, started = port_conflict_client() - client.containers.create.side_effect = [started] - stderr = io.StringIO() - with patch.object(main, "is_port_open", return_value=False), redirect_stderr(stderr): - run_with_client(client, port_free=False) + client, stderr = run_until_port_wait(port_free=False) assert client.containers.create.call_args.kwargs["ports"] == {"3939/tcp": None} - assert "Using port 50000." in stderr.getvalue() - + assert "Port 3939 is in use. Using port 50000." in stderr + assert "Waiting for port 50000 to open" in stderr -def test_explicit_port_taken_is_reported_without_retry(): - client, taken, _ = port_conflict_client() - message = run_with_client(client, "--port", "3939") +def test_explicit_port_used_even_if_taken(): + client, stderr = run_until_port_wait("--port", "3939", port_free=False) - assert "port is already allocated" in message - assert client.containers.create.call_count == 1 - taken.remove.assert_called_once_with(force=True) + assert client.containers.create.call_args.kwargs["ports"] == {"3939/tcp": 3939} + assert "Waiting for port 3939 to open" in stderr def test_published_port_without_binding_is_an_error(): @@ -1056,15 +1026,6 @@ def test_installed_command_uses_main(): test_is_port_free() print("✓ test_is_port_free passed") - test_choose_port_uses_explicit_port() - print("✓ test_choose_port_uses_explicit_port passed") - - test_choose_port_uses_default_when_free() - print("✓ test_choose_port_uses_default_when_free passed") - - test_choose_port_leaves_it_to_docker_when_default_taken() - print("✓ test_choose_port_leaves_it_to_docker_when_default_taken passed") - test_help_explains_how_to_stop() print("✓ test_help_explains_how_to_stop passed") @@ -1137,14 +1098,14 @@ def test_installed_command_uses_main(): test_cleanup_stop_error_does_not_hide_original_error() print("✓ test_cleanup_stop_error_does_not_hide_original_error passed") - test_port_taken_after_check_retries_with_docker_assigned_port() - print("✓ test_port_taken_after_check_retries_with_docker_assigned_port passed") + test_default_port_used_when_free() + print("✓ test_default_port_used_when_free passed") test_default_port_taken_uses_docker_assigned_port() print("✓ test_default_port_taken_uses_docker_assigned_port passed") - test_explicit_port_taken_is_reported_without_retry() - print("✓ test_explicit_port_taken_is_reported_without_retry passed") + test_explicit_port_used_even_if_taken() + print("✓ test_explicit_port_used_even_if_taken passed") test_published_port_without_binding_is_an_error() print("✓ test_published_port_without_binding_is_an_error passed")