blob: e5824ffa41db084cd4bfb87dae5e1f762667334b [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test that stopping and resuming a waiting srun does not end its wait.
Resuming a stopped srun delivers SIGCONT. That is not a request to give up, so
srun must keep waiting on the job allocation or the job step it asked for,
within any --immediate limit, and use it once it is available.
"""
import os
import re
import signal
import time
from pathlib import Path
import pexpect
import pytest
import atf
# How long to watch srun after SIGCONT. A srun that ends its wait on the
# signal does so at once, well within this.
WATCH_TIME = 5
# The --immediate limit for test_resume_keeps_immediate_limit, and how late
# srun may give up after it passes.
IMMEDIATE = 10
IMMEDIATE_SLACK = 5
# srun 26.11 and later queue a waiting step under a real StepId and report it
# (srun.1). An older srun gets its StepId only once the step launches.
STEP_ID_AT_SUBMIT = atf.get_version("bin/srun") >= (26, 11)
resubmits_on_sigcont = pytest.mark.xfail(
atf.get_version("bin/srun") < (26, 5, 5),
# Not strict: whether a single stop/resume hits the bug depends on timing.
strict=False,
reason="Ticket 25617: srun before 26.05.5 may cancel a pending job on"
" SIGCONT and submit another one",
)
@pytest.fixture(scope="module", autouse=True)
def setup():
# test_resume_while_pending_step holds both CPUs of a 2-CPU job with one
# step, so that a second step has to wait for them.
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_nodes(1, [("CPUs", 2)])
atf.require_slurm_running()
@pytest.fixture
def spawn_srun():
"""Return a function that spawns srun with the given arguments.
Every srun it spawned is killed afterwards, even if the test fails midway.
"""
children = []
def _spawn(args):
child = atf.run_command_pexpect(f"srun {args}")
children.append(child)
return child
yield _spawn
for child in children:
child.close(force=True)
def _process_state(pid):
"""Return the state letter of pid from /proc, e.g. "S" or "T".
pid is a srun that pexpect spawned here, so its /proc entry is local.
"""
stat = Path(f"/proc/{pid}/stat").read_text()
# The command name is in parentheses and may contain spaces.
return stat.rsplit(")", 1)[1].split()[0]
def _stop_and_resume(pid):
"""Stop pid, and resume it once it has stopped."""
os.kill(pid, signal.SIGSTOP)
# A stop takes effect at once, so poll faster than the default interval.
for _ in atf.timer(poll_interval=0.1, fatal=True):
if _process_state(pid) == "T":
break
os.kill(pid, signal.SIGCONT)
def _expect_queued_job(child):
"""Wait for srun to report its job as queued, and return the job id."""
assert (
child.expect(
[
r"job (\d+) queued and waiting for resources",
pexpect.EOF,
pexpect.TIMEOUT,
],
timeout=atf.default_command_timeout,
)
== 0
), f"srun should wait for its held job. Output:\n{child.before}"
return int(child.match.group(1))
@resubmits_on_sigcont
def test_resume_while_pending_allocation(spawn_srun):
"""Test srun keeps its pending job when stopped and resumed.
A srun that takes the signal for an interrupted wait cancels the pending
job and submits a new one, and it can keep doing so without end.
"""
child = spawn_srun("--hold true")
job_id = _expect_queued_job(child)
_stop_and_resume(child.pid)
# srun prints this for every job it submits, so seeing it again means it
# gave up the first job and submitted another.
assert (
child.expect(
["queued and waiting for resources", pexpect.EOF, pexpect.TIMEOUT],
timeout=WATCH_TIME,
)
== 2
), (
f"srun should keep waiting on job {job_id} after SIGCONT."
f" Output:\n{child.before}"
)
assert (
atf.get_job_parameter(job_id, "JobState") == "PENDING"
), f"job {job_id} should still be pending after srun is resumed"
atf.run_command(f"scontrol release {job_id}", fatal=True)
assert (
child.expect(
[
rf"job {job_id} has been allocated resources",
pexpect.EOF,
pexpect.TIMEOUT,
],
timeout=atf.default_command_timeout,
)
== 0
), (
f"srun should run in job {job_id} once it is released."
f" 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}"
@resubmits_on_sigcont
def test_resume_keeps_immediate_limit(spawn_srun):
"""Test srun keeps its --immediate limit when stopped and resumed.
Every SIGCONT interrupts srun's wait. A srun that restarts the limit on each
interruption does not give up while they keep coming, and one that drops
the limit waits forever.
"""
child = spawn_srun(f"--immediate={IMMEDIATE} --hold true")
job_id = _expect_queued_job(child)
waiting_since = time.time()
_stop_and_resume(child.pid)
# Keep interrupting the wait for half of the limit.
for _ in atf.timer(timeout=IMMEDIATE / 2, poll_interval=0.5, xfail=True):
os.kill(child.pid, signal.SIGCONT)
assert child.isalive(), (
f"srun should still be waiting within its {IMMEDIATE}s limit."
f" Output:\n{child.before}"
)
assert (
atf.get_job_parameter(job_id, "JobState") == "PENDING"
), f"job {job_id} should still be pending after srun is resumed"
assert (
child.expect(
["Unable to allocate resources", pexpect.EOF, pexpect.TIMEOUT],
timeout=IMMEDIATE + IMMEDIATE_SLACK - (time.time() - waiting_since),
)
== 0
), (
f"srun should give up once its {IMMEDIATE}s limit has passed."
f" Output:\n{child.before}"
)
waited = time.time() - waiting_since
assert (
waited >= IMMEDIATE - 1
), f"srun should not give up before its {IMMEDIATE}s limit, gave up after {waited:.1f}s"
child.expect(pexpect.EOF, timeout=atf.default_command_timeout)
child.close()
assert child.exitstatus != 0, f"srun should fail. Output:\n{child.before}"
def test_resume_while_pending_step(spawn_srun):
"""Test srun keeps waiting for step resources when stopped and resumed.
A srun that ends the wait on the signal asks for the step again at once,
and it keeps doing so, logging a retry each time.
"""
script = Path("hog.sh")
atf.make_bash_script(
script, "srun --exclusive -n2 sleep infinity &\nsleep infinity\n"
)
job_id = atf.submit_job(
"sbatch", "-N1 -n2 -t5", str(script), wrap_job=False, fatal=True
)
assert job_id != 0, "sbatch should submit the job"
atf.wait_for_step(job_id, 0, fatal=True)
child = spawn_srun(f"--jobid={job_id} -n1 -vv true")
if STEP_ID_AT_SUBMIT:
# srun.1: a waiting step is reported as "StepId=<jobid>.<stepid> queued".
marker = r"StepId=(\d+\.\d+) queued"
else:
# -vv logs the step request, just before srun waits for the step.
marker = "requesting job"
assert (
child.expect(
[marker, pexpect.EOF, pexpect.TIMEOUT],
timeout=atf.default_command_timeout,
)
== 0
), f"srun should wait for its step. Output:\n{child.before}"
step_id = child.match.group(1) if STEP_ID_AT_SUBMIT else None
_stop_and_resume(child.pid)
assert (
child.expect(["retrying", pexpect.EOF, pexpect.TIMEOUT], timeout=WATCH_TIME)
== 2
), f"srun should keep waiting after SIGCONT. Output:\n{child.before}"
if STEP_ID_AT_SUBMIT:
step = atf.run_command_output(f"scontrol -o show step {step_id}", fatal=True)
assert (
"State=PENDING" in step
), f"step {step_id} should still be pending after srun is resumed: {step}"
# Freeing the CPUs wakes the waiting srun, which then runs its step.
atf.run_command(f"scancel {job_id}.0", fatal=True)
if STEP_ID_AT_SUBMIT:
# srun.1: the step keeps its StepId when it launches.
assert (
child.expect(
[rf"StepId={re.escape(step_id)} started", pexpect.EOF, pexpect.TIMEOUT],
timeout=atf.default_command_timeout,
)
== 0
), f"step {step_id} should start once the CPUs are free. Output:\n{child.before}"
assert (
child.expect(
[pexpect.EOF, pexpect.TIMEOUT], timeout=atf.default_command_timeout
)
== 0
), f"srun should run its step once the CPUs are free. Output:\n{child.before}"
child.close()
assert child.exitstatus == 0, f"srun should succeed. Output:\n{child.before}"