blob: 15e01a6f4899060287ac49b0163558979893f580 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify the <exit>:<signal> exit code rendering shared by the client commands.
scontrol, squeue, sacct, sview and the jobcomp plugins all decode a
wait()-style exit code through one helper. The helper decides *which* of the
two numbers is populated, so this pins both halves black-box: a job that exits
non-zero must render <status>:0, and a signal-killed job must render 0:<signal>.
DerivedExitCode goes through the same helper and is pinned here too.
"""
import re
import pytest
import atf
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_accounting()
# The per-step sacct rows asserted below do not exist under nosteps
# (nor does the batch row under nojobs), as the retired test7.13 knew.
atf.require_config_parameter_excludes("AccountingStorageEnforce", "nosteps")
atf.require_config_parameter_excludes("AccountingStorageEnforce", "nojobs")
atf.require_nodes(1, [("CPUs", 1)])
atf.require_slurm_running()
# Raised over atf's 45s default: every test here waits for a job to reach
# DONE, which on a loaded runner includes the whole epilog.
DONE_WAIT_SECS = 120
def _scontrol_exit_code(job_id, field="ExitCode"):
"""<exit>:<sig> as scontrol show job renders the named field.
-d because DerivedExitCode is a detail-only field; ExitCode prints
either way, so one command covers both.
"""
output = atf.run_command_output(f"scontrol -d show job {job_id}", fatal=True)
match = re.search(rf"\b{field}=(\d+):(\d+)", output)
assert match, f"no {field} in scontrol output for job {job_id}: {output!r}"
return match.group(1), match.group(2)
def _stepmgr_enabled():
"""A stepmgr job does not track DerivedExitCode on the controller, so
scontrol has nothing to render for it."""
params = atf.get_config_parameter("SlurmctldParameters", default="", quiet=True)
return "enable_stepmgr" in (params or "")
def test_exit_status_rendering():
"""A job exiting non-zero reports <status>:0, not the signal half."""
job_id = atf.submit_job_sbatch(
"-N1 --time=1:00 --job-name=test_108_13_status --wrap 'exit 7'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=DONE_WAIT_SECS, fatal=True)
exit_status, term_sig = _scontrol_exit_code(job_id)
assert exit_status == "7", f"scontrol ExitCode status half: {exit_status}"
assert term_sig == "0", f"an exited job carries no signal; got {term_sig}"
atf.wait_for_job_accounted(job_id, "ExitCode", r"^7:0$", fatal=True)
out = atf.run_command_output(
f"sacct -j {job_id} -X -n -P -o ExitCode", fatal=True
).strip()
assert out == "7:0", f"sacct ExitCode: {out!r}"
@pytest.mark.parametrize(
"wrap,expected",
[
("exit 255", "255:0"),
# sbatch --wrap generates a #!/bin/sh script, and dash rejects a
# negative argument to exit, so spell this one out with bash.
('/bin/bash -c "exit -1"', "255:0"),
],
)
@pytest.mark.xfail(
atf.get_version("bin/sacct") < (25, 11),
reason="Ticket 23460: Wrong exit code for sacct. Fixed in 25.11",
)
def test_exit_status_clamped_to_byte(wrap, expected):
"""job_exit_code.shtml: the exit code "is an 8 bit unsigned number
ranging between 0 and 255", and a negative one is displayed as its
unsigned value in that range. Pin the top of the range and the wrap,
which is where a sign-extension or width regression in the shared
decode helper would show up.
"""
job_id = atf.submit_job_sbatch(
f"-N1 --time=1:00 --job-name=test_108_13_clamp --wrap '{wrap}'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=DONE_WAIT_SECS, fatal=True)
exit_status, term_sig = _scontrol_exit_code(job_id)
assert (
f"{exit_status}:{term_sig}" == expected
), f"scontrol ExitCode: {exit_status}:{term_sig}, expected {expected}"
atf.wait_for_job_accounted(
job_id, "ExitCode", rf"^{re.escape(expected)}$", fatal=True
)
out = atf.run_command_output(
f"sacct -j {job_id} -X -n -P -o ExitCode", fatal=True
).strip()
assert out == expected, f"sacct ExitCode: {out!r}, expected {expected!r}"
def test_signal_rendering():
"""A signal-killed job reports 0:<signal>, not the status half.
The batch shell signals itself so the step's real wait() status reaches
both slurmctld and accounting; cancelling instead would log completion
before the step's exit code is known.
"""
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_108_13_signal --wrap 'kill -KILL $$'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=DONE_WAIT_SECS, fatal=True)
exit_status, term_sig = _scontrol_exit_code(job_id)
assert exit_status == "0", f"a signaled job carries no status; got {exit_status}"
assert term_sig == "9", f"expected SIGKILL in the signal half; got {term_sig}"
atf.wait_for_job_accounted(job_id, "ExitCode", r"^0:9$", fatal=True)
out = atf.run_command_output(
f"sacct -j {job_id} -X -n -P -o ExitCode", fatal=True
).strip()
assert out == "0:9", f"sacct ExitCode: {out!r}"
@pytest.mark.parametrize(
"wrap,expected",
[("exit 7", "7:0"), ("kill -KILL $$", "0:9")],
)
def test_squeue_exit_code_rendering(wrap, expected):
"""squeue -O exit_code renders the same <status>:<signal> split.
squeue decodes through the same helper as scontrol and sacct but is
otherwise untested, so pin both halves here too.
"""
job_id = atf.submit_job_sbatch(
f"-N1 --time=5:00 --job-name=test_108_13_squeue --wrap '{wrap}'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=DONE_WAIT_SECS, fatal=True)
out = atf.run_command_output(
f"squeue --jobs={job_id} --states=all -h -O exit_code", fatal=True
).strip()
assert out == expected, f"squeue exit_code: {out!r}, expected {expected!r}"
@pytest.mark.parametrize(
"steps,job_ec,derived_ec,step_ecs",
[
# A failing step under a script that exits 0: the step's code
# reaches DerivedExitCode only, never the job's ExitCode.
('srun -n1 /bin/sh -c "exit 123"; exit 0', "0:0", "123:0", ["123:0"]),
# The mirror image: the script fails while every step succeeds, so
# DerivedExitCode stays 0:0 and only ExitCode carries the failure.
(
"srun -n1 /bin/true; srun -n1 /bin/true; exit 33",
"33:0",
"0:0",
["0:0", "0:0"],
),
# sacct(1): DerivedExitCode is "the highest exit code returned by
# the job's job steps". Put the highest one first, so an
# implementation that reported the last step's code cannot pass by
# coincidence.
(
'srun -n1 /bin/sh -c "exit 123"; srun -n1 /bin/sh -c "exit 5"; exit 0',
"0:0",
"123:0",
["123:0", "5:0"],
),
# The mirror image: the highest code last. This is the ordering the
# retired test7.13 ran and the one the running-maximum update in the
# stepmgr is defined by; without it an implementation reporting the
# first (or first non-zero) step's code passes every tuple above.
(
'srun -n1 /bin/sh -c "exit 5"; srun -n1 /bin/sh -c "exit 123"; exit 0',
"0:0",
"123:0",
["5:0", "123:0"],
),
],
)
def test_derived_exit_code_rendering(steps, job_ec, derived_ec, step_ecs):
"""DerivedExitCode reports the highest step exit code, not the job's.
Both fields go through the same decode helper, so pin them against each
other in both directions, in scontrol and in accounting, down to the
per-step rows.
"""
job_id = atf.submit_job_sbatch(
f"-N1 --time=5:00 --job-name=test_108_13_derived --wrap '{steps}'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=DONE_WAIT_SECS, fatal=True)
exit_status, term_sig = _scontrol_exit_code(job_id)
assert (
f"{exit_status}:{term_sig}" == job_ec
), f"scontrol ExitCode: {exit_status}:{term_sig}"
if not _stepmgr_enabled():
exit_status, term_sig = _scontrol_exit_code(job_id, "DerivedExitCode")
assert (
f"{exit_status}:{term_sig}" == derived_ec
), f"scontrol DerivedExitCode: {exit_status}:{term_sig}"
# Sync on the accounting State first: DerivedExitCode == "0:0" is also
# the pre-completion default, so for a tuple expecting 0:0 that wait
# could pass while the slurmdbd write is still in flight. A terminal
# State never appears before the completion record is applied.
atf.wait_for_job_accounted(job_id, "State", r"^(COMPLETED|FAILED)$", fatal=True)
atf.wait_for_job_accounted(
job_id, "DerivedExitCode", rf"^{re.escape(derived_ec)}$", fatal=True
)
out = atf.run_command_output(
f"sacct -j {job_id} -X -n -P -o ExitCode,DerivedExitCode", fatal=True
).strip()
assert out == f"{job_ec}|{derived_ec}", f"sacct ExitCode|DerivedExitCode: {out!r}"
# The per-step rows carry each step's own code: the batch step mirrors
# the script's, and each srun step its own.
rows = atf.run_command_output(
f"sacct -j {job_id} -n -P -o JobID,ExitCode", fatal=True
).strip()
step_codes = dict(line.split("|") for line in rows.splitlines() if "|" in line)
assert step_codes.get(f"{job_id}.batch") == job_ec, f"batch step ExitCode: {rows!r}"
for index, expected in enumerate(step_ecs):
assert (
step_codes.get(f"{job_id}.{index}") == expected
), f"step {index} ExitCode: {rows!r}"