The stage that vanished from PIPESTATUS
- #bash-compat
- #pipelines
- #expansion
Last time, an unset variable in a redirection ended the whole shell, and the fix
was to notice which side of bash's fork the failure happened on. Same shell,
same option, one pipe added:
# before
$ cat > count.sh <<'EOF'
set -u
grep root $PASSWD_FILE | wc -l
echo "PS=${PIPESTATUS[@]} st=$?"
EOF
$ bash count.sh
count.sh: line 2: PASSWD_FILE: unbound variable
0
PS=1 0 st=0
$ huck count.sh
count.sh: line 2: PASSWD_FILE: unbound variable
count.sh: line 2: resolve failed with code 1Three things go wrong in those two lines. The script ends where bash carries on.
resolve failed with code 1 is not a shell diagnostic at all — it is huck's own
internal plumbing, printed at the user. And in the cases where the script did
survive, the stage that failed was simply gone:
# before
$ cat > subst.sh <<'EOF'
set -u
cat ${x!} | cat
echo "PS=${PIPESTATUS[@]} st=$?"
EOF
$ huck subst.sh
subst.sh: line 2: ${x!}: bad substitution
subst.sh: line 2: resolve failed with code 1
PS=0 st=1bash reports PS=1 0 there — first stage failed, second succeeded, pipeline
status 0. huck reported a one-element PIPESTATUS describing a pipeline that
never ran, and gave the whole thing status 1. PIPESTATUS is the only place a
dead stage is still visible, since the pipeline's own status belongs to the last
stage; losing the entry loses the failure.
When, not what
bash forks every stage and does all of that stage's work in the child: expand the words, then open the redirections, then exec. huck expanded an external stage's words last — at the moment it spawned the process, after the redirections were already open. By then the failure had nowhere to go: it was past the point where a stage can be failed on its own, so it came back as an internal error and took the pipeline down.
The same ordering shows up with no failure at all, if you put a side effect on each side of the redirect:
# before
$ bash -c '/bin/echo $(echo WORD >&2) > /dev/null$(echo REDIR >&2) | cat'
WORD
REDIR
$ huck -c '/bin/echo $(echo WORD >&2) > /dev/null$(echo REDIR >&2) | cat'
REDIR
WORDBackwards — and worse than backwards when the redirection fails, because then huck never ran the word's substitution at all.
Moving that resolution to the front of the stage's setup fixes both: the words now go first, like bash's, and they land inside the same fork boundary the redirections got last time, so a failure in them fails one stage.
# after
$ huck count.sh
count.sh: line 2: PASSWD_FILE: unbound variable
0
PS=1 0 st=0
$ huck subst.sh
subst.sh: line 2: ${x!}: bad substitution
PS=1 0 st=0
$ huck -c '/bin/echo $(echo WORD >&2) > /dev/null$(echo REDIR >&2) | cat'
WORD
REDIRThe one thing that had to stay exactly where it was: the words are resolved
once. They can hold command substitutions, so resolving them a second time
would run them a second time — and the lastpipe stage, which bash runs in the
shell rather than a child, is deliberately left to be resolved by the path that
already handles it.
What is still open
From the same family: a redirect word whose expansion already failed is still
opened, adding a stray : No such file or directory
(#754); an in-process stage
reports a redirect error one line early
(#755); time is not
implemented as a reserved word
(#756); and when huck quotes a
word back at you, a redirection inside a $( ) goes missing —
$(echo X 1>&2) prints as $(echo X)
(#761).