Five places a shell looks at its jobs

  • #jobs
  • #job-control
  • #wait
  • #bugfix

Start a job in the background, let it finish, and ask jobs. In a script, bash still lists it:

bash
# before
$ cat t.sh
sleep 0.1 & sleep 0.2 &
sleep 0.5
jobs
$ huck t.sh
$
bash
# after
$ huck t.sh
[1]-  Done                    sleep 0.1
[2]+  Done                    sleep 0.2

Nothing had gone wrong with the jobs. huck had simply forgotten them: after every command it reaped its children, decided whether to say anything, and dropped whatever had finished. bash does not work that way. It collects a dead child the moment the kernel says so, but it reports and forgets jobs at exactly five moments — when a foreground command it waited for finishes, when you run jobs, at the end of each loop iteration, in wait, and each time its parser reads a new line of input. Between those points a finished job is still there, and what bash says at each point depends on how it was started: a script file reports a background job only if a signal killed it; a -c string quietly marks a normal exit as reported and drops it.

huck now does the same, with the same five points, and the split is what makes the difference above: a script leaves a normal exit alone until jobs prints it (or wait collects it) — even a bare wait keeps the job that owns $!, as POSIX asks.

The forget-early habit had a louder consequence. Kill a background job in a plain script — no set -m — and bash tells you at the next thing it blocks on:

bash
# before
$ huck -c 'sleep 5 & kill -KILL %1; sleep 0.3; echo done'
done
bash
# after
$ huck -c 'sleep 5 & kill -KILL %1; sleep 0.3; echo done'
huck: line 1: 1906631 Killed                  sleep 5
done

huck's between-command pass had marked the death "already reported" — silently, because job control was off — and pruned it before the sleep 0.3 boundary where the notice belonged.

The third symptom was filed as a race, and was not one. % and %+ name the current job, and in bash a job stays current after it dies until it is dropped. huck resolved %+ only among running or stopped jobs, so a job that had been reaped by the time wait %+ ran was simply gone:

bash
# before
$ cat t.sh
sleep 0.1 &
sleep 0.3
wait %+
echo "rc=$?"
$ huck t.sh
huck: line 3: wait: %+: no such job
rc=127
bash
# after
$ huck t.sh
rc=0

Under the sweep this showed up as wait % returning 127 instead of 137 once in a few hundred runs — only when the reap happened to land first. Now the + and - markers are stored state, as in bash: a stop takes +, a death keeps it, and a deletion re-picks from what is stopped, then what is running.

Two smaller things fell out of reading the C rather than guessing. Job numbers: bash gives a new job the number after the highest live one, so after kill %2 the next job is %4, not %2 (huck reused the lowest free number). And kill on a job that has already finished but is still listed succeeds — bash skips the dead stages rather than failing on them.

The harness for this runs every fragment twice, as a -c string and as a script file, because the two drivers are supposed to differ. Several of its first rows failed on and off, and every one turned out to be racy in bash itself: a kill followed by builtins reports at whichever line read the signal beat; kill -9 of a stopped child then wait reports the death about half the time. The rows now pin the death to a blocking point, a busy-wait, or the wait itself.

The same five places, for coprocesses

A coprocess that has finished is the same story. huck closed its pipes and unset NAME and NAME_PID the instant the child was collected; bash only marks it dead then, and disposes of it at one of the five points. So a one-shot coproc's last line was readable in bash and gone in huck whenever anything at all ran in between — even a $( ), which is not one of the five:

bash
# before
$ huck -c 'coproc X { read l; echo "e:$l"; }; echo yo >&"${X[1]}"; x=$(sleep 0.3); read r <&"${X[0]}"; echo "[$r]"'
huck: line 1: "${X[0]}": Bad file descriptor
[]
bash
# after
$ huck -c 'coproc X { read l; echo "e:$l"; }; echo yo >&"${X[1]}"; x=$(sleep 0.3); read r <&"${X[0]}"; echo "[$r]"'
[e:yo]

That was the race behind a coproc test that failed only on fast machines.

And what a background job may ignore

Two more rows came from reading the same file. bash ignores SIGQUIT in every shell, so a QUIT aimed at a script is harmless; huck took the default and dumped core:

bash
# before
$ huck -c 'kill -QUIT $$; sleep 0.2; echo alive'
timeout: the monitored command dumped core
bash
# after
$ huck -c 'kill -QUIT $$; sleep 0.2; echo alive'
alive

And when job control is off, a command started with & ignores INT and QUIT — a stray ^C does not take the background job with it. Only the command that is the background unit gets that; a { … } & or a function called with & runs its inner commands with the defaults, exactly as bash does, which took a table of eighteen shapes to pin down.

bash
# before
$ huck -c 'sleep 2 & p=$!; sleep 0.2; kill -INT $p; wait $p; echo rc=$?'
rc=130
bash
# after
$ huck -c 'sleep 2 & p=$!; sleep 0.2; kill -INT $p; wait $p; echo rc=$?'
rc=0

Still open from this pass: the Killed notice is suppressed inside ( ) where bash prints it (#767); a background job huck runs under a wrapper subshell reports a signal death as Exit 143 (#768); jobs prints coproc X where bash prints the body (#770); and trap '' SIG is not inherited by children for any signal (#772).