Linux & Bash · Part II · Module 30 · Running Work on Shared Engineering Machines
Stopping Work Properly
Stopping a run is one command and choosing which one costs or saves the evidence you were about to debug. This chapter sends four different signals to the same simulation at the same point and measures, to the byte, what each one leaves behind — including the one the simulator ignores completely.
Intermediate22 min readLinuxPart IISignalsLogsEvidence
Part II · Module 30 · Chapter 30.4 · Stopping Work Properly
Chapter 30.3 established that the job is stuck rather than slow. Now it has to be stopped, and the choice of signal decides how much of the evidence survives — which matters, because the reason you are stopping it is usually that you want to know why.
1. Four Signals, One Simulation
A long-running simulation with waveform dumping on, allowed to run for two seconds and then sent exactly one signal:
| signal | still alive? | vcd bytes | log lines | verdict lines |
|---|---|---|---|---|
INT | SURVIVED | 50,808,945 | 118 | 0 |
TERM | died | 18,667,515 | 84 | 0 |
HUP | died | 12,165,899 | 56 | 0 |
KILL | died | 45,682,688 | 0 | 0 |
Three things in that table need separate explanation, and the second one is the chapter.
vvp survives SIGINT. That is what Ctrl-C sends. Three of them do not help:
$ ( cd sig2/INT2 && exec vvp ../../logs/dumphang.vvp > run.log 2>&1 ) & p=$!
$ sleep 2
$ for i in 1 2 3; do kill -INT $p; sleep 0.5; done
$ kill -0 $p && echo "STILL ALIVE" || echo died
STILL ALIVE
$ kill -TERM $p; sleep 1; kill -0 $p && echo alive || echo died
diedSo the thing an engineer reaches for first — Ctrl-C — does nothing to this tool, and the thing that works is kill with no options, which sends TERM. Chapter 30.1 measured the same tool declining SIGHUP's ignore request; this is the same program making its own decisions about signals, in the other direction.
No signal produced a verdict line. Every row is 0. A signalled run is always Chapter 29.5's NO-VERDICT state — which means you cannot tell from the log whether you stopped it or it died, and the only record that it was deliberate is the one you write yourself.
And KILL left zero log lines while leaving a 45 MB waveform. That is §2.
2. What a Hard Kill Costs, to the Byte
The table above has a confound: each run was stopped after two seconds but the signals arrived at slightly different points, so the file sizes are not comparable. Repeating it with exactly three seconds for both:
$ for s in TERM KILL; do
> d="sig2/$s"; rm -rf "$d"; mkdir -p "$d"
> ( cd "$d" && exec vvp ../../logs/dumphang.vvp > run.log 2>&1 ) & p=$!
> sleep 3 # identical for both
> kill -$s $p 2>/dev/null; sleep 1.5
> kill -9 $p 2>/dev/null; wait $p 2>/dev/null
> printf ' %-7s %-12s %-12s %s\n' "$s" "$(grep -c . $d/run.log)" \
> "$(wc -c < $d/run.log | tr -d ' ')" "$(wc -c < $d/dump.vcd | tr -d ' ')"
> done
SIGNAL log lines log bytes vcd bytes
TERM 292 5949 69634736
KILL 204 4096 506798083. So the Order Is TERM, Wait, Then KILL
What each signal leaves behind
#!/bin/bash
# stopjob PID [GRACE] — stop a run and keep as much evidence as possible.
set -u
pid=${1:?usage: stopjob PID [GRACE]}
grace=${2:-10}
kill -0 "$pid" 2>/dev/null || { echo "stopjob: no such process: $pid" >&2; exit 2; }
# 1. ask. INT is not used: the simulator ignores it (measured in 30.4 §1).
printf 'stopjob: sending TERM to %s, allowing %ss\n' "$pid" "$grace"
kill -TERM "$pid" 2>/dev/null
# 2. wait, so the process can flush and close what it is holding
i=0
while [ "$i" -lt "$grace" ]; do
kill -0 "$pid" 2>/dev/null || { printf 'stopjob: stopped cleanly after %ss\n' "$i"; exit 0; }
sleep 1
i=$((i + 1))
done
# 3. only now, and say what it will cost
printf 'stopjob: still alive after %ss -- sending KILL\n' "$grace" >&2
printf 'stopjob: the unflushed tail of its log will be lost\n' >&2
kill -KILL "$pid" 2>/dev/null
sleep 1
kill -0 "$pid" 2>/dev/null && { echo "stopjob: $pid survived KILL -- it is stuck in the kernel" >&2; exit 1; }
printf 'stopjob: killed\n'
exit 0$ ( cd t1 && exec vvp ../logs/dumphang.vvp > run.log 2>&1 ) & stopjob $! 10
stopjob: sending TERM to 2354, allowing 10s
stopjob: stopped cleanly after 1s
$ echo $?
0The escalation path needs a process that genuinely ignores TERM, which is three lines of C:
#include <signal.h>
#include <unistd.h>
int main(void) { signal(SIGTERM, SIG_IGN); for (;;) sleep(1); return 0; }$ /tmp/stubborn & stopjob $! 3
stopjob: sending TERM to 2406, allowing 3s
stopjob: still alive after 3s -- sending KILL
stopjob: the unflushed tail of its log will be lost
stopjob: killed
$ echo $?
0
$ kill -0 2406 2>/dev/null && echo "STILL ALIVE" || echo gone
goneAnd a pid that is not there:
$ stopjob 999999; echo "exit=$?"
stopjob: no such process: 999999
exit=2A note on how that was tested, because the obvious shell version does not work as a substitute. trap "" TERM in a bash script does protect the shell:
$ printf '#!/bin/bash\ntrap "" TERM\nsleep 60\n' > /tmp/ig.sh; chmod +x /tmp/ig.sh
$ /tmp/ig.sh & sleep 1; kill -TERM $!; sleep 1
$ kill -0 $! 2>/dev/null && echo alive || echo died
alive— but a shell script's lifetime is driven by the command it is waiting on, which makes it a muddier subject than a single process with an explicit SIG_IGN. When you need to prove an escalation path fires, use the unambiguous subject.
Four decisions.
INT is not in the sequence. §1 measured the tool ignoring it, and a signal that does nothing is worse than no signal at all: it makes the stop appear to have been attempted.
The grace period is a loop with a one-second poll, not a sleep "$grace". A job that stops in half a second should not hold the script for ten.
The cost of the KILL is printed before it is sent. The person reading the output is about to lose log lines and should know it from the tool rather than from this chapter.
A process surviving KILL is reported, not retried. SIGKILL cannot be caught, so a process still present afterwards is blocked in an uninterruptible kernel call — an unresponsive filesystem being the usual cause on a shared machine — and no amount of signalling will help. That exits 1 so the caller knows the job is still there.
4. Stopping the Right Process
Two ways to send a signal to something other than what you intended, both measured in earlier chapters and both worth restating here because this is the chapter where you actually send one.
$! is the subshell, not the tool, unless you exec:
# $! is the subshell; vvp is orphaned and keeps writing
( cd "$d" && vvp sim.vvp > run.log 2>&1 ) &
# $! is vvp
( cd "$d" && exec vvp sim.vvp > run.log 2>&1 ) &Chapter 27.7 measured the consequence: wait reported 137 as though the job had been killed while the simulator carried on from 27.8 MB to 43.6 MB of output and then wrote a verdict.
A pid can be reused. By the time a cleanup script reads a pid file and signals it, that pid may belong to something else entirely. The guard is to check identity before signalling:
# is this still the process we think it is?
want=$(cat "$dir/pid")
if ps -o command= -p "$want" 2>/dev/null | grep -q 'vvp'; then
kill -TERM "$want"
else
echo "pid $want is not our simulation any more -- not signalling" >&2
fiMeasured in both directions on a real run:
$ ( cd t2 && exec vvp ../logs/dumphang.vvp > run.log 2>&1 ) & echo $! > t2/pid
$ want=$(cat t2/pid)
$ ps -o command= -p "$want" | grep -q vvp && echo "pid $want IS our simulation"
pid 2367 IS our simulation
$ kill -9 "$want"; sleep 1
$ ps -o command= -p "$want" 2>/dev/null | grep -q vvp \
> && echo "still matches" || echo "no longer matches -- would NOT signal"
no longer matches -- would NOT signalThat check is cheap and the failure it prevents is not: a cleanup job that signals a recycled pid on a shared machine kills somebody else's work, and nothing in your logs will show that you did it.
5. Recording That the Stop Was Deliberate
§1's measurement — no signal produces a verdict — has a direct consequence: a stopped run is indistinguishable in its log from a crashed one. The fix is not technical, it is a note:
# in stopjob, before signalling
{ printf 'stopped %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf 'by %s@%s\n' "$(id -un)" "$(hostname -s)"
printf 'signal TERM then KILL after %ss\n' "$grace"
printf 'reason %s\n' "${REASON:-not given}"
} > "$dir/stopped.txt"Four lines, and they convert a NO-VERDICT run from a mystery into a record. Three weeks later the question "did this fail or did somebody stop it" has an answer, and on a shared machine the question "who stopped my job" has one too.
REASON being optional with a default of not given is deliberate: a required field would be skipped by making the script non-interactive, and an optional one that records its own absence is honest. Chapter 28.4's cleanup made the opposite choice — a marker file that cannot be satisfied by accident — because there the cost of being wrong was deleted data rather than a missing note.
And the complementary check, for whoever finds the run later:
# why does this run have no verdict?
if [ -f "$dir/stopped.txt" ]; then
echo "stopped deliberately:"; cat "$dir/stopped.txt"
elif grep -q 'progress' "$dir/run.log" 2>/dev/null; then
echo "no verdict, but it was making progress -- killed, evicted, or timed out"
tail -1 "$dir/run.log"
else
echo "no verdict and no progress -- see Chapter 29.5's three shapes"
fi6. What Survives, Summarised
Putting §1 and §2 together, the evidence after each way of stopping:
| stopped by | log | waveform | verdict | can you tell it was deliberate? |
|---|---|---|---|---|
$finish in the testbench | complete | complete | yes | n/a — it finished |
TERM, tool shuts down | complete to the moment | complete to the moment | no | only from stopped.txt |
KILL | minus the unflushed tail | complete to the moment | no | only from stopped.txt |
| the machine rebooted | minus the unflushed tail | complete to the moment | no | no |
INT | — | — | — | nothing happened |
Two readings of that table matter.
Rows three and four are identical in their artifacts. A hard kill and a power loss leave the same evidence, which is why stopped.txt is the only thing that distinguishes them — and why a run with no verdict and no note should be assumed to have died rather than been stopped.
The waveform is complete to the moment in every row. Which means Chapter 27.6's dumping decision is what determines whether a stopped run is debuggable at all: a run with no dump leaves a truncated log and nothing else, and a windowed dump leaves nothing if the window had not opened yet. On a long job that you might have to stop, whole-run dumping at a reduced depth is the arrangement that survives being interrupted — the only case in this track where the expensive option is the right default.
Practice
- Send
SIGINTto your simulator and check whether it stops. - Send three, then one
TERM, and record what each did. - Build the four-signal table for your own tool.
- Check whether any signal produced a verdict line.
- Stop the same run with
TERMand withKILLafter identical elapsed times and compare log sizes. - Confirm the
KILLlog size is a whole multiple of the buffer block, and work out how many lines that cost. - Compare the waveform sizes in the same pair and explain the asymmetry.
- Write
stopjobwith the poll loop, and confirm it exits 0 when the job stops in under a second. - Make a job ignore
TERMand confirm the script escalates and says what it will cost. - Launch without
exec, signal$!, and check whether the tool is still running. - Add the pid-identity check and confirm it refuses to signal a recycled pid.
- Check whether
setsidexists on your machine, and decide what you would do about orphans. - Write
stopped.txtand then use the complementary check to classify a stopped run. - Decide what your dumping policy should be for a job you might have to interrupt.
Check Your Understanding
- Which signal does the simulator ignore, and what sends it?
- What happened after three of them, and what worked instead?
- How many of the four signals produced a verdict line?
- What size was the log after
KILL, and why that number exactly? - How many log lines did the hard kill cost, and which ones were they?
- Why did the waveform survive while the log did not?
- Why is that the opposite of the intuition?
- Why is
INTleft out of the stop sequence? - Why a poll loop instead of
sleep "$grace"? - What does a process surviving
SIGKILLmean? - What does
execfix about signalling? - What does the pid-identity check prevent on a shared machine?
- Why does this chapter not recommend killing the process group?
- Why is
REASONoptional when the cleanup chapter's marker file is not? - Which two rows of the final table are indistinguishable, and what follows?
- Which dumping policy survives an interruption, and why is that unusual advice?
7. Remember
- The simulator survives
SIGINT— three of them did nothing. Plainkill, which sendsTERM, works. - No signal produces a verdict line. A signalled run is always a
NO-VERDICTrun. KILLleft the log at exactly 4,096 bytes againstTERM's 5,949 at identical elapsed time: 88 lines lost, and the most recent ones.- The waveform survived both. The streamed artifact is resilient and the buffered one is fragile — the opposite of the intuition.
- TERM, poll, then KILL, with the cost printed before the escalation.
- Leave
INTout. A signal the tool ignores makes the stop look attempted. - A process surviving
KILLis stuck in the kernel. Report it; do not retry. execso$!is the tool, and check the pid's identity before signalling it on a shared machine.- Do not kill the process group — your child shares your shell's group and
setsidis absent here. - Write
stopped.txt. A hard kill and a power loss leave identical artifacts, and the note is the only difference. - For a job you might interrupt, dump the whole run at reduced depth — a window that never opened leaves nothing.
8. What Is Next
Stopping and starting work by hand is bounded by how much you can keep track of. Chapter 30.5 hands the job to a scheduler instead — and measures the three things submission changes about your command, each of which breaks a script that works perfectly at your own prompt.
Where this fits
Part of the Linux & Bash curriculum.
