Repository navigation
SIGTERM (e.g. a SLURM time limit) leaves info.json empty #459
Description
duct handles SIGINT but not SIGTERM. A duct process that gets SIGTERM dies immediately, so info.json is never written: it stays the empty file duct creates at startup. usage.jsonl keeps the samples written so far, but the run has no summary, and con-duct ls shows nothing for it. These are exactly the runs that most need a post-mortem: SLURM time limits, scancel, node drains, a plain kill.
Reproducer
con-duct run -p demo/ sleep 300 &
sleep 3; kill -TERM $!; wait $!; echo "duct exit: $?"
ls -l demo/duct exit: 143
-rw-r--r--. 1 austin austin 0 Oct 5 15:23 info.json
-rw-r--r--. 1 austin austin 0 Oct 5 15:23 stderr
-rw-r--r--. 1 austin austin 0 Oct 5 15:23 stdout
-rw-r--r--. 1 austin austin 424 Oct 5 15:23 usage.jsonl
The sleep 300 child is also left running: duct does not pass the signal on, and in the default new-session mode the child is in its own session, so even a TERM to duct's process group misses it. (Reproduced on current main.)
Seen in the wild
An fMRIPrep job under SLURM (via babs), duct singularity run ..., --time 6-00:00:00, hit its time limit. On that cluster (ProctrackType = proctrack/cgroup, KillWait = 30 sec) slurmstepd logged CANCELLED ... DUE TO TIME LIMIT; with that config SLURM signals every process in the job's cgroup directly, TERM and then KILL 30 s later. Result: info.json 0 bytes with the job-start mtime, usage.jsonl (69 MB) and stdout/stderr intact, so six days of compute with no summary.
One detail we cannot yet explain: duct's last usage.jsonl record is 7 s after slurmstepd's cancel message (same node clock), so duct was still alive briefly after the cancel. Possibly just the gap between the message and the signal delivery.
Possible approach
Not a decided design, for discussion:
- Handle SIGTERM. Forward it to the child (to its process group in
new-sessionmode, so the whole tree gets it), then let the normal wait-and-finalize path run. This is also what Implement "attach" command and graceful exiting #407 (attach) assumes: a plainkillshould shut duct down gracefully. - Do not depend on the child exiting in time. Under SLURM the child gets TERM too, and duct has only
KillWait(often 30 s) before KILL; a large job will often not exit by then. So the handler could write a provisionalinfo.jsonimmediately on TERM, before forwarding and waiting, and overwrite it if the child exits in time. - Never leave
info.jsonempty. Write it at startup with what is already known (command, start time, system info, a "running" marker) and rewrite it at the end, both atomically (temp file + rename). That also covers SIGKILL, OOM kills and node failures, which no handler can catch. This changes what readers ofinfo.json(ls,plot) must expect, so it is the part that needs a decision.
Related: #184 (Ctrl-C handling, where the current SIGINT handler came from), #407 (attach and graceful exit), #130 (SLURM sacct info, the only summary left for such a run today).
Testing idea (from @yarikoptic)
A pytest fixture that brings up SLURM in a container (as babs's CI does with a slurm-docker image), submits a duct-wrapped job, and asserts info.json is written. SLURM's minimum --time is one minute, so either wait out a 1-minute limit (closest to the real case) or scancel --signal=TERM <job> a few seconds in, which tests the handler without waiting.
🤖 Generated with Claude Code
Activity
asmacdo commentedon Oct 8, 2026
The fix for this should also cover a related gap that has no issue of its own: duct forwards a signal only to the command's pid, not to its process group, so in the default
new-sessionmode a grandchild never gets it. Same code path (SigIntHandler) and the same "forward to the group" answer, so one signal-propagation PR for both, on top of #456.The killpg piece
Observed (2026-04-01):
duct babs status --waitignored two Ctrl-Cs and kept looping; only the third (SIGKILL) stopped it, exit 137. Chain: duct →bash→ python.os.kill(pid, SIGINT)reaches onlybash, which does not pass it on to its foreground child, so python'sKeyboardInterrupthandling never runs. The terminal can't help either: innew-sessionmode the command is not in the terminal's foreground process group.Fix direction (commit b917fdc on the
sigint-investigationbranch, written before #456 and not yet rebased onto it):new-sessionmode the command leads its own process group, so forward withos.killpginstead ofos.kill: every press, including the third-press SIGKILL, reaches the whole tree.current-sessionmode: there the command shares duct's group, andkillpgwould signal duct itself.ProcessLookupErrorcaught (since done separately in Fix Ctrl-C being lost or crashing duct #456).To work out when rebasing onto #456:
SigIntHandler(installed beforePopen,attach(pid)) needs the mode at attach time.os.killpg(os.getpgid(pid), ...).getpgidfails once the leader is reaped, though the rest of its group may still be alive; innew-sessionmode the pgid equals the pid, soos.killpg(pid, ...)may reach survivors that the current version misses (not yet tested).start_ductand sleeps before signalling; rewrite it in the Fix Ctrl-C being lost or crashing duct #456 style.🤖 Generated with Claude Code