Bash – “set -e” does not terminate script when error occurs in conditional

basherror handlingshellshell-script

The following script has a syntax error or error of some sort:

#!/usr/bin/env bash
set -euo pipefail

if [ ! -f /custom.log]; then
  echo "test"
fi
abcxyz

The script fails with output of:

./test.sh: line 4: [: missing `]'
./test.sh: line 7: abcxyz: command not found

I'm not concerned how to fix this script, but how can I prevent the script from proceeding further if encounters this error? I would have thought set -e would enforce this behavior.

Best Answer

set -e doesn't trigger on failing commands that are used as conditions like in the condition section of if/while/until constructs or on the left of a ||, && or in functions, subshells, sourced files, evaled code that are invoked under those conditions.

If it did, then:

if [ ! -f /custom.log ]; then

Would exit the script if /custom.log was a regular file as [ would then also exit with a non-zero exit status.

The [ builtin command of the bash shell (and most other implementations) exits with a 1 status if the tested condition is not met, and 2 if there's a syntax error (not all syntax errors though, for instance, not in [ -v 'a[+]' ]). POSIX requires the exit status to be greater than 1 in case of error.

So you could choose to exit the script if a command exits with a code greater than 1 regardless of whether it's used in a condition or not with something like:

shopt -s extdebug # make sure the DEBUG trap propagates to subshells
trap '(($?>1 && (ret=$?))) && exit "$ret"' DEBUG
[ -f / ] || echo / not a regular file # OK
[ -f /] || echo was a syntax error # causes an exit, not output
echo not reached

Note that you can't use the ERR trap for that as the ERR trap is only run in the same condition as those that trigger the exit by set -e.

Now, beware of the implications. For instance, that would cause a:

if grep -qs pattern /file; then
  echo pattern was found in /file
fi

to exit if /file didn't exist or wasn't readable, as grep returns with a 2 status in that case, even though with -s, the intention was clearly to ignore those cases.

So you'll need to beware of upon which conditions the commands you use in your conditions may exit with a status greater than 1. To work around those, you'd need something like:

if sh -c 'grep -sq pattern / file || exit 1'; then...

You could restrict the exit upon exit status greater than 1 to the [ or test command with something like:

unset -v previous_BASH_COMMAND
trap '
  case $previous_BASH_COMMAND in
    ("[ "* | "test "*) (($?>1 && (ret=$?))) && exit "$ret"
  esac
  previous_BASH_COMMAND=$BASH_COMMAND' DEBUG

That has a few limitations. In

echo x
([ -f/]; echo y)

That would cause the subshell to exit, but not the parent as the $previous_BASH_COMMAND has not been set there. And in:

[ -f / ] && echo a regular file
(grep -qs foo /file && echo foo in /file)
echo here

The shell would be exited upon running echo here, because $? would be 2 and $previous_BASH_COMMAND was [ -f / ].

In any case, things like

[ -f /] | cat
export var="$([ -f /])"

could not be detected as the exit status is not propagated to the parent shell process (except with the pipefail option in the first case).

Now, I'm not sure it's worth the trouble adding this kind of (brittle) detection at run time, when the error is easily detectable at development time (when you write and test your script).

Related Question