blob: e4dbc42e8ae933dd64260c520be337c33923fe1d [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify a synchronous step pending on exhausted reserved ports gets a real step ID at submission.
Ports-busy counterpart to test_116_61's id-at-submit/reuse coverage
(issue 50938). A step can be queued because a node is busy or because the
reserved-port pool is exhausted; test_116_61 covers the first. Here a tiny
MpiParams pool is exhausted by one step so a second step pends on ports
instead, and must show the same real-id-while-pending / reuse-on-launch
behavior.
The pending id is read from srun's own stderr announcement, which is the
step's own evidence and needs no polling window.
"""
import logging
import re
from pathlib import Path
import pytest
import atf
# A 1-task step auto-reserves (max tasks-per-node + 1) = 2 ports, so a 2-port
# pool is exhausted by one step and the next must pend on ports.
port_range_size = 2
@pytest.fixture(scope="module", autouse=True)
def setup(safe_port_range):
# Daemons are always >= every client command, so requiring srun >= 26.11
# already implies the daemon serving the step create is new enough too.
atf.require_version(
(26, 11),
"bin/srun",
reason="Issue 50938: pending-step id-at-submit requires 26.11+",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# Under stepmgr the whole job pre-reserves ports at admission (2 *
# tasks-per-node + 1 = 5 for this job), which both exceeds the 2-port
# pool below (the job would never be admitted) and, if the pool were
# widened to fit, would already cover both steps -- never exhausting.
# This test targets _step_create()'s controller-side ESLURM_PORTS_BUSY
# path, so pin the job to the non-stepmgr port model.
atf.require_config_parameter_excludes("SlurmctldParameters", "enable_stepmgr")
# A pool sized so one 1-task step exhausts it outright and a second must
# pend on ESLURM_PORTS_BUSY rather than ESLURM_NODES_BUSY. The range is
# probed rather than hardcoded: slurmstepd must be able to bind every
# port in it on every compute node, or the test's premise collapses.
# Tuple form: it replaces any existing "ports=<other>" token instead of
# appending a second one, which the reserved-port parser would ignore.
lo, hi = safe_port_range
atf.require_config_parameter_includes("MpiParams", ("ports", f"{lo}-{hi}"))
# Enough CPUs for the two 1-task steps to run concurrently so CPU
# contention is never what makes the second step pend -- only the port
# pool should be exhausted.
atf.require_nodes(1, [("CPUs", 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 the reserved-port bind() fail with EADDRINUSE, even with
SO_REUSEADDR set.
"""
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 test_pending_sync_step_shows_real_id_and_reuses_it_ports_busy():
"""A synchronous step that pends because the reserved-port pool is exhausted
(not a busy node/CPU) shows a real StepId while PENDING and launches
once the port-holding step frees the pool, reusing (not reassigning)
that id -- a duplicate-id rejection would otherwise leave it hung."""
ready = Path("port_ready")
pend_err = Path("port_pend.err")
done = Path("port_done")
out = Path("port_hog_and_pending.out")
script = Path("port_hog_and_pending.sh")
atf.make_bash_script(
script,
f"""# --exact caps each step to its 1 task's CPU instead of the whole 2-CPU
# allocation, so the second step's CPU stays free and only the reserved-port
# pool -- auto-reserved as (tasks-per-node + 1) = 2 ports from the probed
# MpiParams ports= pool, since resv_port_cnt is left unset and MpiParams
# alone makes resv_ports_present true -- is what makes it pend.
srun --exact -n1 sh -c 'touch "{ready}"; sleep infinity' &
holder=$!
for k in $(seq 1 60); do [ -f '{ready}' ] && break; sleep 0.5; done
[ -f '{ready}' ] || {{ echo PORT_HOLDER_NOT_READY; exit 1; }}
srun --exact -n1 sh -c 'touch "{done}"' 2>'{pend_err}' &
pend=$!
for k in $(seq 1 60); do
grep -q 'StepId=.* queued' '{pend_err}' 2>/dev/null && break
sleep 0.5
done
scancel $SLURM_JOB_ID.0
wait "$pend"
echo DONE
""",
)
job_id = atf.submit_job_sbatch(
f"-N1 -n2 -t5 --output={out} --error={out} {script}", fatal=True
)
assert job_id != 0, "sbatch should submit the job"
assert atf.wait_for_job_state(
job_id, "DONE", timeout=90
), f"job {job_id} should reach DONE"
# Assert the precondition ahead of the generic "script finished" check,
# which would otherwise report a slow runner as a product failure.
assert "PORT_HOLDER_NOT_READY" not in atf.run_command_output(
f"cat {out} 2>/dev/null", quiet=True
), "the port-holding step never took the reserved-port pool"
atf.assert_file_contents(out, "DONE", contains=True)
# The queued step announced a real, numeric StepId (not TBD), exactly as
# for a step queued behind a busy node. Poll for content, not just
# existence: the file is created empty by the shell redirect, so a bare
# wait_for_file()+cat can read it before srun's write lands.
atf.assert_file_contents(pend_err, "queued", contains=True)
pend_err_text = atf.run_command_output(f"cat {pend_err}", fatal=True)
match = re.search(r"StepId=(\S+) queued", pend_err_text)
assert match, (
"the pending step should announce a real StepId while queued, got: "
f"{pend_err_text!r}"
)
assert "TBD" not in match.group(
1
), f"the pending step should show a real id, not TBD, got {match.group(1)!r}"
# _step_create() assigns an id before it tries the ports, so the
# placeholder must reuse that id instead of consuming a second one.
assert match.group(1) == f"{job_id}.1", (
f"ports-busy queuing skipped a StepId: expected {job_id}.1, got "
f"{match.group(1)!r}"
)
# A re-send rejected as a duplicate is what keeps the step from launching,
# so assert it before the launch check, which would otherwise always fire
# first and report the generic symptom instead of this cause. srun renders
# errnos through slurm_strerror(), so match the message text rather than
# the ESLURM_DUPLICATE_STEP_ID symbol, against the pending srun's own
# stderr, which is redirected away from the job output.
assert "Duplicate job step id" not in pend_err_text, (
"the re-send carrying the assigned id was rejected as a duplicate "
f"instead of reusing it, got: {pend_err_text!r}"
)
assert atf.wait_for_file(
done
), "the pending step did not launch once the port-holding step freed the pool"