blob: 4917c6e8cd81addfa65448d74d7eaa9f87a9388d [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify a pre-26.11 srun keeps the poke-and-re-send path under a 26.11 stepmgr.
Issue 50935 cross-version guard: create-at-wake is gated on the pending
step's negotiated protocol version, so a below-gate srun must keep the old
behavior. The stepmgr must poke it to re-send rather than create the step
and push SRUN_STEP_ALLOCATED, which such an srun cannot decode and would
wait out until its timeout.
Issue 51470: hold the blocking step across an old srun's retry so the wake
must reach its refreshed callback listener rather than a closed port.
This is the inverse of test_162_11 (which checks the new behavior). It runs
only in a mixed-version setup with srun < 26.11 and daemons >= 26.11 (an
older client against upgraded daemons); it skips otherwise, so a normal
single-version run never executes it. Needs the SQA mixed-version harness.
Delete this module when 26.05 leaves the supported version matrix: the skipif
below can never be true after that, and it would become permanently skipped
dead weight.
"""
import re
from pathlib import Path
import pexpect
import pytest
import atf
# New daemons, old srun: only true in a mixed-version (upgrade) cluster.
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
atf.get_version("bin/srun") >= (26, 11),
reason="Issue 50935: needs a pre-26.11 srun (mixed-version setup)",
),
]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"sbin/slurmd",
reason="Issue 50935: cross-version guard needs a 26.11 stepmgr",
)
atf.require_nodes(1, [("CPUs", 2)])
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# 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)
# The step manager runs in the batch node's slurmstepd, which is where
# create-at-wake would take effect; Contain is required for stepmgr.
atf.require_config_parameter_includes("SlurmctldParameters", "enable_stepmgr")
atf.require_config_parameter_includes("PrologFlags", "Contain")
atf.require_slurm_running()
def test_old_srun_pending_step_still_launches_under_stepmgr():
"""A below-gate srun's queued step still launches once its blocker frees.
The stepmgr must fall back to poking this srun to re-send. If it instead
pushed the created step, the old srun would never consume it and the step
would hang until its create timeout.
"""
held = "old_srun_held"
release = "old_srun_release"
out = "old_srun.out"
script = "old_srun.sh"
queued_steps = "old_srun_queued_steps"
# A below-gate srun's queued step carries the SLURM_PENDING_STEP sentinel,
# rendered <job_id>.TBD, so gate on the state rather than on a numeric
# StepId that will never appear here.
atf.make_bash_script(
script,
f"""srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch "{held}"; until [ -f "{release}" ]; do sleep 0.2; done; if [ "$SLURM_PROCID" -eq 0 ]; then echo A_END; fi' &
until [ -f '{held}' ]; do sleep 0.2; done
srun -n1 bash -c 'echo B_DONE' &
for _ in $(seq 1 300); do
scontrol -o show step $SLURM_JOB_ID 2>/dev/null | grep -q 'State=PENDING' && break
sleep 0.2
done
scontrol -o show step $SLURM_JOB_ID > '{queued_steps}' 2>&1
grep -q 'State=PENDING' '{queued_steps}' || {{ echo STEP_NOT_PENDING; exit 1; }}
touch '{release}'
wait""",
)
job_id = atf.submit_job_sbatch(
f"-J old_srun -t5 -N1 --exclusive --output={out} --error={out} {script}",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=120, fatal=True)
lines = []
for _ in atf.timer(fatal=True):
lines = atf.run_command_output(f"cat {out}", fatal=True).splitlines()
if "A_END" in lines and "B_DONE" in lines:
break
assert "A_END" in lines, "blocking step A did not finish"
assert "B_DONE" in lines, "a below-gate srun's queued step never launched"
assert lines.index("B_DONE") > lines.index(
"A_END"
), "the queued step launched before its blocker freed CPUs"
# scancel.1 and srun.1 promise that a below-gate srun's queued step is
# shown as <job_id>.TBD, and so can only be cancelled at the job level.
# Re-poll for content before reading: the redirect creates the file empty.
atf.assert_file_contents(queued_steps, "StepId=", contains=True)
queued = atf.run_command_output(f"cat {queued_steps}", fatal=True)
# Older scontrol releases printed the pending sentinel as TDB.
labels = ("TBD", "TDB") if atf.get_version("bin/scontrol") < (26, 5) else ("TBD",)
assert any(
f"StepId={job_id}.{label}" in queued for label in labels
), f"an older srun's queued step should have a pending sentinel, got: {queued}"
assert (
f"StepId={job_id}.1" not in queued
), f"a pre-26.11 srun's queued step must not get a real StepId, got: {queued}"
@pytest.fixture
def pending_old_srun():
"""Yield an old srun waiting behind a step that holds both CPUs."""
script = Path("old_srun_callback_retry.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)
atf.wait_for_step(job_id, 0, fatal=True)
child = atf.run_command_pexpect(
f"env SLURM_DEBUG_FLAGS=net srun -vv --jobid={job_id} "
"--exact -n1 echo CALLBACK_RETRY_STARTED"
)
try:
yield job_id, child
finally:
child.close(force=True)
def test_old_srun_wakes_after_pending_step_retry(pending_old_srun):
"""A stepmgr wake must reach the listener opened by an old srun's retry."""
job_id, child = pending_old_srun
for message in (
"Sending message REQUEST_JOB_STEP_CREATE",
"Received message RESPONSE_SLURM_RC",
"Requested nodes are busy",
"Sending message REQUEST_JOB_STEP_CREATE",
"Received message RESPONSE_SLURM_RC",
):
assert (
child.expect([message, pexpect.EOF, pexpect.TIMEOUT], timeout=90) == 0
), f"srun should report {message!r}. Output:\n{child.before}"
atf.run_command(f"scancel {job_id}.0", fatal=True)
# Below the 60s retry floor, so a stale callback cannot pass on a retry.
assert (
child.expect(
[r"(?m)^CALLBACK_RETRY_STARTED\r?$", pexpect.EOF, pexpect.TIMEOUT],
timeout=30,
)
== 0
), f"Pending srun should launch on the stepmgr 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}"
# Gate on scontrol, which get_version() uses to tell whether swait exists:
# get_version("bin/swait") on a tree without swait skips the whole module at
# collection, taking the srun test above with it.
@pytest.mark.skipif(
atf.get_version("bin/scontrol") < (26, 5),
reason="Issue 50940: swait needed for the drain assertion, added in 26.05",
)
def test_old_swait_drains_despite_queued_bystander():
"""A whole-set swait drains when the blocking step ends, even while a
below-gate srun's step is still queued behind it.
A queued synchronous step holds the whole-set wait open only when both
the job's stepmgr and the srun that submitted it are 26.11 or newer, so
this below-gate bystander must not. The stepmgr checks for a drain as
the blocker ends, before the poked srun re-sends, so a correct wait
returns 0 then. A regression that counted the poke-only placeholder as
blocking would instead wait on the bystander, which outlives swait's
--timeout.
"""
held = "old_swait_held"
release = "old_swait_release"
result_file = "old_swait_drain.result"
swait_err = "old_swait_drain.err"
script = "old_swait_drain.sh"
atf.make_bash_script(
script,
f"""srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch "{held}"; until [ -f "{release}" ]; do sleep 0.2; done' &
until [ -f '{held}' ]; do sleep 0.2; done
# Bystander: queues behind the blocker as a poke-only placeholder (.TBD);
# once it launches it outlives swait's --timeout.
srun -n1 sleep infinity &
for _ in $(seq 1 300); do
scontrol -o show step $SLURM_JOB_ID 2>/dev/null | grep -q 'State=PENDING' && break
sleep 0.2
done
scontrol -o show step $SLURM_JOB_ID 2>/dev/null | grep -q 'State=PENDING' || {{ echo rc=step-not-pending > '{result_file}'; exit 1; }}
swait -v --timeout=60 2>'{swait_err}' &
SW=$!
# Release the blocker only once swait has subscribed.
( {atf.swait_await_subscribe(swait_err)} ) || {{ echo rc=no-subscribe > '{result_file}'; exit 1; }}
touch '{release}'
wait $SW
echo "rc=$?" > '{result_file}'
""",
)
atf.submit_job_sbatch(
f"-J old_swait_drain -t5 -N1 --exclusive {script}",
fatal=True,
)
# Poll the content, not just the file: it may show up before the echo
# lands in it. A wedged wait (the regression) never writes rc=0: swait
# hits its own 60s timeout first.
match = None
for _ in atf.timer(timeout=180, quiet=True):
content = atf.run_command_output(f"cat {result_file}", quiet=True)
match = re.search(r"rc=(\S+)", content)
if match:
break
assert match, "the batch script should report swait's result, but it did not"
assert match.group(1) == "0", (
"swait should drain once the blocking step ended, but got "
f"rc={match.group(1)}: the queued bystander must not hold the wait open"
)