2

How do I get -e / errexit to work in bash functions, so that the first failed command* within a function causes the function to return with an error code (just as -e works at top-level).

* not part of boolean expression, if/elif/while/etc etc etc

I ran the following test-script, and I expect any function containing f in its name (i.e. a false line in its source) to return error-code, but they don't if there's another command after. Even when I put the function body in a subshell with set -e specified again, it just blindly steamrolls on through the function after a command fails instead of exiting the subshell/function with the nonzero status code.

Environments / interpreters tested:

I get the same result in all of these envs/shells, which is to be expected I guess unless one was buggy.

Arch:

  • bash test.sh (bash 5.1)
  • zsh test.sh (zsh 5.8)
  • sh test.sh (just a symlink to bash)

Alpine:

  • docker run -v /test.sh:/test.sh alpine:latest sh /test.sh (BusyBox 1.34)

Ubuntu:

  • docker run -v /test.sh:/test.sh ubuntu:21.04 sh /test.sh
  • docker run -v /test.sh:/test.sh ubuntu:21.04 bash /test.sh (bash 5.1)
  • docker run -v /test.sh:/test.sh ubuntu:21.04 dash /test.sh (bash 5.1)

Test script

set -e

t() {
    true
}

f() {
    false
}

tf() {
    true
    false
}

ft() {
    false
    true
}

et() {
    set -e
    true
}

ef() {
    set -e
    false
}

etf() {
    set -e
    true
    false
}

eft() {
    set -e
    false
    true
}

st() {( set -e
    true
)}

sf() {( set -e
    false
)}

stf() {( set -e
    true
    false
)}

sft() {( set -e
    false
    true
)}

for test in t f tf ft _ et ef etf eft _ st sf stf sft; do
    if [ "$test" = '_' ]; then
        echo ""
    elif "$test"; then
        echo "$test: pass"
    else
        echo "$test: fail"
    fi
done

Output on my machine

t: pass
f: fail
tf: fail
ft: pass

et: pass
ef: fail
etf: fail
eft: pass

st: pass
sf: fail
stf: fail
sft: pass

Desired output

Without significantly changing source in functions themselves, i.e. not adding an if/then or || return to every line in the function.

t: pass
f: fail
tf: fail
ft: fail

et: pass
ef: fail
etf: fail
eft: fail

st: pass
sf: fail
stf: fail
sft: fail

Or at least pass/fail/fail/fail in one group, so I can use that approach for writing robust functions.

With accepted solution

With the accepted solution, the first four tests give the desired result. The other two groups don't, but are irrelevant since those were my own attempts to work around the issue. The root cause is that the "don't errexit the script when if/while-condition fails" behaviour propagates into any functions called in the condition, rather than just applying to the end-result.

Mark K Cowan
  • 1,755
  • 1
  • 20
  • 28
  • You don't. Encapsuation requires that if `ft`, for example, were supposed to fail if any of its commands fails, it would have been written to do so. It's not up to the caller to determine how it behaves. – chepner Feb 04 '22 at 14:38
  • So essentially, there's no way to make a bash function fail (instead of blindly steamrolling ahead) if a command within it fails, aside from peppering if/else or ||return on every statement? – Mark K Cowan Feb 04 '22 at 14:44
  • You can set these flags locally inside of a function, at least for /bin/sh that works. – Ulrich Eckhardt Feb 04 '22 at 18:13
  • @UlrichEckhardt I added more tests with more shells including `sh` and `zsh`, and on several distros. Same result in all cases, as expected. – Mark K Cowan Feb 04 '22 at 19:45

1 Answers1

3

How to make errexit behaviour work in bash functions

Call the function not inside if.

I expect any function containing f in its name (i.e. a false line in its source) to return error-code

Weeeeeell, your expectancy does not match reality. It is not how it is implemented. And flag errexit will exit your script, not return error-code.

Desired output

You can:

  • Download Bash or other shell sources or write your own and implement the behavior that you want to have.

    • consider extending the behavior with like shopt -s errexit_but_return_nonzero_in_if_contexts or something shorter
  • Run it in a separate shell.

    elif bash -ec "$(declare -f "$test"); $test"; then
    
  • Write a custom loadable to "clear" CMD_IGNORE_RETURN flag when Bash enters contexts in which set -e should be ignored. I think for that static COMMAND *currently_executing_command; needs to be extern and then just currently_executing_command.flags &= ~CMD_IGNORE_RETURN;.

  • You can do a wrapper where you temporarily disable errexit and get return status, but you have to call it outside of if:

errreturn() {
    declare -g ERRRETURN
    local flags=$(set +o)
    set +e
    (
        set -e
        "$@"
    )
    ERRRETURN=$?
    eval "$flags"
}

....
        echo ""
    else
        errreturn "$test"
        if (( ERRRETURN == 0 )); then
            echo "$test: pass"
        else
            echo "$test: fail"
        fi
    fi
KamilCuk
  • 120,984
  • 8
  • 59
  • 111
  • 2
    Thanks, moving the function call out of the `if` (plus the wrapping for `±e` to get status code) got things working. I didn't realise that the "don't bail on errors" aspect of commands in if/elif/while would propagate into functions. – Mark K Cowan Feb 05 '22 at 11:20
  • Could you elaborate more on `Write a custom loadable to "clear" CMD_IGNORE_RETURN flag when Bash enters contexts in which set -e should be ignored. ` - does that require recompiling bash? – balupton Aug 08 '23 at 17:30
  • What exactly would you want to have elaborated? `does that require recompiling bash?` No idea, one would want to have invested his time into trying to do that. It is just an idea on how it maybe to possible to implement it. Maybe it is possible to write a bash builtin to wrap another command to still enable set -e when executing inside if. I do not know if that is possible. It is up to the person that would want to try to implement it to find a way. Having spend 1 minute thinking about it, I do not see a way without recompiling bash, as `currently_executing_command` is static. – KamilCuk Aug 08 '23 at 17:44