Skip to main content

lib/wait_strategy/port_wait_strategy.ex

# SPDX-License-Identifier: MIT
defmodule TestcontainerEx.PortWaitStrategy do
  @moduledoc """
  Considers the container as ready when it successfully accepts connections on the specified port.
  """

  require Logger

  @retry_delay 200
  defstruct [:ip, :port, :timeout, retry_delay: @retry_delay]

  # Public interface

  @doc """
  Creates a new PortWaitStrategy to wait until a specified port is open and accepting connections.
  """
  def new(ip, port, timeout \\ 5000, retry_delay \\ @retry_delay) do
    %__MODULE__{ip: ip, port: port, timeout: timeout, retry_delay: retry_delay}
  end

  # Private functions and implementations

  defimpl TestcontainerEx.WaitStrategy do
    @impl true
    def wait_until_container_is_ready(wait_strategy, container, _conn) do
      host = TestcontainerEx.get_host(container)
      host_port = TestcontainerEx.get_port(container, wait_strategy.port)

      case {host, host_port} do
        {_, nil} ->
          {:error, TestcontainerEx.Error.port_not_mapped(wait_strategy.port), wait_strategy}

        _ ->
          perform_port_check(%{wait_strategy | ip: host}, host_port)
      end
    end

    defp perform_port_check(wait_strategy, host_port) do
      started_at = current_time_millis()
      attempt = 0

      case wait_for_open_port(wait_strategy, host_port, started_at, attempt) do
        :port_is_open ->
          :ok

        {:error, reason} ->
          {:error, reason, wait_strategy}
      end
    end

    defp wait_for_open_port(wait_strategy, host_port, start_time, attempt) do
      if reached_timeout?(wait_strategy.timeout, start_time) do
        {:error, fail_with_logs(wait_strategy, host_port, start_time, attempt)}
      else
        check_port_status(wait_strategy, host_port, start_time, attempt)
      end
    end

    defp check_port_status(wait_strategy, host_port, start_time, attempt) do
      poll_result = port_open?(wait_strategy.ip, host_port)

      elapsed_ms = current_time_millis() - start_time

      :telemetry.execute(
        [:testcontainer_ex, :wait_strategy, :poll],
        %{attempt: attempt + 1, elapsed_ms: elapsed_ms},
        %{
          strategy: :port_wait,
          container_id: nil,
          result: if(poll_result, do: :ok, else: {:error, :port_not_open})
        }
      )

      if poll_result do
        :port_is_open
      else
        log_retry_message(wait_strategy, host_port)
        :timer.sleep(wait_strategy.retry_delay)
        wait_for_open_port(wait_strategy, host_port, start_time, attempt + 1)
      end
    end

    defp port_open?(ip, port, timeout \\ 1000) do
      case :gen_tcp.connect(to_charlist(ip), port, [:binary, active: false], timeout) do
        {:ok, socket} ->
          :gen_tcp.close(socket)
          true

        {:error, _} ->
          false
      end
    end

    defp current_time_millis, do: System.monotonic_time(:millisecond)

    defp reached_timeout?(timeout, start_time), do: current_time_millis() - start_time > timeout

    defp fail_with_logs(_wait_strategy, _host_port, start_time, _attempt) do
      elapsed_ms = current_time_millis() - start_time
      TestcontainerEx.Error.wait_strategy_failed(:port_wait, elapsed_ms)
    end

    defp log_retry_message(wait_strategy, host_port) do
      Logger.debug(
        "Port #{wait_strategy.port} (host port #{host_port}) not open on IP #{wait_strategy.ip}, retrying in #{wait_strategy.retry_delay}ms."
      )
    end
  end
end