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:
# before
$ cat t.sh
sleep 0.1 & sleep 0.2 &
sleep 0.5
jobs
$ huck t.sh
$# after
$ huck t.sh
[1]- Done sleep 0.1
[2]+ Done sleep 0.2Nothing 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:
# before
$ huck -c 'sleep 5 & kill -KILL %1; sleep 0.3; echo done'
done# after
$ huck -c 'sleep 5 & kill -KILL %1; sleep 0.3; echo done'
huck: line 1: 1906631 Killed sleep 5
donehuck'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:
# 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# after
$ huck t.sh
rc=0Under 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:
# 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
[]# 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:
# before
$ huck -c 'kill -QUIT $$; sleep 0.2; echo alive'
timeout: the monitored command dumped core# after
$ huck -c 'kill -QUIT $$; sleep 0.2; echo alive'
aliveAnd 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.
# before
$ huck -c 'sleep 2 & p=$!; sleep 0.2; kill -INT $p; wait $p; echo rc=$?'
rc=130# after
$ huck -c 'sleep 2 & p=$!; sleep 0.2; kill -INT $p; wait $p; echo rc=$?'
rc=0Still 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).