blob: 5cc01997849b326f5ff439ad913eed1afad56e0a [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test --max-pids constrains the pids.max of each step cgroup."""
import pytest
import atf
MAX_PIDS = 17
# Job-wide value used by the sbatch test; every step of the job inherits it.
JOB_MAX_PIDS = 50
# The node every step of this module runs on, and its kernel PID limit, both
# read in setup_module() from a probe step.
NODE = None
PID_MAX = None
def setup_module():
global NODE, PID_MAX
atf.require_version(
(26, 11),
"bin/srun",
reason="Issue 50878: srun --max-pids added in 26.11",
)
atf.require_auto_config("needs to modify TaskPlugin/cgroup parameters")
atf.require_config_parameter("CgroupPlugin", "cgroup/v2", source="cgroup")
atf.require_config_parameter_includes("TaskPlugin", "cgroup")
atf.require_nodes(1, [("CPUs", 1)])
atf.require_slurm_running()
# One probe step picks the node, reads its kernel PID limit and checks
# that the pids controller reaches the step cgroups. Without it the
# option is documented as silently ignored, so there is nothing to test.
probe_script = atf.module_tmp_path / "probe_pids.sh"
atf.make_bash_script(
probe_script,
"""
cg=$(awk -F: '/^0::/ {print $3}' /proc/self/cgroup)
echo "$SLURMD_NODENAME"
cat /proc/sys/kernel/pid_max
test -f "/sys/fs/cgroup${cg%/*}/pids.max" && echo yes || echo no
""",
)
NODE, pid_max, has_pids = atf.run_job_output(
f"-N1 -t1 -J test_120_3 {probe_script}", fatal=True
).split()
PID_MAX = int(pid_max)
if has_pids != "yes":
pytest.skip(
f"The pids controller is not available in the step cgroups of {NODE}",
allow_module_level=True,
)
def get_pids_max(srun_args):
"""
Run a step and return the pids.max value found in its user cgroup, plus
the task cgroup it was derived from (for diagnostics).
"""
task_script = atf.module_tmp_path / "print_pids_max.sh"
atf.make_bash_script(
task_script,
"""
cg=$(awk -F: '/^0::/ {print $3}' /proc/self/cgroup)
echo "$cg"
cat "/sys/fs/cgroup${cg%/*}/pids.max"
""",
)
output = atf.run_job_output(
f"{srun_args} -w {NODE} -n1 -t1 -J test_120_3 {task_script}", fatal=True
)
cgroup, pids_max = output.split()
return pids_max, cgroup
def test_max_pids_sets_cgroup_limit():
"""Verify srun --max-pids sets pids.max in the step's user cgroup"""
pids_max, cgroup = get_pids_max(f"--max-pids={MAX_PIDS}")
assert pids_max == str(
MAX_PIDS
), f"Expected pids.max '{MAX_PIDS}' above task cgroup {cgroup}, got: {pids_max}"
def test_max_pids_is_enforced():
"""Verify a step cannot hold more PIDs than --max-pids at the same time"""
# Fork from python so that a failed fork surfaces as EAGAIN right away.
# bash would retry it quietly for up to half a minute instead.
forker = atf.module_tmp_path / "forker.sh"
atf.make_bash_script(
forker,
"""
exec python3 - "$1" <<'EOF'
import errno
import os
import sys
forked = 0
for i in range(int(sys.argv[1])):
try:
pid = os.fork()
except OSError as e:
if e.errno != errno.EAGAIN:
raise
break
if pid == 0:
os.execvp("sleep", ["sleep", "30"])
forked += 1
print(forked)
EOF
""",
)
# The python process itself is one of the PIDs, so only two children fit.
forked = atf.run_job_output(
f"--max-pids=3 -w {NODE} -n1 -t1 -J test_120_3 {forker} 6", fatal=True
).strip()
assert (
forked == "2"
), f"Expected 2 forks to succeed under --max-pids=3, got: {forked}"
forked = atf.run_job_output(
f"--max-pids=max -w {NODE} -n1 -t1 -J test_120_3 {forker} 6", fatal=True
).strip()
assert (
forked == "6"
), f"Expected 6 forks to succeed under --max-pids=max, got: {forked}"
def test_no_max_pids_leaves_cgroup_unconstrained():
"""Verify that without srun --max-pids, pids.max is not constrained"""
pids_max, cgroup = get_pids_max("")
assert (
pids_max == "max"
), f"Expected pids.max 'max' above task cgroup {cgroup}, got: {pids_max}"
def test_max_pids_max_literal_is_accepted():
"""Verify srun --max-pids=max is accepted and sets pids.max to 'max'"""
pids_max, cgroup = get_pids_max("--max-pids=max")
assert (
pids_max == "max"
), f"Expected pids.max 'max' above task cgroup {cgroup}, got: {pids_max}"
def test_max_pids_zero_is_rejected():
"""Verify srun --max-pids=0 is rejected before any step is launched"""
# pids.max=0 would forbid the kernel from admitting any process into the
# step cgroup, so the option refuses it up front.
results = atf.run_command(
"srun --max-pids=0 -n1 -t1 -J test_120_3 true", xfail=True, fatal=True
)
assert (
"Invalid --max-pids specification" in results["stderr"]
), f"Expected the failure to name --max-pids, got: {results['stderr']}"
def test_job_max_pids_applies_to_every_step():
"""Verify the sbatch value is the default of every step and a ceiling for srun
srun may lower it. A larger value, and max, are rejected. The step records
in slurmctld carry the effective value of every step.
"""
file_out = atf.module_tmp_path / "job_max_pids.out"
step_script = atf.module_tmp_path / "print_step_pids_max.sh"
atf.make_bash_script(
step_script,
"""
cg=$(awk -F: '/^0::/ {print $3}' /proc/self/cgroup)
cat "/sys/fs/cgroup${cg%/*}/pids.max"
""",
)
job_script = atf.module_tmp_path / "job_max_pids.sh"
atf.make_bash_script(
job_script,
f"""
echo "batch=$({step_script})"
echo "inherited=$(srun -n1 {step_script})"
echo "lowered=$(srun -n1 --max-pids={JOB_MAX_PIDS - 10} {step_script})"
if srun -n1 --max-pids={JOB_MAX_PIDS + 1} true 2> raised.err; then
echo "raised=accepted"
else
echo "raised=rejected"
fi
echo "message=$(grep -q 'Invalid --max-pids specification' raised.err && echo found || echo missing)"
if srun -n1 --max-pids=max true 2> /dev/null; then
echo "max=accepted"
else
echo "max=rejected"
fi
echo "batchrec=$(scontrol show step $SLURM_JOB_ID.batch | grep -o 'MaxPids=[^ ]*')"
echo "steprec=$(srun -n1 --max-pids={JOB_MAX_PIDS - 10} bash -c 'scontrol show step $SLURM_JOB_ID.$SLURM_STEP_ID | grep -o "MaxPids=[^ ]*"')"
""",
)
job_id = atf.submit_job_sbatch(
f"--max-pids={JOB_MAX_PIDS} -w {NODE} -N1 -t1 -J test_120_3 --output={file_out} {job_script}",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.wait_for_file(file_out, fatal=True)
output = atf.run_command_output(f"cat {file_out}", fatal=True)
expected = [
f"batch={JOB_MAX_PIDS}",
f"inherited={JOB_MAX_PIDS}",
f"lowered={JOB_MAX_PIDS - 10}",
"raised=rejected",
"message=found",
"max=rejected",
f"batchrec=MaxPids={JOB_MAX_PIDS}",
f"steprec=MaxPids={JOB_MAX_PIDS - 10}",
]
for line in expected:
assert line in output.split(
"\n"
), f"Expected '{line}' in the job output, got: {output}"
def test_max_pids_at_kernel_limit_is_accepted():
"""Verify srun --max-pids accepts the kernel PID limit itself"""
# The clamp only applies above the kernel limit, so the limit itself must
# be written as is. It guards against an off-by-one in the clamp.
pids_max, cgroup = get_pids_max(f"--max-pids={PID_MAX}")
assert pids_max == str(
PID_MAX
), f"Expected pids.max '{PID_MAX}' above task cgroup {cgroup}, got: {pids_max}"
def test_max_pids_above_kernel_limit_is_unlimited():
"""Verify srun --max-pids above the kernel PID limit is written as 'max'"""
# Such a limit can never be reached, and the kernel rejects values above
# its own ceiling, so slurmstepd writes max instead of failing the step.
pids_max, cgroup = get_pids_max(f"--max-pids={PID_MAX + 1}")
assert (
pids_max == "max"
), f"Expected pids.max 'max' above task cgroup {cgroup}, got: {pids_max}"
@pytest.mark.parametrize(
"sbatch_arg,scontrol_value,squeue_value",
[
(f"--max-pids={JOB_MAX_PIDS}", JOB_MAX_PIDS, str(JOB_MAX_PIDS)),
("--max-pids=max", "max", "UNLIMITED"),
("", None, "N/A"),
],
)
def test_job_max_pids_is_shown(sbatch_arg, scontrol_value, squeue_value):
"""Verify scontrol show job and squeue -O MaxPids report the job limit"""
job_id = atf.submit_job_sbatch(
f"{sbatch_arg} --hold -N1 -t1 -J test_120_3 --wrap=true", fatal=True
)
shown = atf.get_job_parameter(job_id, "MaxPids")
assert (
shown == scontrol_value
), f"Expected scontrol MaxPids={scontrol_value}, got: {shown}"
shown = atf.run_command_output(
f"squeue -h -O MaxPids -j {job_id}", fatal=True
).strip()
assert (
shown == squeue_value
), f"Expected squeue MaxPids {squeue_value}, got: {shown}"