blob: 5d27995d4631d57cd86e1591c17dd5fa2d335ded [file]
############################################################################
# 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"