six demon bag

Wind, fire, all that kind of thing!

2020-03-23

Shell Patterns (3) - Structured Output

This is a short series describing some Bash constructs that I frequently use in my scripts.

On Linux (and many other operating systems) it's common to have regular and error output written to stdout and stderr respectively. In shell scripts you'd use the echo or printf commands for displaying messages, and redirect stdout to stderr for having the message displayed on stderr.

echo 'foo'       # output goes to stdout
echo 'bar' 1>&2  # output goes to stderr

There may be different levels of information that you want to separate from each other, though, like having additional debug output that you don't want to pollute stdout or stderr. For that you can use the file descriptors 3 through 9.


echo 'debug message' 1>&3

However, for output on these file descriptors you need to tell Bash what to do with it, otherwise running the script will produce the following error:

Bad file descriptor

To avoid this error redirect the output to a file when running the script:

./script.sh 3> /var/tmp/debug.log

or redirect it from within the script via the exec command:

#!/bin/bash
exec 3> /var/tmp/debug.log
echo 'debug message 1>&3

Alternatively you can merge the file descriptor into stdout to have the debug output displayed in the console.

#!/bin/bash
exec 3>&1
echo 'debug message 1>&3

I know it sounds silly to separate output streams only to merge them again right away, but bear with me here. Let's say, when you run the script interactively you want debug output to go to the console, but when you're running the script in the background you want debug output in a file. Using an environment variable you can discriminate between these two cases by sending debug output either to a path defined in that variable, or to the console if the variable is empty.

if [ -z "${DEBUG_LOG:-}" ]; then
  exec 3>&1
else
  exec 3>"$DEBUG_LOG"
fi

Beware that if you're using the above code in several scripts that invoke each other (e.g. because you put the code in a library script that every script dot-sources) each script will truncate the log file and thus destroy all output that has been written before! Check if the file descriptor is already being redirected to avoid that:

if [ -z "${DEBUG_REDIRECTION:-}" ]; then
  if [ -z "${DEBUG_LOG:-}" ]; then
    exec 3>&1
  else
    exec 3>"$DEBUG_LOG"
  fi
  export DEBUG_REDIRECTION="$(readlink /proc/$$/fd/3 | grep -v '^pipe:')"
fi

Wrap the statements for displaying output in functions, so you can use "speaking" names and also avoid cluttering your code with redirections:

msg() {
  if [ -n "${1:-}" ]; then
    echo -e "$1"
  fi
}

warn() {
  msg "${1:-}" 1>&2
}

Functions can also be used to make output conditional and/or decorate it:

debug() {
  if [ "$DEBUG" = 'y' ]; then
    echo "${1:+$(date +"$DATEFMT") DEBUG ${FUNCNAME[1]}()\t$1}" 1>&3
  fi
}

Hence my scripts usually contain a section like this:

DEBUG="${DEBUG:-n}"

if [ "${DEBUG,,}" = 'y' ] && [ -z "${DEBUG_REDIRECTION:-}" ]; then
  if [ -z "${DEBUG_LOG:-}" ]; then
    exec 3>&1
  else
    exec 3>"$DEBUG_LOG"
  fi
  export DEBUG_REDIRECTION="$(readlink /proc/$$/fd/3 | grep -v '^pipe:' || true)"
fi

msg() {
  if [ -n "${1:-}" ]; then
    echo -e "$1"
  fi
}

warn() {
  msg "${1:-}" 1>&2
}

fail() {
  warn "${1:+${FUNCNAME[1]}()\t$1}"
  exit "${2:-1}"
}

debug() {
  if [ "${DEBUG,,}" = 'y' ]; then
    msg "${1:+$(date +"$DATEFMT") DEBUG ${FUNCNAME[1]}()\t$1}" 1>&3
  fi
}

Posted 15:22 [permalink]