bashshellsubshellcommand-substitution

How to use `set -e` inside a bash command substitution?


I have a simple shell script with the following preamble:

#!/usr/bin/env bash
set -eu
set -o pipefail

I also have the following function:

foo() {
  printf "Foo working... "
  echo "Failed!"
  false  # point of interest #1
  true   # point of interest #2
}

Executing foo() as a regular command works as expected: The script exits at #1, because the return code of false is non-zero and we use set -e.

My goal is to capture the output of the function foo() in a variable, and only print it in case an error occurs during the execution of foo(). This is what I've come up with:

printf "Doing something that could fail... "
if a="$(foo 2>&1)"; then
  echo "Success!"
else
  code=$?
  echo "Error:"
  printf "${a}"
  exit $code
fi

The script doesn't exit at #1 and the "Success!" path of the if statement is executed. Commenting out the true at #2 causes the "Error:" path of the if statement to be executed.

It seems like bash just ignores set -e inside the substitution and the if statement is simply checking the return code of the last command in foo().

Q: What causes this weird behaviour?

A: This is just how bash works, it's normal behaviour

Q: Is there any way to make bash respect set -e inside a command substitution and make this work correctly?

A: You shouldn't use set -e for this purpose

Q: How would you go about implementing this without set -e (i.e. print the output of a function only if something went wrong while executing it)?

A: See accepted answer and my "final thoughts" section.

I am using:

GNU bash, version 5.0.11(1)-release (x86_64-apple-darwin18.6.0)

Final thoughts / takeaway (might be useful for someone else):

Beware that using if ...; then, or even && ... || ... will disable most kinds of "traditional" bash error handling methods (this includes set -e and trap ... ERR + set -o errtrace) by design. If you want to do something like I did, you probably should check the return codes inside your function manually and return a non-null exit code by hand (dangerous_command || return 1) to avoid continuing execution on errors (you can do this whether you use set -e or not).

As answered, set -e does not propagate inside command substitutions by design. If you wish to implement error handling logic which does, you can use trap ... ERR in combination with set -o errtrace, which will work with functions running inside command substitutions (that is unless you put them inside an if statement, which will disable trap ... ERR as well, so in this case manual return code checking is your only option if you wish to stop your function on errors).

If you think about it, this whole behaviour kind of makes sense: you wouldn't expect your script to terminate on a command "guarded" by an if statement, as the whole point of your if statement is checking whether the command succeeds or not.

Personally I still wouldn't go as far as avoiding set -e and trap ... ERR entirely as they can be really useful, but understanding how they behave in different circumstances is important, because they are no silver bullet either.


Solution

  • Q: How would you go about implementing this without set -e (i.e. print the output of a function only if something went wrong while executing it)?

    You may use this way by checking return value of the function:

    #!/usr/bin/env bash
    
    foo() {
      local n=$RANDOM
      echo "Foo working with random=$n ..."
      (($n % 2))
    }
    
    echo "Doing something that could fail..."
    a="$(foo 2>&1)"
    code=$?
    if (($code == 0)); then
      echo "Success!"
    else
      printf '{"ErrorCode": %d, "ErrorMessage": "%s"}\n' $code "$a"
      exit $code
    fi
    

    Now run it as:

    $> ./errScript.sh
    Doing something that could fail...
    Success!
    $> ./errScript.sh
    Doing something that could fail...
    {"ErrorCode": 1, "ErrorMessage": "Foo working with random=27662 ..."}
    $> ./errScript.sh
    Doing something that could fail...
    Success!
    $> ./errScript.sh
    Doing something that could fail...
    {"ErrorCode": 1, "ErrorMessage": "Foo working with random=31864 ..."}
    

    This dummy function code returns failure if $RANDOM is even number and success for $RANDOM being odd number.


    Original answer for original question

    You need to enable set -e in command substitution as well:

    #!/usr/bin/env bash
    set -eu
    set -o pipefail
    
    foo() {
      printf "Foo working... "
      echo "Failed!"
      false  # point of interest #1
      true   # point of interest #2
    }
    
    printf "Doing something that could fail... "
    a="$(set -e; foo)"
    code=$?
    if (($code == 0)); then
      echo "Success!"
    else
      echo "Error:"
      printf "${a}"
      exit $code
    fi
    

    Then use it as:

    ./errScript.sh; echo $?
    
    Doing something that could fail... 1
    

    However do note that using set -e is not ideal in shell scripts and it may fail to exit script in many scenarios.

    Do check this important post on set -e