| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """Test that --kill-on-bad-exit controls peer termination for async steps.""" | |
| import pytest | |
| import atf | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| atf.require_version( | |
| (26, 5), | |
| "bin/srun", | |
| reason="Issue 50739: srun --async was added in 26.05", | |
| ) | |
| atf.require_version( | |
| (26, 5), | |
| "sbin/slurmd", | |
| reason="Issue 50739: async --kill-on-bad-exit enforcement (slurmstepd) was added in 26.05", | |
| ) | |
| atf.require_config_parameter_includes("SlurmctldParameters", "enable_stepmgr") | |
| atf.require_config_parameter_includes("PrologFlags", "Contain") | |
| atf.require_accounting() | |
| atf.require_nodes(1, [("CPUs", 4)]) | |
| atf.require_slurm_running() | |
| @pytest.mark.parametrize( | |
| "kill_flag,expect_kill", | |
| [ | |
| ("--kill-on-bad-exit", True), | |
| ("--kill-on-bad-exit=0", False), | |
| ], | |
| ) | |
| def test_async_kill_on_bad_exit(kill_flag, expect_kill): | |
| """--kill-on-bad-exit controls peer termination for async steps. | |
| A task exits non-zero mid-run. With the flag enabled the peers must be | |
| killed before they reach a post-sleep marker; with =0 the peers must | |
| survive and print it. The =0 case isolates the flag as the cause of the | |
| kill regardless of the site KillOnBadExit default. | |
| """ | |
| error_text = "SHOULD_NOT_BE_HERE" | |
| started_text = "started-" | |
| num_tasks = 4 | |
| bad_task = num_tasks - 2 | |
| tag = "kill" if expect_kill else "nokill" | |
| out_file = f"kill_bad_exit_{tag}.out" | |
| task_script = f"kill_bad_exit_{tag}.sh" | |
| atf.make_bash_script( | |
| task_script, | |
| f"""echo "{started_text}$SLURM_PROCID" | |
| if [ "$SLURM_PROCID" -eq {bad_task} ]; then | |
| # Wait until all tasks prints its "{started_text}N" before exiting bad_task | |
| for i in $(seq 0 $(({num_tasks} - 1))); do | |
| until grep -q "{started_text}$i" {out_file}; do | |
| sleep 0.1 | |
| done | |
| done | |
| exit 2 | |
| fi | |
| sleep 15 | |
| echo "{error_text}" | |
| """, | |
| ) | |
| script = f"kill_bad_exit_submit_{tag}.sh" | |
| atf.make_bash_script( | |
| script, | |
| f"srun --async --stepmgr {kill_flag} -n{num_tasks} -O -o {out_file} {task_script}\nswait\n", | |
| ) | |
| job_id = atf.submit_job_sbatch( | |
| f"-N1 -n{num_tasks} -O -t1 --stepmgr --job-name=test_116_58 {script}", | |
| fatal=True, | |
| ) | |
| atf.wait_for_step_accounted(job_id, 0, fatal=True) | |
| atf.wait_for_job_state(job_id, "DONE", fatal=True) | |
| text = "" | |
| for _ in atf.timer(fatal=True): | |
| text = atf.run_command_output(f"cat {out_file}") | |
| started_all = all(f"{started_text}{i}" in text for i in range(num_tasks)) | |
| if started_all and (expect_kill or error_text in text): | |
| break | |
| if expect_kill: | |
| assert error_text not in text, ( | |
| f"--kill-on-bad-exit should kill peers before the {error_text!r} " | |
| f"line is written; got {text!r}" | |
| ) | |
| else: | |
| assert error_text in text, ( | |
| f"--kill-on-bad-exit=0 should let peers run to completion and print " | |
| f"{error_text!r}; got {text!r}" | |
| ) | |
| # Peers ran to completion (rc 0), so the failing task's exit code (2) | |
| # surfaces to accounting rather than any peer's signal. A completed step | |
| # is gone from the live controller (scontrol show steps), so the | |
| # ExitCode must be read from sacct; format is "<rc>:<signal>". | |
| exit_code = atf.run_command_output( | |
| f"sacct -j {job_id}.0 --noheader -P -o ExitCode", | |
| fatal=True, | |
| ).strip() | |
| assert exit_code, f"sacct returned no ExitCode for step {job_id}.0" | |
| assert exit_code.splitlines()[0] == "2:0", ( | |
| "The step should report the failing task's exit code (2:0), " | |
| f"not a peer's signal; got {exit_code!r}" | |
| ) |