25

I'm observing some weird behavior when using set -e (errexit), set -u (nounset) along with ERR and EXIT traps. They seem related, so putting them into one question seems reasonable.

1) set -u does not trigger ERR traps

  • Code:

    #!/bin/bash
    trap 'echo "ERR (rc: $?)"' ERR
    set -u
    echo ${UNSET_VAR}
    
  • Expected: ERR trap gets called, RC != 0
  • Actual: ERR trap is not called, RC == 1
  • Note: set -e does not change the result

2) Using set -eu the exit code in an EXIT trap is 0 instead of 1

  • Code:

    #!/bin/bash
    trap 'echo "EXIT (rc: $?)"' EXIT
    set -eu
    echo ${UNSET_VAR}
    
  • Expected: EXIT trap gets called, RC == 1
  • Actual: EXIT trap is called, RC == 0
  • Note: When using set +e, the RC == 1. The EXIT trap returns the proper RC when any other command throws an error.
  • Edit: There is a SO post on this topic with an interesting comment suggesting that this might be related to the Bash version being used. Testing this snippet with Bash 4.3.11 results in an RC=1, so that's better. Unfortunately upgrading Bash (from 3.2.51) on all hosts is not possible at the moment, so we have to come up with some other solution.

Can anyone explain either of these behaviors?

Searching these topics was not very successful, which is rather surprising given the number of posts on Bash settings and traps. There is one forum thread, though, but the conclusion is rather unsatisfying.

  • 2
    From 4 on I think bash broke with the standard and started putting traps in subshells. The trap is supposed to be executed in the same environment from whence came the return, but bash hasn't done that for quite awhile. – mikeserv Jun 7 '15 at 17:10
  • 1
    Wait a minute - do you want a solution or an explanation? And if you want a solution, then a solution to what exactly? What is it you want to occur? set -e and set -u are both designed specifically to kill a scripted shell. Using them in conditions which might trigger their application will kill a scripted shell. There's no getting around that, except to not use them, and instead to test for those conditions when they apply in a code sequence. So, basically, you can write good shell-code, or you can use set -eu. – mikeserv Jun 14 '15 at 4:35
  • 2
    Actually, I'm looking for both, as I couldn't find sufficient information on why -u would not trigger the ERR trap (it's an error, so shouldn't it trigger the trap) or the error code is 0 instead of 1. The latter seems to be a bug what has already been fixed in later version, so that's that. But the first part is quite hard to understand if you haven't realized that errors in shell evaluation (parameter expansion) and actual errors in commands seem to be two different things. For the solution, well, as you suggested, I'm now trying to avoid -eu and check manually when it's necessary. – dvdgsng Jun 15 '15 at 16:36
  • @dvdsng - Good. That's the way to go - you should post your script when you do as an answer and award yourself the bounty. I really dislike those options - they don't allow for exception handling in any safe way. – mikeserv Jun 15 '15 at 20:17
  • 1
    @dvdsng - where either of those options can be useful, though, is in a subshelled context. And so it is conceivable that whatever you were using them for before could be localized into a subshell context like: (set -u; : $UNSET_VAR) and similar. This kind of stuff can be good too - you can drop a lot of &&'s occasionally: (set -e; mkdir dir; cd dir; touch dirfile), if you get my drift. It's just that those are controlled contexts - when you set them as global options, you lose control and become controlled. There are usually more efficient solutions, though. – mikeserv Jun 16 '15 at 23:43
14
+50

From man bash:

  • set -u
    • Treat unset variables and parameters other than the special parameters "@" and "*" as an error when performing parameter expansion. If expansion is attempted on an unset variable or parameter, the shell prints an error message, and, if not -interactive, exits with a nonzero status.

POSIX states that, in the event of an expansion error, a non-interactive shell shall exit when the expansion is associated with either a shell special builtin (which is a distinction bash regularly ignores anyway, and so maybe is irrelevant) or any other utility besides.

  • Consequences of Shell Errors:
    • An expansion error is one that occurs when the shell expansions defined in Word Expansions are carried out (for example, "${x!y}", because ! is not a valid operator); an implementation may treat these as syntax errors if it is able to detect them during tokenization, rather than during expansion.
    • [A]n interactive shell shall write a diagnostic message to standard error without exiting.

Also from man bash:

  • trap ... ERR
    • If a sigspec is ERR, the command arg is executed whenever a pipeline (which may consist of a single simple command), a list, or a compound command returns a non-zero exit status, subject to the following conditions:
      • The ERR trap is not executed if the failed command is part of the command list immediately following a while or until keyword...
      • ...part of the test in an if statement...
      • ...part of a command executed in a && or || list except the command following the final && or ||...
      • ...any command in a pipeline but the last...
      • ...or if the command's return value is being inverted using !.
    • These are the same conditions obeyed by the errexit -e option.

Note above that the ERR trap is all about the evaluation of some other command's return. But when an expansion error occurs, there is no command run to return anything. In your example, echo never happens - because while the shell evaluates and expands its arguments it encounters an -unset variable, which has been specified by explicit shell option to cause an immediate exit from the current, scripted shell.

And so the EXIT trap, if any, is executed, and the shell exits with a diagnostic message and exit status other than 0 - exactly as it should do.

As for the rc: 0 thing, I expect that is a version specific bug of some kind - probably to do with the two triggers for the EXIT occurring at the same time and the one getting the other's exit code (which should not occur). And anyway, with an up-to-date bash binary as installed by pacman:

bash <<\IN
    printf "shell options:\t$-\n"
    trap 'echo "EXIT (rc: $?)"' EXIT
    set -eu
    echo ${UNSET_VAR}
IN

I added the first line so you can see that the shell's conditions are those of a scripted shell - it is not interactive. The output is:

shell options:  hB
bash: line 4: UNSET_VAR: unbound variable
EXIT (rc: 1)

Here are some relevant notes from recent changelogs:

  • Fixed a bug that caused asynchronous commands to not set $? correctly.
  • Fixed a bug that caused error messages generated by expansion errors in for commands to have the wrong line number.
  • Fixed a bug that caused SIGINT and SIGQUIT to not be trappable in asynchronous subshell commands.
  • Fixed a problem with interrupt handling that caused a second and subsequent SIGINT to be ignored by interactive shells.
  • The shell no longer blocks receipt of signals while running trap handlers for those signals, and allows most trap handlers to be run recursively (running trap handlers while a trap handler is executing).

I think it is either the last or the first that is most relevant - or possibly a combination of the two. A trap handler is by its very nature asynchronous because its whole job is to wait for and handle asynchronous signals. And you trigger two simultaneously with -eu and $UNSET_VAR.

And so maybe you should just update, but if you like yourself, you'll do it with a different shell altogether.

  • Thanks for the explanation of how parameter expansion is handled differently. That cleared up a lot of things to me. – dvdgsng Jun 15 '15 at 16:38
  • I'm granting you the bounty because your explanation was most helpful. – dvdgsng Jun 19 '15 at 9:48
  • @dvdgsng - Gracias. Out of curiosity, did you ever come up w/ your solution? – mikeserv Jun 19 '15 at 9:49
8

(I'm using bash 4.2.53). For part 1, the bash man page just says "An error message will be written to the standard error, and a non-interactive shell will exit". It doesn't say an ERR trap will be called, though I agree it would be useful if it did.

To be pragmatic, if what you really want is to cope more cleanly with undefined variables, a possible solution is to put most of your code inside a function, then execute that function in a sub-shell and recover the return code and stderr output. Here's an example where "cmd()" is the function:

#!/bin/bash
trap 'rc=$?; echo "ERR at line ${LINENO} (rc: $rc)"; exit $rc' ERR
trap 'rc=$?; echo "EXIT (rc: $rc)"; exit $rc' EXIT
set -u
set -E # export trap to functions

cmd(){
 echo "args=$*"
 echo ${UNSET_VAR}
 echo hello
}
oops(){
 rc=$?
 echo "$@"
 return $rc # provoke ERR trap
}

exec 3>&1 # copy stdin to use in $()
if output=$(cmd "$@" 2>&1 >&3) # collect stderr, not stdout 
then    echo ok
else    oops "fail: $output"
fi

On my bash I get

./script my stuff; echo "exit was $?"
args=my stuff
fail: ./script: line 9: UNSET_VAR: unbound variable
ERR at line 15 (rc: 1)
EXIT (rc: 1)
exit was 1

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.