| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """Test srun forced termination of a hetjob.""" | |
| import os | |
| import re | |
| import signal | |
| import pexpect | |
| import pytest | |
| import atf | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| # Hetjobs are only started by backfill; don't wait for its full cycle | |
| atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1)) | |
| atf.require_nodes(2, [("CPUs", 2)]) | |
| atf.require_slurm_running() | |
| @pytest.fixture | |
| def hetjob_with_waiting_tasks(): | |
| """Spawn a 2 component hetjob whose tasks ignore SIGINT and wait forever. | |
| Yields the pexpect child, once the task of every component has started, | |
| and reaps it afterwards even if the test fails midway. | |
| """ | |
| file_in = "hetjob_int_prog" | |
| atf.make_bash_script( | |
| file_in, | |
| """trap "" INT | |
| echo WAITING | |
| sleep infinity""", | |
| ) | |
| child = pexpect.spawn( | |
| f"srun -N1 -n1 --unbuffered ./{file_in} : -N1 -n1 --unbuffered ./{file_in}", | |
| encoding="utf-8", | |
| ) | |
| for _ in range(2): | |
| assert ( | |
| child.expect( | |
| ["WAITING", pexpect.EOF, pexpect.TIMEOUT], | |
| timeout=atf.default_command_timeout, | |
| ) | |
| == 0 | |
| ), ( | |
| "the task of every hetjob component must start before the" | |
| f" interrupts. Output:\n{child.before}" | |
| ) | |
| yield child | |
| child.close() | |
| def test_hetjob_force_terminate(hetjob_with_waiting_tasks): | |
| """Test two interrupts force terminate the tasks of every hetjob component. | |
| srun.1 documents that a second interrupt within one second sends SIGINT | |
| to all tasks and enters a termination sequence for all spawned tasks. | |
| For a hetjob that has to reach the step of every component, otherwise | |
| srun waits forever on the components it never signaled. | |
| This covers the first stage of job_force_termination(), which forwards | |
| the signal to the tasks directly. | |
| """ | |
| child = hetjob_with_waiting_tasks | |
| # Only an interrupt arriving before the deadline that the previous one | |
| # armed escalates, so wait for srun to announce it rather than racing it | |
| os.kill(child.pid, signal.SIGINT) | |
| assert ( | |
| child.expect( | |
| ["one more within 1 sec to abort", pexpect.EOF, pexpect.TIMEOUT], | |
| timeout=atf.default_command_timeout, | |
| ) | |
| == 0 | |
| ), f"srun did not arm the interrupt deadline. Output:\n{child.before}" | |
| os.kill(child.pid, signal.SIGINT) | |
| assert ( | |
| child.expect( | |
| [pexpect.EOF, pexpect.TIMEOUT], timeout=atf.default_command_timeout | |
| ) | |
| == 0 | |
| ), ( | |
| "srun did not exit after two interrupts, so the step of some hetjob" | |
| f" component was never signaled. Output:\n{child.before}" | |
| ) | |
| # Issue 50352: slurmstepd logs the signal name since 25.05 | |
| if atf.get_version("sbin/slurmd") >= (25, 5): | |
| cancelled_regex = ( | |
| r"STEP (\d+)\.\d+(?:\+\d+)? ON \S+ CANCELLED AT \S+ DUE to SIGNAL" | |
| ) | |
| else: | |
| cancelled_regex = r"STEP (\d+)\.\d+(?:\+\d+)? ON \S+ CANCELLED AT \S+ \*\*\*" | |
| cancelled_job_ids = set(re.findall(cancelled_regex, child.before)) | |
| assert len(cancelled_job_ids) == 2, ( | |
| "the termination sequence must reach the step of every hetjob" | |
| f" component, but only {sorted(cancelled_job_ids)} got it." | |
| f" Output:\n{child.before}" | |
| ) | |
| @pytest.mark.xfail( | |
| atf.get_version("bin/srun") < (26, 5, 4), | |
| reason="Issue 51060: job_force_termination() only asked slurmctld to" | |
| " terminate the step of the first hetjob component", | |
| ) | |
| def test_hetjob_force_terminate_through_slurmctld(hetjob_with_waiting_tasks): | |
| """Test the slurmctld stage of a forced termination reaches every component. | |
| job_force_termination() forwards SIGKILL to the tasks first, and only | |
| asks slurmctld to terminate the steps on a later call. A third interrupt | |
| reaches that stage, because the escalating interrupt does not re-arm the | |
| one second deadline that the first one armed. | |
| """ | |
| child = hetjob_with_waiting_tasks | |
| os.kill(child.pid, signal.SIGINT) | |
| assert ( | |
| child.expect( | |
| ["one more within 1 sec to abort", pexpect.EOF, pexpect.TIMEOUT], | |
| timeout=atf.default_command_timeout, | |
| ) | |
| == 0 | |
| ), f"srun did not arm the interrupt deadline. Output:\n{child.before}" | |
| # Identical signals are not queued, so the escalating interrupt has to be | |
| # seen handled before the next one, which still has to arrive within the | |
| # window the first one armed. Signal in process to stay inside it. | |
| os.kill(child.pid, signal.SIGINT) | |
| assert ( | |
| child.expect( | |
| ["forcing job termination", pexpect.EOF, pexpect.TIMEOUT], | |
| timeout=atf.default_command_timeout, | |
| ) | |
| == 0 | |
| ), f"srun did not force the termination. Output:\n{child.before}" | |
| os.kill(child.pid, signal.SIGINT) | |
| assert ( | |
| child.expect( | |
| [pexpect.EOF, pexpect.TIMEOUT], timeout=atf.default_command_timeout | |
| ) | |
| == 0 | |
| ), f"srun did not exit after the interrupts. Output:\n{child.before}" | |
| terminated_job_ids = set( | |
| re.findall(r"Terminating StepId=(\d+)\.\d+(?:\+\d+)?", child.before) | |
| ) | |
| assert len(terminated_job_ids) == 2, ( | |
| "the termination requested through slurmctld must cover the step of" | |
| f" every hetjob component, but only {sorted(terminated_job_ids)} got" | |
| f" it. Output:\n{child.before}" | |
| ) |