fix(slurm): stop the multi-node template retrying a completed run - #720
Merged
lfengad merged 3 commits intoJul 30, 2026
Merged
Conversation
COSMOS_SHUTDOWN_ON_NO_POLICY_REPLICAS makes the controller SIGTERM itself once the last policy replica unregisters, releasing the allocation instead of idling to wall-clock. That is how a successful run ends, but the batch script saw 128+SIGTERM and could not tell it from a crash, so it requeued -- re-running finished training until the retry budget ran out, then reporting FAILED. Observed on a 2-node GCP run (job 1781412): four full 500/500-step runs over ~1.5h of allocation, final state FAILED, with every run logging a clean coordinated shutdown. The existing guard could not catch this. handle_auto_retry skips retry when received_signal == SIGTERM, but that is set only by this script's own trap -- it answers "was I cancelled?", not "how did the child exit?". A controller self-SIGTERM never signals the batch script, so the guard was never consulted. Classify at the point where the monitor already knows which process exited and with what code. Setting status=0 there is enough: handle_auto_retry already returns early on success, so the requeue, the FAILED state and the misleading sacct output all follow from the one classification. PR nvidia-cosmos#714 fixed the same misreading in launch_all.py for the single-node CLI path; this mirrors _is_coordinated_controller_exit and keeps it equally narrow. One clause is new: SLURM's wall-clock SIGTERM also reaches the controller as 143, so a job killed at its time limit would otherwise now report success -- requiring received_signal to be empty excludes both that and scancel. tests/test_slurm_multinode_exit.py drives the extracted shell functions directly, since the template is an sbatch script with no import surface. It covers both directions -- excusing too much would be worse than the original bug -- including scancel, the pre-timeout SIGUSR1, SIGKILL, ordinary crashes, and the 1/true/yes truthiness that utils/constant.py accepts. Verified to fail against the pre-fix behaviour.
The predicate tests passed with the call site deleted -- i.e. with the fix fully reverted -- so they could not catch a regression. Verified by mutation. Drive the real monitoring loop with real children instead: a controller exiting 143 must yield status=0 with the feature on, and still 143 with it off, while a crash or a policy failure is unaffected.
lfengad
approved these changes
Jul 30, 2026
Two failures, both from the test rather than the fix. The template was located by repo layout. CI copies only tests/ into the image and installs the package into site-packages, so that path resolved to nothing and every case errored with FileNotFoundError. Resolve it next to the installed module instead -- the .sh ships alongside it either way. TestMonitorLoopWiring sat below `if __name__ == "__main__": unittest.main()`, so `python tests/<file>.py` -- how run_test.sh invokes it -- registered only the first 8 tests and never reached the wiring class. pytest imports the whole module, which is why local runs showed 13 while CI ran 8: the tests added specifically to catch a revert of the fix were the ones not running. Entry point moved to the end. Verified as CI invokes it, from a foreign cwd: 13 run, and neutering the call site still fails test_controller_self_sigterm_yields_success_status.
lfengad
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
COSMOS_SHUTDOWN_ON_NO_POLICY_REPLICASmakes the controllerSIGTERMitself once the last policy replica unregisters, so the allocation is released instead of idling to wall-clock. That is how a successful run ends — but the batch script sees128+SIGTERMand cannot tell it from a crash, so it requeues and re-runs finished training until the retry budget is gone, then reportsFAILED.Observed on a 2-node GCP run (job
1781412) — four full 500/500-step runs across ~1.5 h of allocation, every one of them logging a clean shutdown:FAILEDThe cost isn't only wasted allocation:
sacctreportsFAILEDfor a run that completed, so there is no way to tell a finished job from a broken one without reading logs.Why the existing guard didn't catch it
handle_auto_retryalready skips retry on SIGTERM:But
received_signalis set only by this script's owntrap— it answers "was I cancelled?", not "how did the child exit?". A controller self-SIGTERM never signals the batch script, so the guard was never consulted.The fix
Classify at the point where the monitor already knows which process exited and with what code:
handle_auto_retryneeds no change — it already returns early onstatus -eq 0. So one classification fixes the requeue, theFAILEDstate, and the misleadingsacctoutput together.Peer reaping now runs on both paths; it previously sat under the failure log only. On a coordinated shutdown the policy replicas are already gone (that is what triggered it), but a rollout can still be draining.
Kept deliberately narrow
Mirrors
_is_coordinated_controller_exit(launcher/launch_all.py:397), which fixed the same misreading for the single-node CLI path in #714. Excusing too much would be worse than the original bug, so:${exit_code_controller}128+SIGTERMutils/constant.py(1/true/yes, case-insensitive) rather than a non-empty test, so=0stays offThat last clause is new relative to the single-node version, and it matters: SLURM's wall-clock
SIGTERMalso reaches the controller as 143. Without it, a job killed at its time limit would now report success — trading one false verdict for another.scanceland the--signal=B:SIGUSR1@...pre-timeout both setreceived_signal, so requiring it empty excludes both.Tests
tests/test_slurm_multinode_exit.py, 13 cases, registered inrun_test.sh. The template is an sbatch script with no import surface, so the functions are extracted and driven directly.The first draft tested the predicate in isolation and was not sufficient — deleting the call site, i.e. reverting the fix entirely, left all 8 predicate tests green. Confirmed by mutation. So the suite now also drives the real monitoring loop with real child processes and asserts on the resulting
status; the same mutation fails it with143 != 0.Covered in both directions:
status=0; with it off → still 143SIGKILL(137), a policy-side failure → unaffectedscanceland the pre-timeoutSIGUSR1→ still fail loudly1/true/yestruthinessutils/constant.pyacceptsCPU-only, ~1.7 s.
Verification
Unit-tested and reasoned against a real failure. Not re-run on SLURM — the fix is in the template that job
1781412exercised, but confirming end-to-end would need another multi-node allocation.