blob: 638c35a954da779105a95d57757ae8683d012439 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Wake an older srun after its pending-step retry changes the callback port.
An older srun opens a new listener for each create attempt. Exercise a retry
while resources are busy and a retry while the job is suspended. In both
cases, releasing the resources must wake srun before its next timeout.
Run with pre-26.11 srun and a 26.11+ controller to expose the regression.
Delete this module when 26.05 leaves the supported version matrix: the
skipif below can never be true after that.
"""
from pathlib import Path
import pexpect
import pytest
import atf
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
atf.get_version("bin/srun") >= (26, 11),
reason="Issue 51470: old srun must reopen its listener on retry",
),
]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"sbin/slurmctld",
reason="Issue 51470: requires retained pending steps in slurmctld 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_excludes("SlurmctldParameters", "enable_stepmgr")
# srun waits MAX(60, SlurmctldTimeout) between retries; keep it at 60s.
atf.require_config_parameter("SlurmctldTimeout", 60, "<=")
# A narrow SrunPortRange can reuse the old port and mask a stale wake.
atf.require_config_parameter("SrunPortRange", None)
atf.require_nodes(1, [("CPUs", 2)])
atf.require_slurm_running()
def _expect_step_create_error(child, timeout):
"""Wait until srun has received an error for its next step-create request."""
for message in (
"Sending message REQUEST_JOB_STEP_CREATE",
"Received message RESPONSE_SLURM_RC",
):
assert (
child.expect([message, pexpect.EOF, pexpect.TIMEOUT], timeout=timeout) == 0
), f"srun should report {message!r}. Output:\n{child.before}"
@pytest.fixture
def pending_step():
"""Yield an external srun waiting behind a CPU hog."""
script = Path("cpu_hog.sh")
atf.make_bash_script(
script, "srun --exclusive -n2 sleep infinity &\nsleep infinity\n"
)
job_id = atf.submit_job_sbatch(f"-N1 -n2 -t5 {script}", fatal=True)
child = None
try:
atf.wait_for_step(job_id, 0, fatal=True)
# Run outside the allocation so suspending the job does not stop srun.
child = atf.run_command_pexpect(
f"env SLURM_DEBUG_FLAGS=net srun -vv --jobid={job_id} "
"--exact -n1 echo PORT_REFRESH_STARTED"
)
_expect_step_create_error(child, atf.default_command_timeout)
yield job_id, child
finally:
if child is not None:
child.close(force=True)
@pytest.mark.parametrize("suspended", [False, True], ids=["busy", "suspended"])
def test_pending_step_wakes_after_retry(pending_step, suspended):
"""A retry must refresh the wake address even while the job is suspended."""
job_id, child = pending_step
if suspended:
atf.run_command(
f"scontrol suspend {job_id}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_job_state(job_id, "SUSPENDED", fatal=True)
# The wait floor is 60s plus up to 9s of PID jitter. Wait for the
# retry's RESPONSE, not the notice printed before sending the retry.
_expect_step_create_error(child, 90)
if suspended:
atf.run_command(
f"scontrol resume {job_id}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
atf.run_command(f"scancel {job_id}.0", fatal=True)
# Less than the 60s retry floor: a stale-port wake cannot be hidden
# by srun eventually retrying and finding the resources free.
assert (
child.expect(
[r"(?m)^PORT_REFRESH_STARTED\r?$", pexpect.EOF, pexpect.TIMEOUT],
timeout=30,
)
== 0
), f"Pending srun should launch on the resource-release wake. Output:\n{child.before}"
child.expect(pexpect.EOF, timeout=atf.default_command_timeout)
child.close()
assert child.exitstatus == 0, f"srun should succeed. Output:\n{child.before}"