blob: 88cb29821cce6081ef59524afc0198f21e09fb1c [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify what swait reports for a step that ends in a terminal-failure state.
Covers the signal path (code=<status>:<signal> with no reason), the
reason=TIMEOUT decode and its raw --json state, and waits on a still-pending
async step that either launches and completes, is cancelled before launch
(rendering "never-launched"), or is caught by a whole-job cancel.
A queued synchronous step cancelled before launch is covered too: with a
26.11 srun it reserves a real StepId at submit, so it can be targeted while
still PENDING.
Terminal states are asserted in both the human and the --json rendering
here, because the state decode itself is what is under test.
"""
import json
import re
import pytest
import atf
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Issue 50928: per-step SRUN_STEPS_DRAINED dispatch lives in slurmd 26.11",
)
atf.require_version(
(26, 11),
component="bin/swait",
reason="Issue 50928: swait --follow/--json were added in 26.11",
)
# 4 CPUs: the pending-step tests park a -n4 blocker on the node so the
# async step they subscribe to stays PENDING. The 2 nodes are the shape the
# whole test_162_5..8 family requires, kept identical here so the suite
# does not reconfigure between them.
atf.require_nodes(2, [("CPUs", 4)])
atf.require_config_parameter_includes("SlurmctldParameters", "enable_stepmgr")
atf.require_config_parameter_includes("PrologFlags", "Contain")
atf.require_slurm_running()
# run_command timeout for a swait that blocks on a step: the step's own
# lifetime plus job startup and the subscribe round trip, with headroom for
# a loaded runner. Raised over atf's 60s default so an outer kill cannot
# mask a real wait.
BLOCKING_CMD_SECS = 180
# Raised over atf's 45s default: these modules park several concurrent
# steps, so the first step can take longer than that to appear.
STEP_WAIT_SECS = 60
@pytest.mark.parametrize("fmt", ["", "--json"])
def test_step_signal_emits_no_spurious_reason(fmt):
"""A signal-killed step is still RUNNING at dispatch, so swait must
convey the kill only via code=<status>:<signal> and emit no reason
(reasons are for terminal-failure states only)."""
out = "sig.out"
err = "sig.err"
# Gate the scancel on the step actually RUNNING and on swait having
# subscribed: killing a still-PENDING step would instead render
# never-launched reason=CANCELLED and fail both assertions below.
# -v goes to stderr, so stdout stays clean for the --json arm.
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_162_6_sig "
"--wrap '"
"srun --async -n1 sleep infinity; "
"for _ in $(seq 300); do "
"scontrol show step $SLURM_JOB_ID.0 2>/dev/null | grep -q State=RUNNING "
"&& break; sleep 0.2; done; "
f"swait -v {fmt} $SLURM_JOB_ID.0 > {out} 2>{err} & SW=$!; "
+ atf.swait_await_subscribe(err, quote='"')
+ "scancel $SLURM_JOB_ID.0; "
"wait $SW; exit $?'",
fatal=True,
)
assert atf.wait_for_job_state(job_id, "COMPLETED", timeout=120, fatal=False), (
"job did not complete; swait did not report the step after cancel"
f"; a job ExitCode of {atf.SWAIT_SUBSCRIBE_TIMEOUT_RC}:0 means the subscribe "
f"sentinel {atf.SWAIT_SUBSCRIBED!r} never appeared in swait -v output, which is "
"a harness/log-text problem rather than a notification failure"
)
# swait wrote this on the compute node, so job completion does not imply
# the file is visible here yet. Wait on a marker that only appears once
# the record is complete, so the read below cannot catch a partial line.
# For --json that is "state", the last field swait emits. For the human
# rendering "code=" is not enough -- it precedes both the value and any
# trailing reason=, and the negative reason= assertion below would pass
# on a truncated line -- so poll for the full code=<status>:<signal>.
if fmt:
atf.assert_file_contents(out, '"state"', contains=True)
else:
pattern = re.compile(rf"StepId={job_id}\.0 code=\d+:\d+")
for _ in atf.timer(timeout=60, poll_interval=1, quiet=True):
if pattern.search(atf.run_command_output(f"cat {out}", quiet=True)):
break
else:
pytest.fail(f"no complete completion line in {out}")
output = atf.run_command_output(f"cat {out}", fatal=True)
if not fmt:
assert re.search(
rf"StepId={job_id}\.0 code=0:[1-9]", output
), f"expected a non-zero kill signal in the exit code; got: {output!r}"
assert (
"reason=" not in output
), f"a RUNNING (signal-killed) step must not emit a reason; got: {output!r}"
return
objs = [json.loads(line) for line in output.splitlines() if line.strip()]
assert objs, f"no JSON completion emitted: {output!r}"
obj = objs[0]
# swait.1: exit_code.status names the outcome; SIGNALED is the value
# documented for a step a signal killed.
status = obj["exit_code"]["status"]
assert "SIGNALED" in (
status if isinstance(status, list) else [status]
), f"expected SIGNALED in exit_code.status: {obj!r}"
# swait.1: exit_code carries signal, an object with id and name.
signal = obj["exit_code"]["signal"]
assert atf.get_data_parser_number(signal["id"]) not in (
None,
0,
), f"expected a non-zero signal id for a killed step: {obj!r}"
assert signal["name"], f"expected a signal name: {obj!r}"
assert atf.get_data_parser_number(obj["exit_code"]["return_code"]) in (
None,
0,
), f"a signal-killed step reports the signal, not a return code: {obj!r}"
@pytest.mark.slow
@pytest.mark.parametrize("fmt", ["", "--json"])
def test_step_timeout_reason_decoded(fmt):
"""A step that exceeds its time limit reaches TIMEOUT state, which swait
decodes client-side (from the raw state on the wire). The human
rendering names it as reason=TIMEOUT; --json reports the raw terminal
state, not clamped to COMPLETED the way a normal completion is."""
job_id = atf.submit_job_sbatch(
"-N1 --time=10:00 --job-name=test_162_6_timeout "
"--wrap 'srun --async -n1 --time=1 sleep infinity; sleep infinity'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=STEP_WAIT_SECS, fatal=True)
# The step's --time=1 (one minute) elapses, stepmgr sets it TIMEOUT and
# kills it; swait is notified of the step's end.
result = atf.run_command(
f"swait {fmt} {job_id}.0",
env_vars=f"SLURM_JOB_ID={job_id} SLURM_STEPMGR={atf.get_stepmgr_host(job_id)}",
timeout=BLOCKING_CMD_SECS,
)
assert result["exit_code"] == 0, f"swait exited {result['exit_code']}"
if not fmt:
assert "reason=TIMEOUT" in result["stdout"], (
"expected reason=TIMEOUT decoded from step state; got: "
f"{result['stdout']!r}"
)
return
obj = json.loads(result["stdout"].strip())
assert (
atf.get_data_parser_flag(obj["state"]) == "TIMEOUT"
), f"bad json: {result['stdout']!r}"
@pytest.mark.slow
def test_step_wait_pending_then_completes():
"""A swait on a still-PENDING async step must follow it into its
launched record and report the real completion, not time out."""
# The blocker must outlast the whole setup path (wait_for_step's 1s
# poll, atf.get_pending_async_step()'s poll over get_steps, and _stepmgr's
# scontrol round trip), or the async step launches before swait
# subscribes and the pending-subscriber path is never exercised.
BLOCK = 60
job_id = atf.submit_job_sbatch(
"-N1 -n4 --time=5:00 --job-name=test_162_6_pending_done "
f"--wrap 'srun -n4 sleep {BLOCK} & sleep 1; "
# Trailing sleep keeps the job alive well past the blocker so the
# async step has room to launch and end under CI load; swait exits
# on the per-step push, so this costs the test no wall time.
"srun --async -n1 sleep 3; wait; sleep 30'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=STEP_WAIT_SECS, fatal=True)
pend = atf.get_pending_async_step(job_id)
assert pend is not None, "expected a pending async step to subscribe to"
result = atf.run_command(
f"swait --json --timeout={BLOCK + 90} {pend}",
env_vars=f"SLURM_JOB_ID={job_id} SLURM_STEPMGR={atf.get_stepmgr_host(job_id)}",
timeout=BLOCK + 120,
)
assert result["exit_code"] == 0, (
f"swait exited {result['exit_code']}; the pending step's completion "
f"was not delivered. stderr: {result['stderr']!r}"
)
# A fast return prints nothing, so name that case rather than letting
# json.loads raise an opaque decode error on an empty string.
assert result["stdout"].strip(), (
"swait returned no completion; the async step had already ended when "
"swait subscribed, so the pending-subscriber path was not exercised"
)
obj = json.loads(result["stdout"].strip())
# The async step launches once the blocker frees CPUs; depending on timing
# it may exit cleanly or be signaled when the job ends. The regression
# guarded here is the orphaned subscriber (swait timing out), so assert
# swait was notified of the target step's terminal end -- not a specific
# exit code.
assert (
obj["step_id"]["step_id"] == pend.split(".")[-1]
), f"bad json: {result['stdout']!r}"
assert atf.get_data_parser_flag(obj["exit_code"]["status"]) in (
"SUCCESS",
"ERROR",
"SIGNALED",
"CORE_DUMPED",
), f"step did not launch and end: {result['stdout']!r}"
@pytest.mark.parametrize("fmt", ["", "--json"])
def test_step_wait_pending_cancelled(fmt):
"""A swait on a still-PENDING async step must wake (never-launched)
when that step is cancelled before launch, not hang to --timeout."""
# The blocker holds the CPUs until teardown, so the async step cannot
# launch out from under the wait; scancel is the only terminator.
job_id = atf.submit_job_sbatch(
"-N1 -n4 --time=5:00 --job-name=test_162_6_pending_cancel "
"--wrap 'srun -n4 sleep infinity & sleep 1; "
"srun --async -n1 sleep 5; wait'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=STEP_WAIT_SECS, fatal=True)
pend = atf.get_pending_async_step(job_id)
assert pend is not None, "expected a pending async step to subscribe to"
# Split the streams: -v writes the subscribe sentinel to stderr, so
# keeping stdout clean lets the --json arm parse it directly.
out_file = "pending_cancel.err"
stdout_file = "pending_cancel.out"
# Background swait (subscribe to the pending step), then scancel that
# step while it is still pending; `wait` makes the script's rc swait's.
script = (
f"env SLURM_JOB_ID={job_id} SLURM_STEPMGR={atf.get_stepmgr_host(job_id)} "
f"swait -v {fmt} --timeout=30 {pend} "
f">{stdout_file} 2>{out_file} & sw=$!; "
+ atf.swait_await_subscribe(out_file)
+ f"scancel {pend}; wait $sw"
)
result = atf.run_command(script, timeout=BLOCKING_CMD_SECS)
assert result["exit_code"] == 0, (
"swait was not notified of the pending step's cancellation "
f"(exited {result['exit_code']})"
f"; {atf.SWAIT_SUBSCRIBE_TIMEOUT_RC} means the subscribe sentinel "
f"{atf.SWAIT_SUBSCRIBED!r} never appeared in swait -v output, which is a "
f"harness/log-text problem rather than a notification failure"
)
out = atf.run_command_output(f"cat {stdout_file}", fatal=True)
if not fmt:
# swait.1: a never-launched step always carries a reason=, normally
# FAILED or CANCELLED. Assert the whole documented line, not tokens.
assert re.search(
rf"StepId={re.escape(pend)} never-launched reason=(FAILED|CANCELLED)\b",
out,
), f"expected the documented never-launched line: {out!r}"
return
objs = [json.loads(line) for line in out.splitlines() if line.strip()]
assert objs, f"no JSON completion emitted: {out!r}"
obj = objs[0]
# swait.1: a never-launched step has PENDING in status and an unset
# return_code, "which is what distinguishes it from a step that exited 0".
status = obj["exit_code"]["status"]
status = status if isinstance(status, list) else [status]
assert "PENDING" in status, f"expected PENDING in exit_code.status: {obj!r}"
assert (
atf.get_data_parser_number(obj["exit_code"]["return_code"]) is None
), f"a never-launched step must not carry a return_code: {obj!r}"
assert atf.get_data_parser_flag(obj["state"]) in (
"CANCELLED",
"FAILED",
), f"expected a terminal failure state: {obj!r}"
# Only a 26.11 srun reserves a real StepId for a queued sync step.
@pytest.mark.skipif(
atf.get_version("bin/srun") < (26, 11),
reason="Issue 50940: a queued sync step reserves a real StepId only with a 26.11 srun",
)
def test_step_wait_pending_sync_cancelled():
"""A swait on a queued synchronous step must wake (never-launched) when
that step is cancelled before launch, not hang to --timeout.
Sync twin of test_step_wait_pending_cancelled: with a 26.11 srun the
queued step reserves a real StepId at submit, so it can be targeted
while still PENDING. The regression guarded here is a reaped
placeholder reporting nothing (swait hanging to --timeout or exiting
3 as if the step never existed)."""
# The blocker holds the CPUs until teardown, so the sync step cannot
# launch out from under the wait; scancel is the only terminator.
job_id = atf.submit_job_sbatch(
"-N1 -n4 --time=5:00 --job-name=test_162_6_pending_sync_cancel "
"--wrap 'srun -n4 sleep infinity & sleep 1; srun -n1 sleep 5 & wait'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=STEP_WAIT_SECS, fatal=True)
# The queued step is listed under its real id while still PENDING.
pend = f"{job_id}.1"
atf.wait_for_step(job_id, 1, state=None, timeout=STEP_WAIT_SECS, fatal=True)
out_file = "pending_sync_cancel.err"
stdout_file = "pending_sync_cancel.out"
# Background swait (subscribe to the queued step), then scancel it while
# still pending; `wait` makes the script's rc swait's.
script = (
f"env SLURM_JOB_ID={job_id} SLURM_STEPMGR={atf.get_stepmgr_host(job_id)} "
f"swait -v --timeout=30 {pend} "
f">{stdout_file} 2>{out_file} & sw=$!; "
+ atf.swait_await_subscribe(out_file)
+ f"scancel {pend}; wait $sw"
)
result = atf.run_command(script, timeout=BLOCKING_CMD_SECS)
assert result["exit_code"] == 0, (
"swait was not notified of the queued sync step's cancellation "
f"(exited {result['exit_code']})"
f"; {atf.SWAIT_SUBSCRIBE_TIMEOUT_RC} means the subscribe sentinel "
f"{atf.SWAIT_SUBSCRIBED!r} never appeared in swait -v output, which is a "
f"harness/log-text problem rather than a notification failure"
)
out = atf.run_command_output(f"cat {stdout_file}", fatal=True)
# swait.1: a never-launched step always carries a reason=, normally
# FAILED or CANCELLED. Assert the whole documented line, not tokens.
assert re.search(
rf"StepId={re.escape(pend)} never-launched reason=(FAILED|CANCELLED)\b",
out,
), f"expected the documented never-launched line: {out!r}"
def _pending_jobcancel_output():
"""Runs a STEP wait on a pending async step, cancels the whole job, and
returns what swait printed. Fails if swait did not wake at all."""
job_id = atf.submit_job_sbatch(
"-N1 -n4 --time=5:00 --job-name=test_162_6_pending_jobcancel "
"--wrap 'srun -n4 sleep infinity & sleep 1; "
"srun --async -n1 sleep 5; wait'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=STEP_WAIT_SECS, fatal=True)
pend = atf.get_pending_async_step(job_id)
assert pend is not None, "expected a pending async step to subscribe to"
out_file = "pending_jobcancel.out"
# Background swait (subscribe to the pending step), then scancel the
# *whole job* -- not the step -- while it is still pending.
script = (
f"env SLURM_JOB_ID={job_id} SLURM_STEPMGR={atf.get_stepmgr_host(job_id)} "
f"swait -v --timeout=30 {pend} >{out_file} 2>&1 & sw=$!; "
+ atf.swait_await_subscribe(out_file)
+ f"scancel {job_id}; wait $sw"
)
result = atf.run_command(script, timeout=BLOCKING_CMD_SECS)
assert result["exit_code"] == 0, (
"swait was not notified when the job was cancelled "
f"(exited {result['exit_code']})"
f"; {atf.SWAIT_SUBSCRIBE_TIMEOUT_RC} means the subscribe sentinel "
f"{atf.SWAIT_SUBSCRIBED!r} never appeared in swait -v output, which is a "
f"harness/log-text problem rather than a notification failure"
)
out = atf.run_command_output(f"cat {out_file}", fatal=True)
# swait.1 permits either shape here: a jobid.stepid wait "can also be
# satisfied by the whole-set drain".
per_step_never_launched = (pend in out) and ("never-launched" in out)
whole_set_drained = f"JobId={job_id} steps drained" in out
assert (
per_step_never_launched or whole_set_drained
), f"swait did not wake for the pending step wait: {out!r}"
return pend, out
def test_step_wait_pending_no_hang_on_job_cancel():
"""A STEP subscriber does not hang when the whole job (not the step) is
cancelled while its target step is still PENDING. Either documented
shape satisfies the wait; this pins only that swait wakes."""
_pending_jobcancel_output()
def test_step_wait_pending_reports_never_launched_on_job_cancel():
"""The per-step never-launched line is the more useful of the two shapes
swait.1 permits, and is what a cancelled pending step now reports:
_delete_pending_steps() forces the step terminal before the push, so the
subscriber gets the per-step result rather than the whole-set drain."""
pend, out = _pending_jobcancel_output()
assert pend in out and "never-launched" in out, (
"expected the per-step never-launched line rather than the "
f"whole-set drain: {out!r}"
)
def test_step_wait_json_reports_target_on_job_cancel():
"""A jobid.stepid wait satisfied by the whole-set drain emits no object
at all (the silent exit-3 path): swait never emits the "entire"
terminator in STEP mode, so a --json consumer of a per-step wait only
ever sees the target step's own result. Pin that shape here.
"""
job_id = atf.submit_job_sbatch(
"-N1 -n4 --time=5:00 --job-name=test_162_6_json_drain "
"--wrap 'srun -n4 sleep infinity & sleep 1; "
"srun --async -n1 sleep 5; wait'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=STEP_WAIT_SECS, fatal=True)
pend = atf.get_pending_async_step(job_id)
assert pend is not None, "expected a pending async step to subscribe to"
out_file = "json_drain.out"
err_file = "json_drain.err"
# -v writes the subscribe sentinel to stderr, so stdout stays parseable.
# Cancel the whole job, which is the case that reaches the terminator.
script = (
f"env SLURM_JOB_ID={job_id} SLURM_STEPMGR={atf.get_stepmgr_host(job_id)} "
f"swait -v --json --timeout=30 {pend} "
f">{out_file} 2>{err_file} & sw=$!; "
+ atf.swait_await_subscribe(err_file)
+ f"scancel {job_id}; wait $sw"
)
result = atf.run_command(script, timeout=BLOCKING_CMD_SECS)
assert result["exit_code"] == 0, (
"swait was not notified when the job was cancelled "
f"(exited {result['exit_code']})"
f"; {atf.SWAIT_SUBSCRIBE_TIMEOUT_RC} means the subscribe sentinel "
f"{atf.SWAIT_SUBSCRIBED!r} never appeared in swait -v output, which is a "
f"harness/log-text problem rather than a notification failure"
)
out = atf.run_command_output(f"cat {out_file}", fatal=True)
objs = [json.loads(line) for line in out.splitlines() if line.strip()]
assert objs, f"no JSON completion emitted: {out!r}"
obj = objs[0]
# The target step's own result satisfied the wait.
assert (
obj["step_id"]["step_id"] == pend.split(".")[-1]
), f"expected the target step's result: {obj!r}"
assert "exit_code" in obj, f"a step result must carry an exit_code: {obj!r}"
assert "state" in obj, f"a step result must carry a state: {obj!r}"
def test_step_wait_reports_target_on_job_cancel():
"""A STEP subscriber's target step is RUNNING when the whole job is
force-killed: every teardown path funnels through
_internal_step_complete(), so the subscriber gets the real per-step
push, not just the generic drain terminator."""
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_162_6_running_jobcancel "
"--wrap 'srun --async -n1 sleep infinity; sleep infinity'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=STEP_WAIT_SECS, fatal=True)
out_file = "running_jobcancel.out"
script = (
f"env SLURM_JOB_ID={job_id} SLURM_STEPMGR={atf.get_stepmgr_host(job_id)} "
f"swait -v --timeout=30 {job_id}.0 >{out_file} 2>&1 & sw=$!; "
+ atf.swait_await_subscribe(out_file)
+ f"scancel {job_id}; wait $sw"
)
result = atf.run_command(script, timeout=BLOCKING_CMD_SECS)
assert result["exit_code"] == 0, (
"swait was not notified of the force-killed step "
f"(exited {result['exit_code']})"
f"; {atf.SWAIT_SUBSCRIBE_TIMEOUT_RC} means the subscribe sentinel "
f"{atf.SWAIT_SUBSCRIBED!r} never appeared in swait -v output, which is a "
f"harness/log-text problem rather than a notification failure"
)
out = atf.run_command_output(f"cat {out_file}", fatal=True)
assert (
f"StepId={job_id}.0" in out
), f"expected the real per-step line, not just the drain line: {out!r}"
assert "code=" in out, f"missing exit code in step line; got: {out!r}"
assert (
f"JobId={job_id} steps drained" not in out
), f"did not expect the generic drain line: {out!r}"