| ############################################################################ |
| # Copyright (C) SchedMD LLC. |
| ############################################################################ |
| import logging |
| import re |
| import time |
| |
| import pytest |
| |
| import atf |
| |
| port_range_size = 9 |
| |
| |
| # Setup |
| @pytest.fixture(scope="module", autouse=True) |
| def setup(safe_port_range): |
| lo, hi = safe_port_range |
| atf.require_auto_config("wants to edit SrunPortRange and create a node") |
| atf.require_config_parameter("SrunPortRange", f"{lo}-{hi}") |
| atf.require_nodes(145, [("CPUs", 2), ("RealMemory", 2)]) |
| atf.require_slurm_running() |
| |
| |
| @pytest.fixture(scope="module") |
| def safe_port_range(): |
| """Returns (lo, hi) for a free port range outside ip_local_port_range. |
| |
| Placing the range outside the ephemeral range and ensuring that none is |
| blocked avoids future interferences and ensures current availability. |
| """ |
| range_str = atf.run_command_output( |
| "cat /proc/sys/net/ipv4/ip_local_port_range", fatal=True, quiet=True |
| ) |
| ephem_lo, ephem_hi = (int(x) for x in range_str.split()) |
| |
| # Prefer just above the ephemeral range; fall back to just below. |
| candidates = (ephem_hi + 100, ephem_lo - 100 - port_range_size) |
| for lo in candidates: |
| hi = lo + port_range_size - 1 |
| if not (1024 <= lo and hi <= 65535 and (hi < ephem_lo or lo > ephem_hi)): |
| continue |
| if is_port_range_available(lo, hi): |
| return lo, hi |
| logging.debug(f"[PORT_PICK] Skipping candidate {lo}-{hi}: already blocked") |
| |
| pytest.fail( |
| f"Cannot find a {port_range_size}-port range outside ephemeral ({ephem_lo}-{ephem_hi}) with no active binders" |
| ) |
| |
| |
| def is_port_range_available(lo, hi): |
| """Return True if every port in [lo, hi] is available/bindable. |
| |
| Any TCP socket on a local port in the range whose state is NOT TIME-WAIT |
| will make srun's bind() fail with EADDRINUSE, even though srun sets |
| SO_REUSEADDR. |
| """ |
| logging.debug(f"[PORT_MONITOR] Checking ports {lo}-{hi}") |
| |
| ss_result = atf.run_command( |
| f'ss -tanH "sport >= :{lo} and sport <= :{hi}"', |
| timeout=10, |
| quiet=True, |
| ) |
| if ss_result["exit_code"] != 0: |
| logging.debug(f"[PORT_MONITOR] ss command failed: {ss_result['stderr']}") |
| return False |
| |
| # ss -tanH columns: State Recv-Q Send-Q Local-Addr:Port Peer-Addr:Port |
| for line in ss_result["stdout"].splitlines(): |
| line = line.strip() |
| if not line: |
| continue |
| state = line.split()[0] |
| if state == "TIME-WAIT": |
| continue |
| logging.debug(f"[PORT_MONITOR] Blocking socket ({state}): {line}") |
| return False |
| |
| return True |
| |
| |
| def wait_for_srun_ports_clear(lo, hi, timeout=90): |
| """Wait until all ports in [lo, hi] are released. |
| |
| Timeout defaults to 90s so FIN-WAIT-2 sockets from a prior srun can |
| drain (bounded by net.ipv4.tcp_fin_timeout, default 60s). |
| """ |
| logging.debug( |
| f"[PORT_WAIT] Waiting for ports {lo}-{hi} to be released (timeout: {timeout}s)" |
| ) |
| |
| for t in atf.timer(timeout=timeout, fatal=True, quiet=True): |
| if is_port_range_available(lo, hi): |
| logging.debug("[PORT_WAIT] All ports available") |
| break |
| |
| logging.debug( |
| f"[PORT_WAIT] Still waiting... some ports not yet available ({t:.1f}s remaining)" |
| ) |
| |
| |
| @pytest.mark.parametrize("nodes", [1, 10, 48, 49, 96, 100, 144]) |
| def test_srun_ports_in_range(nodes, safe_port_range): |
| """Test srun uses the right SrunPortRange""" |
| |
| lo, hi = safe_port_range |
| |
| # Wait for ports to be open before running test |
| wait_for_srun_ports_clear(lo, hi) |
| |
| command = """bash -c ' |
| echo "[DEBUG] Starting port check" >&2 |
| echo "[DEBUG] Environment: SLURM_JOBID=$SLURM_JOBID SLURM_STEPID=$SLURM_STEPID" >&2 |
| echo "[DEBUG] Hostname: $(hostname)" >&2 |
| echo "[DEBUG] PID: $$" >&2 |
| |
| echo "[DEBUG] Running scontrol show step..." >&2 |
| step_info=$(scontrol show step $SLURM_JOBID.$SLURM_STEPID 2>&1) |
| scontrol_exit_code=$? |
| echo "[DEBUG] scontrol exit code: $scontrol_exit_code" >&2 |
| echo "[DEBUG] scontrol output length: ${#step_info}" >&2 |
| echo "[DEBUG] scontrol output: $step_info" >&2 |
| |
| if [[ $scontrol_exit_code -ne 0 ]]; then |
| echo "[ERROR] scontrol failed with exit code $scontrol_exit_code" >&2 |
| exit 1 |
| fi |
| |
| echo "[DEBUG] Extracting SrunHost info..." >&2 |
| srun_host_line=$(echo "$step_info" | grep SrunHost) |
| echo "[DEBUG] SrunHost line: $srun_host_line" >&2 |
| |
| if [[ -z "$srun_host_line" ]]; then |
| echo "[ERROR] No SrunHost found in step info" >&2 |
| exit 2 |
| fi |
| |
| task_id=$(echo "$srun_host_line" | awk -F: '"'"'{print $3}'"'"') |
| echo "[DEBUG] Extracted task_id: $task_id" >&2 |
| |
| if [[ -z "$task_id" ]]; then |
| echo "[ERROR] Could not extract task_id" >&2 |
| exit 3 |
| fi |
| |
| echo "[DEBUG] Checking if process $task_id exists..." >&2 |
| if ! ps -p $task_id > /dev/null 2>&1; then |
| echo "[ERROR] Process $task_id does not exist" >&2 |
| echo "[DEBUG] Current srun processes:" >&2 |
| ps aux | grep srun | grep -v grep >&2 |
| exit 4 |
| fi |
| |
| echo "[DEBUG] Running ss to find listening ports for PID $task_id..." >&2 |
| ss_output=$(ss -tlnp 2>&1) |
| ss_exit_code=$? |
| echo "[DEBUG] ss exit code: $ss_exit_code" >&2 |
| |
| if [[ $ss_exit_code -ne 0 ]]; then |
| echo "[ERROR] ss failed with exit code $ss_exit_code" >&2 |
| echo "[DEBUG] ss error output: $ss_output" >&2 |
| exit 5 |
| fi |
| |
| echo "[DEBUG] Filtering ss output for PID $task_id..." >&2 |
| filtered_output=$(echo "$ss_output" | grep "pid=$task_id,") |
| echo "[DEBUG] Filtered output: $filtered_output" >&2 |
| |
| echo "[DEBUG] Using filtered output as listen ports..." >&2 |
| listen_ports="$filtered_output" |
| echo "[DEBUG] LISTEN lines: $listen_ports" >&2 |
| |
| echo "[DEBUG] Extracting port numbers..." >&2 |
| port_numbers=$(echo "$listen_ports" | awk '"'"'{print $4}'"'"' | awk -F: '"'"'{print $NF}'"'"') |
| echo "[DEBUG] Port numbers: $port_numbers" >&2 |
| |
| echo "[DEBUG] Final output:" >&2 |
| echo "$port_numbers" |
| |
| echo "[DEBUG] Completed" >&2 |
| \' """ |
| |
| start_time = time.time() |
| |
| # Log existing srun processes before test |
| ps_result = atf.run_command("ps aux | grep srun | grep -v grep") |
| # Logging for Ticket 19089 |
| logging.debug(f"[TEST] Existing srun processes before test: {ps_result['stdout']}") |
| |
| result = atf.run_job(f"-N{nodes} {command}", timeout=180) |
| end_time = time.time() |
| |
| # Logging for Ticket 19089 |
| logging.debug(f"[TEST] Command took {end_time - start_time:.3f} seconds") |
| |
| output = result["stdout"].split("\n") |
| count = 0 |
| for port_string in output: |
| # Ignore blank lines |
| if len(port_string) < 4: |
| continue |
| count += 1 |
| port_int = int(port_string) |
| assert lo <= port_int <= hi, f"Port {port_int} is not in range {lo}-{hi}" |
| |
| # From the docs: |
| # "A single srun opens 4 listening ports plus 2 more for every 48 hosts |
| # beyond the first 48." |
| ports = nodes * (4 + 2 * ((nodes - 1) // 48)) |
| assert count == ports, f"srun with -N{nodes} should use {ports} ports, not {count}" |
| |
| |
| @pytest.mark.parametrize("nodes", [145]) |
| def test_srun_ports_out_of_range(nodes, safe_port_range): |
| """Test sruns with too many nodes, so with not enough SrunPortRange""" |
| |
| lo, hi = safe_port_range |
| |
| # Wait for ports to be open before running test |
| wait_for_srun_ports_clear(lo, hi) |
| |
| result = atf.run_job_error(f"-t1 -N{nodes} sleep 1", fatal=True, xfail=True) |
| |
| regex = rf"all ports in range .{lo}, {hi}. exhausted" |
| assert ( |
| re.search(regex, result) is not None |
| ), "srun's stderr should contain the 'all ports in range exhausted' message" |
| |
| |
| def test_out_of_srun_ports(safe_port_range): |
| """Test exhausted ports""" |
| |
| lo, hi = safe_port_range |
| |
| # Wait for ports to be open before running test |
| wait_for_srun_ports_clear(lo, hi) |
| |
| job_id1 = atf.submit_job_sbatch('-N1 -o/dev/null --wrap="srun sleep 30"') |
| job_id2 = atf.submit_job_sbatch('-N1 -o/dev/null --wrap="srun sleep 30"') |
| |
| atf.wait_for_step(job_id1, 0, fatal=True) |
| atf.wait_for_step(job_id2, 0, fatal=True) |
| |
| result = atf.run_job_error("-t1 -N1 sleep 1", fatal=True, xfail=True) |
| |
| regex = rf"all ports in range .{lo}, {hi}. exhausted" |
| assert ( |
| re.search(regex, result) is not None |
| ), "srun's stderr should contain the 'all ports in range exhausted' message" |