Redirection and Pipes · about 40 min

Pipeline exit status, tee and process substitution

Why this matters

Pipelines are so convenient that they end up in scripts that run unattended, and that raises questions the interactive user never notices. If the first command of a pipeline fails, does anyone find out? How do you keep a copy of data flowing through the middle of a pipeline? And what do you do with a program like diff that wants two files, when what you have is the output of two commands? This lesson answers all three.

The exit status of a pipeline

A pipeline runs several processes, so which status ends up in $?? The answer: the last command's.

$ false | true
$ echo $?
0
$ grep ERROR /nope | wc -l
grep: /nope: No such file or directory
0
$ echo $?
0

The second example is the dangerous one: grep failed to open the file, but the pipeline "succeeded" because wc did. In a script, if grep ... | wc -l or cmd | tee log will happily report success after the important part failed.

bash keeps the status of every stage in a special array variable, PIPESTATUS (arrays come in module 15; for now just use this syntax):

$ grep ERROR /nope | wc -l
grep: /nope: No such file or directory
0
$ echo "${PIPESTATUS[@]}"
2 0

${PIPESTATUS[0]} is the first command's status, ${PIPESTATUS[1]} the second's, and so on. Like $?, it is overwritten by the next command, so read it immediately. (Even echo resets it: the second echo "${PIPESTATUS[@]}" in a row would print just 0.)

The other tool is a shell option:

$ set -o pipefail
$ false | true
$ echo $?
1
$ set +o pipefail

With pipefail on, the pipeline's status is that of the last command that failed (or 0 if all succeeded). Most careful scripts turn it on at the top; module 12 discusses it with its siblings set -e and set -u, including one surprise: with pipefail, producer | head -n 1 may fail because head quitting early kills the producer with SIGPIPE (status 141).

Why does bash default to the last command? Because the shell's own status logic predates pipefail, and because often the last command really is the one that decides: grep -q at the end of a pipeline answers "was it found?".

tee: a T-junction for data

tee copies its standard input to standard output and to one or more files. The name comes from a T-shaped pipe fitting:

              ┌──────────▶ sorted.txt
  sort -u ───▶│ tee
              └──────────▶ stdout ───▶ wc -l
$ sort -u names.txt | tee sorted.txt | wc -l
8
$ head -n 3 sorted.txt
alice
bob
carol

Uses:

Process substitution: commands that look like files

Some programs need file names, not stdin. diff compares two files; it has only one standard input. You could write temporary files:

sort expected.txt > /tmp/a
sort installed.txt > /tmp/b
diff /tmp/a /tmp/b
rm /tmp/a /tmp/b

bash has a better way, process substitution:

$ diff <(sort expected.txt) <(sort installed.txt)
3d2
< htop
5a5
> nano

<(command) runs command with its stdout connected to a pipe, and is replaced on the command line by a file name that refers to the read end of that pipe. You can see the name:

$ echo <(true)
/dev/fd/63

So diff actually received the arguments /dev/fd/63 and /dev/fd/62. When diff opens /dev/fd/63, Linux gives it the pipe that bash attached to fd 63, and diff reads sort's output as if it were a file. diff never knows. In the diff output, 3d2 < htop means "line 3 of the first input (htop) would have to be deleted", 5a5 > nano means "nano was added".

The mirror form >(command) gives a file name to write to, feeding a command's stdin:

./generate-report | tee >(gzip > report.gz) > report.txt

Here tee writes one copy into the gzip process and one into report.txt.

Limitations worth knowing:

Common mistakes

Exercises

A quiz on pipeline status, two tasks (tee and process substitution) and a script that has to report the status of the first stage of a pipeline.

Recap

Going further: compare ls /etc | head -n 2; echo "${PIPESTATUS[@]}" with seq 1 1000000 | head -n 2; echo "${PIPESTATUS[@]}". The second shows 141 for seq: 128 plus signal 13, SIGPIPE.

Exercises

Exercise 1 · quiz

Whose status?

What does this print? Type the number.

false | true; echo $?

Answer
  1. The exit status of a pipeline is the status of its last command, here true. The

failure of false is invisible in $?. bash records every stage in the PIPESTATUS array (echo ${PIPESTATUS[@]} right after the pipeline prints 1 0), and set -o pipefail makes the pipeline fail if any stage fails. This is why a script doing curl ... | tar x can "succeed" after a failed download unless pipefail is on.

Exercise 2 · task in a shell

Save the middle of a pipeline

Open the shell. /work/names.txt holds one name per line, unsorted, with some names repeated.

Write one pipeline that

  1. sorts the names and removes duplicates with sort -u,
  2. saves that sorted, de-duplicated list in /work/sorted.txt using tee, and
  3. counts the lines with wc -l, saving just the number in /work/count.txt.
Hint 1

tee FILE copies its stdin both to FILE and to its stdout, so it can sit in the middle of a pipeline.

Hint 2

sort -u names.txt | tee sorted.txt | wc -l > count.txt

Reference solution
sort -u names.txt | tee sorted.txt | wc -l > count.txt

Exercise 3 · task in a shell

Compare two lists without temporary files

/work/expected.txt and /work/installed.txt are two lists of package names, each in a different random order. Use diff with process substitution to compare the two lists after sorting each, and save diff's output in /work/diff.txt.

In other words, the result must be what diff prints when it compares sorted expected.txt (first) with sorted installed.txt (second). Do not create any other files.

Hint 1

<(command) is replaced by a file name (like /dev/fd/63) from which you can read command's output.

Hint 2

diff <(sort expected.txt) <(sort installed.txt) > diff.txt. diff exits with status 1 when the files differ; that is normal.

Reference solution
diff <(sort expected.txt) <(sort installed.txt) > diff.txt
true   # diff exits with 1 when the inputs differ; that is expected here

Exercise 4 · write code

Report the hidden status

Write a script that reads a log from standard input and

  1. prints the number of lines containing ERROR, using the pipeline grep ERROR | wc -l,
  2. then prints grep status: N on a second line, where N is the exit status of grep (the first command of that pipeline), not of wc.

grep exits with 0 when it found matching lines and 1 when it found none, so for a log without errors the output is:

0
grep status: 1

File: script.sh

Starting point

grep ERROR | wc -l
echo "grep status: $?"

Example

input

INFO start
ERROR disk
INFO retry
ERROR disk again

expected output

2
grep status: 0

Example

input

INFO start
INFO stop

expected output

0
grep status: 1
Hint 1

$? is the status of the last command of the pipeline, wc, which always succeeds.

Hint 2

bash puts every stage's status in the array PIPESTATUS; the first stage is ${PIPESTATUS[0]}. Read it in the very next command.

Reference solution
grep ERROR | wc -l
echo "grep status: ${PIPESTATUS[0]}"