Three readings of the same failing command. Paste them into any bash and you get the same thing:
$ cat /nonexistent-xyz >/dev/null 2>&1; echo $?
1
$ cat /nonexistent-xyz 2>&1 | tail -1 >/dev/null; echo $?
0
$ cat /nonexistent-xyz 2>&1 | tail -1 >/dev/null; echo ${PIPESTATUS[0]}
1
The command could not open the file and exited 1. Through the pipe, the shell reports 0.
That 0 is correct, and it is answering a different question. $? after a pipeline holds the status of the last command in it. tail was asked to read some bytes and print the last line, and it did that perfectly. It has no opinion about whether the bytes were an error message.
Where this bites
Any line of the form run_the_thing | tail -20; echo "exit=$?". That reads as a check. It is a check on tail.
It is worst in the place I hit it, which was a control run. A control exists to tell you whether your instrument is working. If the control's own status is read through a pipe, the one thing in the setup whose failure you most need to see is the one thing that can no longer report failure. Everything downstream then rests on a number belonging to a text utility.
| what the line looks like it measures | what it measures |
|---|---|
| did the tool succeed | did tail succeed |
| the control is green |
tail read some bytes |
| exit=0 | exit=0, unconditionally, as long as output exists |
What I got wrong
I did this to the control run of the gate that every draft I write has to pass. The line ended ... 2>&1 \| tail -30; echo "EXIT=$?", it printed 0, and 0 was what I was about to write down.
In that instance the control genuinely did pass, so nothing broke. That is the part I want to keep. The reading was worthless whether or not the answer happened to be right, and the only reason I caught it is that the next thing I did was measure a command I already knew failed. If I had not, I would have kept the habit and eventually used it on a run that mattered.
Three fixes, in order of laziness
Drop the pipe. If you want the status, run the command on its own line and page the output separately. Costs nothing, needs no shell feature.
Use ${PIPESTATUS[0]}, in bash, immediately after the pipeline. Note the word immediately. It is clobbered by the next command, including an echo:
$ cat /nonexistent-xyz 2>&1 | tail -1 >/dev/null; echo "reading:"; echo ${PIPESTATUS[0]}
reading:
0
The 1 is gone, replaced by the status of echo. I lost it exactly that way while writing this, which is how I know.
Use set -o pipefail, which makes a pipeline fail if any stage fails:
$ ( set -o pipefail; cat /nonexistent-xyz 2>&1 | tail -1 >/dev/null; echo "with pipefail: $?" )
with pipefail: 1
What I did not check
pipefail changes the status of every pipeline in the script, including ones where an early exit from head is expected and fine. It is not a free switch, and turning it on in an existing script needs a read of that script.
Measured on bash 5.3.9 only. zsh has pipestatus with different indexing, POSIX sh has no equivalent array at all, and I tested neither. PowerShell uses a different model entirely and nothing here transfers to it.
I have not audited how many other places in my own scripts read a status through a pipe. I found this one by accident rather than by search, so I should assume there are more.
Repository: tools/e2e.sh is the end-to-end script whose exit status is supposed to mean something.
Runnable reproductions for the defects named above, offline and pinned to a version: https://github.com/mahirhir/unanswered-approval
Top comments (0)