Skip to content

Instantly share code, notes, and snippets.

@balupton
Last active September 1, 2023 20:22
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save balupton/21ded5cefc26dc20833e6ed606209e1b to your computer and use it in GitHub Desktop.
Save balupton/21ded5cefc26dc20833e6ed606209e1b to your computer and use it in GitHub Desktop.
Stack Overflow Question 76861137 — How to prevent nested function failures being missed in bash?

Exit Status and Bash

I'm @balupton, author of dorothy which is the largest public bash codebase that I am aware of.

Dorothy is coded using set -e (errexit) to avoid || return $? statements on every single one of its thousands of lines of code, for the most part this has worked well, however I was suprised when the down command would report a failure via:

However it would also report a success within the called function:

Asking stackoverflow and asking the bug-bash mailing list revealed this pecularity with errexit was at fault:

The shell does not exit if the command that fails is part of the command list immediately following a while or until keyword, part of the test in an if statement, part of any 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 status is being inverted with !. https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html

  1. When this option is on, when any command fails (for any of the reasons listed in Consequences of Shell Errors or by returning an exit status greater than zero), the shell immediately shall exit, as if by executing the exit special built-in utility with no arguments, with the following exceptions: The failure of any individual command in a multi-command pipeline shall not cause the shell to exit. Only the failure of the pipeline itself shall be considered.

  2. The -e setting shall be ignored when executing the compound list following the while, until, if, or elif reserved word, a pipeline beginning with the ! reserved word, or any command of an AND-OR list other than the last.

  3. If the exit status of a compound command other than a subshell command was the result of a failure while -e was being ignored, then -e shall not apply to this command.

https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#set

Which put another way means using any way of acting upon the exit status of a command, will permantly disable errexit on that command.

This is what caused the unexpected behaviour in my earlier code, eval-wrapper was detecting the exit status of the eval'd act function, but in turn, the act function was executing with errexit completely disabled! Not what I wanted!!!

This gist is my attempt to showcase all the techniques to demonstrate and workaround this issue, in a neat, composable, and definitive way. Licensed under the Attribution-ShareAlike 4.0 International (include credit when you copy and paste, submit your improvements).

TLDR: Show me the ultimate solution

Copy the eval_capture function (and its authorship/license header) from technique-09-trap+stdout+stderr+output+script.bash, listen to this song, and use it like so:

local status=0 stdout='' stderr=''
eval_capture [--statusvar=status] [--stdoutvar=stdout] [--stderrvar=stderr] [--] cmd ...

Reproducing the problem, and comparing techniques

I have attached each technique, and their outputs, inside this gist, so you don't have to run them locally, you can just scroll to them.

If you do wish to run the techniques locally:

  1. Clone this repository and cd into it
  2. Run chmod +x ./script.bash so you can run it
  3. Copy and paste the technique you want to trial into script.bash at the top, replacing any prior techniques you copied and pasted in there
  4. Run ./script.bash to see the result

While we are at it, be aware of another batch gotha that return is equivalent to return $? and should not be used if you actually want return 0.

What about capturing status and stdout?

One could have hoped for:

function capture_failure_and_output {
	log "$FUNCNAME: start"

	local status output
	output="$(errexit_workound --statusvar=status indirect_failure)"
	log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
	echo "output=[$output]"

	log "$FUNCNAME: end"
}

However because $(this is a subshell) it produces the unexpected but correct result of:

capture_failure_and_output: $? = 0, $status =  [this should report: 0 / 99]
output=[14: indirect_failure: start
15: direct_failure: start]

Same issue with:

local status output
output="$(mktemp)"
errexit_workound --statusvar=status indirect_failure > >(tee "$output")
output="$(cat "$output")"
log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
echo "output=[$output]"

Same issue with:

local status output=''
shopt -s lastpipe
errexit_workound --statusvar=status indirect_failure | read -r output
echo "output=[$output]"

However that one has a single line of output:

capture_failure_and_output: $? = 0, $status =  [this should report: 0 / 99]
output=[14: indirect_failure: start]

As such, we should update our methods with support for --stdoutvar=....

However, not to fear, I figured it out with technique-07-trap+stdout+script.bash which includes the script.bash code as well, due to modifications.

What about capturing status, stdout, and stderr?

Got you covered, check out technique-08-trap+stdout+stderr+script.bash

What about cpaturing status, stdout, stderr, and both stdout+stderr?

Got you covered, check out technique-09-trap+stdout+stderr+output+script.bash

#!/usr/bin/env bash
# --> copy and paste one of the techniques here <---
# allow us to track progress and side effects via line number updates
LOG_NUMBER=0
function log {
LOG_NUMBER=$((LOG_NUMBER + 1))
echo "$LOG_NUMBER: $1"
}
# these are our testing functions
function direct_failure {
log "$FUNCNAME: start"
return 99
log "$FUNCNAME: end [this line should not be reached]"
}
function indirect_failure {
log "$FUNCNAME: start"
direct_failure
log "$FUNCNAME: \$? = $? [this line should not be reached]"
log "$FUNCNAME: end [this line should not be reached]"
return 66
}
function capture_failure {
log "$FUNCNAME: start"
local status
errexit_workound --statusvar=status indirect_failure
log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
log "$FUNCNAME: end"
}
function discard_failure {
log "$FUNCNAME: start"
errexit_workound indirect_failure
log "$FUNCNAME: \$? = $?"
log "$FUNCNAME: end"
}
# run our testing functions
set -e
capture_failure
log "root [this should be log entry 6]"
discard_failure
log "root [this should be log entry 12]"
indirect_failure
log "root [this line should not be reached]"
# the [if] technique, this technique disables [errexit] for the command
function errexit_workound {
# fetch the variable (if any) that will store the command exit status
local exit_status_local exit_status_variable='exit_status_local'
if [[ "$1" = '--statusvar='* ]]; then
exit_status_variable="${1#*--statusvar=}"
shift
fi
if test "$1" = '--'; then
shift
fi
# execute the command
if "$@"; then
# the command was successful, store a success exit status
eval "${exit_status_variable}=0"
else
# the command was unsuccessful, store its failure exit status
eval "${exit_status_variable}=\$?"
fi
# return a success to not crash the caller
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 2: indirect_failure: start
# 3: direct_failure: start
# 4: indirect_failure: $? = 99 [this line should not be reached]
# 5: indirect_failure: end [this line should not be reached]
# 6: capture_failure: $? = 0, $status = 66 [this should report: 0 / 99]
# 7: capture_failure: end
# 8: root [this should be log entry 6]
# 9: discard_failure: start
# 10: indirect_failure: start
# 11: direct_failure: start
# 12: indirect_failure: $? = 99 [this line should not be reached]
# 13: indirect_failure: end [this line should not be reached]
# 14: discard_failure: $? = 0
# 15: discard_failure: end
# 16: root [this should be log entry 12]
# 17: indirect_failure: start
# 18: direct_failure: start
# the [conditional] technique, this technique disables [errexit] for the command
function errexit_workound {
# fetch the variable (if any) that will store the command exit status
local exit_status_local exit_status_variable='exit_status_local'
if [[ "$1" = '--statusvar='* ]]; then
exit_status_variable="${1#*--statusvar=}"
shift
fi
if test "$1" = '--'; then
shift
fi
# store a preliminary success exit status
eval "${exit_status_variable}=0"
# execute the command and store its failure exit status
"$@" || eval "${exit_status_variable}=\$?"
# return a success to not crash the caller
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 2: indirect_failure: start
# 3: direct_failure: start
# 4: indirect_failure: $? = 99 [this line should not be reached]
# 5: indirect_failure: end [this line should not be reached]
# 6: capture_failure: $? = 0, $status = 66 [this should report: 0 / 99]
# 7: capture_failure: end
# 8: root [this should be log entry 6]
# 9: discard_failure: start
# 10: indirect_failure: start
# 11: direct_failure: start
# 12: indirect_failure: $? = 99 [this line should not be reached]
# 13: indirect_failure: end [this line should not be reached]
# 14: discard_failure: $? = 0
# 15: discard_failure: end
# 16: root [this should be log entry 12]
# 17: indirect_failure: start
# 18: direct_failure: start
# the [export] technique, this technique prevents side effects
function errexit_workound {
# fetch the variable (if any) that will store the command exit status
local exit_status_local exit_status_variable='exit_status_local'
if [[ "$1" = '--statusvar='* ]]; then
exit_status_variable="${1#*--statusvar=}"
shift
fi
if test "$1" = '--'; then
shift
fi
# export command dependencies, this is the downside of this method
export -f direct_failure
export -f log
# execute the command in its own bash invocation
export -f "$1" # export the command
if bash -ec "$@"; then
# the command was successful, store a success exit status
eval "${exit_status_variable}=0"
else
# the command was unsuccessful, store its failure exit status
eval "${exit_status_variable}=\$?"
fi
# return a success to not crash the caller
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 1: indirect_failure: start
# 2: direct_failure: start
# 2: capture_failure: $? = 0, $status = 99 [this should report: 0 / 99]
# 3: capture_failure: end
# 4: root [this should be log entry 6]
# 5: discard_failure: start
# 1: indirect_failure: start
# 2: direct_failure: start
# 6: discard_failure: $? = 0
# 7: discard_failure: end
# 8: root [this should be log entry 12]
# 9: indirect_failure: start
# 10: direct_failure: start
# use the [set -a] technique, this technique prevents side effects
set -a
function errexit_workound {
# fetch the variable (if any) that will store the command exit status
local exit_status_local exit_status_variable='exit_status_local'
if [[ "$1" = '--statusvar='* ]]; then
exit_status_variable="${1#*--statusvar=}"
shift
fi
if test "$1" = '--'; then
shift
fi
# execute the command in its own bash invocation
if bash -ec "$@"; then
# the command was successful, store a success exit status
eval "${exit_status_variable}=0"
else
# the command was unsuccessful, store its failure exit status
eval "${exit_status_variable}=\$?"
fi
# return a success to not crash the caller
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 2: indirect_failure: start
# 3: direct_failure: start
# 2: capture_failure: $? = 0, $status = 99 [this should report: 0 / 99]
# 3: capture_failure: end
# 4: root [this should be log entry 6]
# 5: discard_failure: start
# 6: indirect_failure: start
# 7: direct_failure: start
# 6: discard_failure: $? = 0
# 7: discard_failure: end
# 8: root [this should be log entry 12]
# 9: indirect_failure: start
# 10: direct_failure: start
# the [subshell] technique, this technique prevents side effects
function errexit_workound {
# fetch the variable (if any) that will store the command exit status
local exit_status_local exit_status_variable='exit_status_local'
if [[ "$1" = '--statusvar='* ]]; then
exit_status_variable="${1#*--statusvar=}"
shift
fi
if test "$1" = '--'; then
shift
fi
# store the shell options that we will modify, so we can restore them afterwards
local shopts
shopts="$(shopt -po | grep 'errexit')"
# store a preliminary success exit status
eval "${exit_status_variable}=0"
# permit failures inside this function so we can capture the exit status
set +e
(
# disallow failures inside the subshell
set -e
"$@" # execite the command
)
# set the [exit_status_variable] to the exit status
eval "${exit_status_variable}=\$?"
# restore the shell options that we modified
$shopts
# return a success to not crash the caller
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 2: indirect_failure: start
# 3: direct_failure: start
# 2: capture_failure: $? = 0, $status = 99 [this should report: 0 / 99]
# 3: capture_failure: end
# 4: root [this should be log entry 6]
# 5: discard_failure: start
# 6: indirect_failure: start
# 7: direct_failure: start
# 6: discard_failure: $? = 0
# 7: discard_failure: end
# 8: root [this should be log entry 12]
# 9: indirect_failure: start
# 10: direct_failure: start
# 11: indirect_failure: $? = 99 [this line should not be reached]
# 12: indirect_failure: end [this line should not be reached]
# 13: root [this line should not be reached]
# use the [trap] technique, this technique supports side effects
function errexit_workound {
# fetch the variable (if any) that will store the command exit status
local exit_status_local exit_status_variable='exit_status_local'
if [[ "$1" = '--statusvar='* ]]; then
exit_status_variable="${1#*--statusvar=}"
shift
fi
if test "$1" = '--'; then
shift
fi
# store the shell options that we will modify, so we can restore them afterwards
local shopts
shopts="$(shopt -po | grep 'errtrace')"
# store a preliminary success exit status
eval "${exit_status_variable}=0"
# allow us to trap errors inside this function, and nested executions
set -E
# setup our error trap
# it will:
# - set the [exit_status_variable] to the exit status
# - remove the error trap otherwise it will persist
# - restore the shell options we modified
# - return a success to not crash the caller
# note that:
# - if trapped an error inside this function, it will return this immediately
# - if trapped an error inside a nested execution, it will run the trap inside that, allowing this function to continue
# as such, we must cleanup inside the trap and after the trap
trap "${exit_status_variable}=\$?; trap - ERR; $shopts; return 0" ERR
"$@" # execute the command
trap - ERR
$shopts
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 2: indirect_failure: start
# 3: direct_failure: start
# 4: capture_failure: $? = 0, $status = 99 [this should report: 0 / 99]
# 5: capture_failure: end
# 6: root [this should be log entry 6]
# 7: discard_failure: start
# 8: indirect_failure: start
# 9: direct_failure: start
# 10: discard_failure: $? = 0
# 11: discard_failure: end
# 12: root [this should be log entry 12]
# 13: indirect_failure: start
# 14: direct_failure: start
#!/usr/bin/env bash
# use the [Trap+ProcessSubstitution] technique, this technique supports side effects, and stdout capture
# https://www.gnu.org/software/bash/manual/bash.html#Process-Substitution
function errexit_workound {
# fetch the variables (if any) that will store the command exit status, and the stdout output
local item cmd=() exit_status_local exit_status_variable='exit_status_local' stdout_variable='' stdout_temp=''
while test "$#" -ne 0; do
item="$1"
shift
case "$item" in
'--statusvar='*) exit_status_variable="${item#*--statusvar=}" ;;
'--stdoutvar='*) stdout_variable="${item#*--stdoutvar=}" ;;
'--')
cmd+=("$@")
shift $#
break
;;
*)
cmd+=(
"$item"
"$@"
)
shift $#
break
;;
esac
done
# store the shell options that we will modify, so we can restore them afterwards
local shopts
shopts="$(shopt -po | grep 'errtrace')"
# store a preliminary success exit status
eval "${exit_status_variable}=0"
# allow us to trap errors inside this function, and nested executions
set -E
# run the command and capture its exit status, and if applicable, capture its stdout
# - if trapped an error inside this function, it will return this immediately
# - if trapped an error inside a nested execution, it will run the trap inside that, allowing this function to continue
# as such, we must cleanup inside the trap and after the trap
function cleanup {
$shopts
if test -f "$stdout_temp"; then
eval "${stdout_variable}=\"\$(cat $stdout_temp)\""
fi
}
trap "${exit_status_variable}=\$?; trap - ERR; cleanup; return 0" ERR
if test -n "$stdout_variable"; then
stdout_temp="$(mktemp)"
touch "$stdout_temp"
# stdin test was [confirm --positive --ppid=$$ -- 'sup'] inside [direct_failure]
# stdout_temp="$("${cmd[@]}")"; eval "${stdout_variable}=\"\$stdout_temp\"" # supports stdout, supports stdin, fails status capture
# "${cmd[@]}" >"$stdout_temp" # skips stdout, supports stdin, supports status capture
# "${cmd[@]}" | tee "$stdout_temp" # support stdout, supports stdin, fails status capture
"${cmd[@]}" > >(tee "$stdout_temp") # this works, supports stdout, supports stdin reading, supports status capture
else
"${cmd[@]}"
fi
trap - ERR
cleanup
if test -n "$stdout_temp"; then
rm "$stdout_temp"
fi
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 2: indirect_failure: start
# 3: direct_failure: start
# 4: capture_failure: $? = 0, $status = 99 [this should report: 0 / 99]
# 5: capture_failure: end
# 6: root [this should be log entry 6]
# 7: discard_failure: start
# 8: indirect_failure: start
# 9: direct_failure: start
# 10: discard_failure: $? = 0
# 11: discard_failure: end
# 12: root [this should be log entry 12]
# 13: capture_failure_and_output: start
# 14: indirect_failure: start
# 15: direct_failure: start
# 16: capture_failure_and_output: $? = 0, $status = 99 [this should report: 0 / 99]
# output=[14: indirect_failure: start
# 15: direct_failure: start]
# 17: capture_failure_and_output: end
# 18: root [this should be log entry 18]
# 19: indirect_failure: start
# 20: direct_failure: start
# allow us to track progress and side effects via line number updates
LOG_NUMBER=0
function log {
LOG_NUMBER=$((LOG_NUMBER + 1))
echo "$LOG_NUMBER: $1"
}
# these are our testing functions
function direct_failure {
log "$FUNCNAME: start"
return 99
log "$FUNCNAME: end [this line should not be reached]"
}
function indirect_failure {
log "$FUNCNAME: start"
direct_failure
log "$FUNCNAME: \$? = $? [this line should not be reached]"
log "$FUNCNAME: end [this line should not be reached]"
return 66
}
function capture_failure {
log "$FUNCNAME: start"
local status
errexit_workound --statusvar=status indirect_failure
log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
log "$FUNCNAME: end"
}
function discard_failure {
log "$FUNCNAME: start"
errexit_workound indirect_failure
log "$FUNCNAME: \$? = $?"
log "$FUNCNAME: end"
}
function capture_failure_and_output {
log "$FUNCNAME: start"
local status output
errexit_workound --statusvar=status --stdoutvar=output indirect_failure
log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
echo "output=[$output]"
log "$FUNCNAME: end"
}
# run our testing functions
set -e
capture_failure
log "root [this should be log entry 6]"
discard_failure
log "root [this should be log entry 12]"
capture_failure_and_output
log "root [this should be log entry 18]"
indirect_failure
log "root [this line should not be reached]"
#!/usr/bin/env bash
# Copyright 2023+ Benjamin Lupton <b@lupton.cc> (https://balupton.com)
# Licensed under the Attribution-ShareAlike 4.0 International License (https://creativecommons.org/licenses/by-sa/4.0/)
# For more information: https://gist.github.com/balupton/21ded5cefc26dc20833e6ed606209e1b
function eval_capture {
# fetch (if supplied) the variables that will store the command exit status, the stdout output, and/or the stderr output
local item cmd=() exit_status_local exit_status_variable='exit_status_local' stdout_variable='' stdout_temp_file='' stderr_variable='' stderr_temp_file=''
while test "$#" -ne 0; do
item="$1"
shift
case "$item" in
'--statusvar='*) exit_status_variable="${item#*--statusvar=}" ;;
'--stdoutvar='*) stdout_variable="${item#*--stdoutvar=}" ;;
'--stderrvar='*) stderr_variable="${item#*--stderrvar=}" ;;
'--')
cmd+=("$@")
shift $#
break
;;
'-'*)
echo "ERROR: $0: $FUNCNAME: $LINENO: An unrecognised flag was provided: $item" >/dev/stderr
return 22 # EINVAL 22 Invalid argument
;;
*)
cmd+=(
"$item"
"$@"
)
shift $#
break
;;
esac
done
# store the shell options that we will modify, so we can restore them afterwards
local shopts
shopts="$(shopt -po | grep 'errtrace')"
# store a preliminary success exit status
eval "${exit_status_variable}=0"
# allow us to trap errors inside this function, and nested executions
set -E
# run the command and capture its exit status, and if applicable, capture its stdout
# - if trapped an error inside this function, it will return this immediately
# - if trapped an error inside a nested execution, it will run the trap inside that, allowing this function to continue
# as such, we must cleanup inside the trap and after the trap, and cleanup must work in both contexts
function cleanup {
$shopts
if test -n "$stdout_temp_file" -a -f "$stdout_temp_file"; then
eval "${stdout_variable}=\"\$(cat $stdout_temp_file)\""
rm "$stdout_temp_file"
stdout_temp_file=''
fi
if test -n "$stderr_temp_file" -a -f "$stderr_temp_file"; then
eval "${stderr_variable}=\"\$(cat $stderr_temp_file)\""
rm "$stderr_temp_file"
stderr_temp_file=''
fi
}
if test -n "$stdout_variable"; then
stdout_temp_file="$(mktemp)"
fi
if test -n "$stderr_variable"; then
stderr_temp_file="$(mktemp)"
fi
trap "${exit_status_variable}=\$?; trap - ERR; cleanup; return 0" ERR
if test -n "$stderr_variable" -a -n "$stderr_variable"; then
"${cmd[@]}" > >(tee "$stdout_temp_file") 2> >(tee "$stderr_temp_file" >/dev/stderr)
elif test -n "$stdout_variable" -a -z "$stderr_variable"; then
"${cmd[@]}" > >(tee "$stdout_temp_file")
elif test -z "$stdout_variable" -a -n "$stderr_variable"; then
"${cmd[@]}" 2> >(tee "$stderr_temp_file" >/dev/stderr)
else
"${cmd[@]}"
fi
trap - ERR
cleanup
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 2: capture_failure: stderr
# 3: indirect_failure: start
# 4: indirect_failure: stderr
# 5: direct_failure: start
# 6: direct_failure: stderr
# 7: capture_failure: $? = 0, $status = 99 [this should report: 0 / 99]
# 8: capture_failure: end
# 9: root [this should be log entry 9]
# 10: discard_failure: start
# 11: discard_failure: stderr
# 12: indirect_failure: start
# 13: indirect_failure: stderr
# 14: direct_failure: start
# 15: direct_failure: stderr
# 16: discard_failure: $? = 0
# 17: discard_failure: end
# 18: root [this should be log entry 18]
# 19: capture_failure_and_output: start
# 20: capture_failure_and_output: stderr
# 21: indirect_failure: start
# 23: direct_failure: start
# 22: indirect_failure: stderr
# 24: direct_failure: stderr
# 25: capture_failure_and_output: $? = 0, $status = 99 [this should report: 0 / 99]
# stdout=[21: indirect_failure: start
# 23: direct_failure: start]
# stderr=[22: indirect_failure: stderr
# 24: direct_failure: stderr]
# 26: capture_failure_and_output: end
# 27: root [this should be log entry 27]
# 28: indirect_failure: start
# 29: indirect_failure: stderr
# 30: direct_failure: start
# 31: direct_failure: stderr
# allow us to track progress and side effects via line number updates
LOG_NUMBER=0
function log {
LOG_NUMBER=$((LOG_NUMBER + 1))
echo "$LOG_NUMBER: $1"
}
# these are our testing functions
function direct_failure {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
return 99
log "$FUNCNAME: end [this line should not be reached]"
}
function indirect_failure {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
direct_failure
log "$FUNCNAME: \$? = $? [this line should not be reached]"
log "$FUNCNAME: end [this line should not be reached]"
return 66
}
function capture_failure {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
local status
eval_capture --statusvar=status indirect_failure
log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
log "$FUNCNAME: end"
}
function discard_failure {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
eval_capture indirect_failure
log "$FUNCNAME: \$? = $?"
log "$FUNCNAME: end"
}
function capture_failure_and_output {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
local status stdout stderr
eval_capture --statusvar=status --stdoutvar=stdout --stderrvar=stderr indirect_failure
log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
echo "stdout=[$stdout]"
echo "stderr=[$stderr]"
log "$FUNCNAME: end"
}
# run our testing functions
set -e
capture_failure
log "root [this should be log entry 9]"
discard_failure
log "root [this should be log entry 18]"
capture_failure_and_output
log "root [this should be log entry 27]"
indirect_failure
log "root [this line should not be reached]"
#!/usr/bin/env bash
# Copyright 2023+ Benjamin Lupton <b@lupton.cc> (https://balupton.com)
# Licensed under the Attribution-ShareAlike 4.0 International License (https://creativecommons.org/licenses/by-sa/4.0/)
# For more information: https://gist.github.com/balupton/21ded5cefc26dc20833e6ed606209e1b
function eval_capture {
# fetch (if supplied) the variables that will store the command exit status, the stdout output, the stderr output, and/or the stdout+stderr output
local item cmd=() exit_status_local exit_status_variable='exit_status_local' stdout_variable='' stdout_temp_file='' stderr_variable='' stderr_temp_file='' output_variable='' output_temp_file=''
while test "$#" -ne 0; do
item="$1"
shift
case "$item" in
'--statusvar='*) exit_status_variable="${item#*--statusvar=}" ;;
'--stdoutvar='*) stdout_variable="${item#*--stdoutvar=}" ;;
'--stderrvar='*) stderr_variable="${item#*--stderrvar=}" ;;
'--outputvar='*) output_variable="${item#*--outputvar=}" ;;
'--')
cmd+=("$@")
shift $#
break
;;
'-'*)
echo "ERROR: $0: $FUNCNAME: $LINENO: An unrecognised flag was provided: $item" >/dev/stderr
return 22 # EINVAL 22 Invalid argument
;;
*)
cmd+=(
"$item"
"$@"
)
shift $#
break
;;
esac
done
# store the shell options that we will modify, so we can restore them afterwards
local shopts
shopts="$(shopt -po | grep 'errtrace')"
# store a preliminary success exit status
eval "${exit_status_variable}=0"
# allow us to trap errors inside this function, and nested executions
set -E
# run the command and capture its exit status, and if applicable, capture its stdout
# - if trapped an error inside this function, it will return this immediately
# - if trapped an error inside a nested execution, it will run the trap inside that, allowing this function to continue
# as such, we must cleanup inside the trap and after the trap, and cleanup must work in both contexts
function cleanup {
$shopts
if test -n "$stdout_temp_file" -a -f "$stdout_temp_file"; then
eval "${stdout_variable}=\"\$(cat $stdout_temp_file)\""
rm "$stdout_temp_file"
stdout_temp_file=''
fi
if test -n "$stderr_temp_file" -a -f "$stderr_temp_file"; then
eval "${stderr_variable}=\"\$(cat $stderr_temp_file)\""
rm "$stderr_temp_file"
stderr_temp_file=''
fi
if test -n "$output_temp_file" -a -f "$output_temp_file"; then
eval "${output_variable}=\"\$(cat $output_temp_file)\""
rm "$output_temp_file"
output_temp_file=''
fi
}
if test -n "$stdout_variable"; then
stdout_temp_file="$(mktemp)"
fi
if test -n "$stderr_variable"; then
stderr_temp_file="$(mktemp)"
fi
if test -n "$output_variable"; then
output_temp_file="$(mktemp)"
fi
trap "${exit_status_variable}=\$?; trap - ERR; cleanup; return 0" ERR
if test -n "$output_variable"; then
if test -n "$stdout_variable"; then
if test -n "$stderr_variable"; then
"${cmd[@]}" > >(tee -a "$stdout_temp_file" "$output_temp_file") 2> >(tee -a "$stderr_temp_file" "$output_temp_file" >/dev/stderr)
else
"${cmd[@]}" > >(tee -a "$stdout_temp_file" "$output_temp_file") 2> >(tee -a "$output_temp_file" >/dev/stderr)
fi
else
if test -n "$stderr_variable"; then
"${cmd[@]}" > >(tee -a "$output_temp_file") 2> >(tee -a "$stderr_temp_file" "$output_temp_file" >/dev/stderr)
else
"${cmd[@]}" > >(tee -a "$output_temp_file") 2> >(tee -a "$output_temp_file" >/dev/stderr)
fi
fi
else
if test -n "$stdout_variable"; then
if test -n "$stderr_variable"; then
"${cmd[@]}" > >(tee -a "$stdout_temp_file") 2> >(tee -a "$stderr_temp_file" >/dev/stderr)
else
"${cmd[@]}" > >(tee -a "$stdout_temp_file")
fi
else
if test -n "$stderr_variable"; then
"${cmd[@]}" 2> >(tee -a "$stderr_temp_file" >/dev/stderr)
else
"${cmd[@]}"
fi
fi
fi
trap - ERR
cleanup
return 0
}
# its results are as follows:
# 1: capture_failure: start
# 2: capture_failure: stderr
# 3: indirect_failure: start
# 4: indirect_failure: stderr
# 5: direct_failure: start
# 6: direct_failure: stderr
# 7: capture_failure: $? = 0, $status = 99 [this should report: 0 / 99]
# 8: capture_failure: end
# 9: root [this should be log entry 9]
# 10: discard_failure: start
# 11: discard_failure: stderr
# 12: indirect_failure: start
# 13: indirect_failure: stderr
# 14: direct_failure: start
# 15: direct_failure: stderr
# 16: discard_failure: $? = 0
# 17: discard_failure: end
# 18: root [this should be log entry 18]
# 19: capture_failure_and_output: start
# 20: capture_failure_and_output: stderr
# 21: indirect_failure: start
# 23: direct_failure: start
# 22: indirect_failure: stderr
# 24: direct_failure: stderr
# 25: capture_failure_and_output: $? = 0, $status = 99 [this should report: 0 / 99]
# stdout=[21: indirect_failure: start
# 23: direct_failure: start]
# stderr=[22: indirect_failure: stderr
# 24: direct_failure: stderr]
# 26: capture_failure_and_output: end
# 27: root [this should be log entry 27]
# 28: indirect_failure: start
# 29: indirect_failure: stderr
# 30: direct_failure: start
# 31: direct_failure: stderr
# allow us to track progress and side effects via line number updates
LOG_NUMBER=0
function log {
LOG_NUMBER=$((LOG_NUMBER + 1))
echo "$LOG_NUMBER: $1"
}
# these are our testing functions
function direct_failure {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
return 99
log "$FUNCNAME: end [this line should not be reached]"
}
function indirect_failure {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
direct_failure
log "$FUNCNAME: \$? = $? [this line should not be reached]"
log "$FUNCNAME: end [this line should not be reached]"
return 66
}
function capture_failure {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
local status
eval_capture --statusvar=status indirect_failure
log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
log "$FUNCNAME: end"
}
function discard_failure {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
eval_capture indirect_failure
log "$FUNCNAME: \$? = $?"
log "$FUNCNAME: end"
}
function capture_failure_and_output {
log "$FUNCNAME: start"
log "$FUNCNAME: stderr" >/dev/stderr
local status stdout stderr output
eval_capture --statusvar=status --stdoutvar=stdout --stderrvar=stderr --outputvar=output indirect_failure
log "$FUNCNAME: \$? = $?, \$status = $status [this should report: 0 / 99]"
echo "stdout=[$stdout]"
echo "stderr=[$stderr]"
echo "output=[$output]"
log "$FUNCNAME: end"
}
# run our testing functions
set -e
capture_failure
log "root [this should be log entry 9]"
discard_failure
log "root [this should be log entry 18]"
capture_failure_and_output
log "root [this should be log entry 27]"
indirect_failure
log "root [this line should not be reached]"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment