| 1 | #!/bin/bash |
| 2 | # Copyright (C) 2019 Ian Kelling |
| 3 | # SPDX-License-Identifier: GPL-3.0-or-later |
| 4 | |
| 5 | # Commentary: Print stack trace and exit/return on errors, or use |
| 6 | # functions below for for more details and manual error handling. See |
| 7 | # end of file for credits etc. |
| 8 | |
| 9 | ####################################### |
| 10 | # err-catch: Setup trap on ERR to print stack trace and exit (or return |
| 11 | # if the shell is interactive). This is the most common use case so we |
| 12 | # run it after defining it, you can call err-allow to undo that. |
| 13 | # |
| 14 | # This also sets pipefail because it's a good practice to catch more |
| 15 | # errors. |
| 16 | # |
| 17 | # Note: In interactive shell, stack calling line number is not |
| 18 | # available, so we print function definition lines. |
| 19 | # |
| 20 | # Globals |
| 21 | # |
| 22 | # err_catch_ignore Array containing glob patterns to test against |
| 23 | # filenames to ignore errors from in interactive |
| 24 | # shell. Initialized to ignore bash-completion |
| 25 | # scripts on debian based systems. |
| 26 | # |
| 27 | # err-cleanup If set, this command will run just before exiting. |
| 28 | # |
| 29 | # _err_func_last Used internally in err-bash-trace-interactive |
| 30 | # |
| 31 | ####################################### |
| 32 | err-catch() { |
| 33 | set -E; |
| 34 | if [[ $- == *i* ]]; then |
| 35 | if ! test ${err_catch_ignore+defined}; then |
| 36 | err_catch_ignore=( |
| 37 | '/etc/bash_completion.d/*' |
| 38 | '*/bash-completion/*' |
| 39 | ) |
| 40 | fi |
| 41 | declare -i _err_func_last=0 |
| 42 | shopt -s extdebug |
| 43 | # shellcheck disable=SC2154 |
| 44 | trap '_err-bash-trace-interactive $? "$BASH_COMMAND" ${BASH_ARGC[0]} "${BASH_ARGV[@]}" || return $?' ERR |
| 45 | else |
| 46 | # Man bash on exdebug: "If set at shell invocation, arrange to |
| 47 | # execute the debugger". We want to avoid that, but I want this file |
| 48 | # to be sourceable from bash startup files. noninteractive ssh and |
| 49 | # sources .bashrc on invocation. login_shell sources things on |
| 50 | # invocation. |
| 51 | # |
| 52 | # extdebug allows us to print function arguments in our stack trace. |
| 53 | if ! shopt login_shell >/dev/null && [[ ! $SSH_CONNECTION ]]; then |
| 54 | shopt -s extdebug |
| 55 | fi |
| 56 | trap err-exit ERR |
| 57 | fi |
| 58 | set -o pipefail |
| 59 | } |
| 60 | # This is the most common use case so run it now. |
| 61 | err-catch |
| 62 | |
| 63 | ####################################### |
| 64 | # Undo err-catch/err-catch-interactive |
| 65 | ####################################### |
| 66 | err-allow() { |
| 67 | shopt -u extdebug |
| 68 | set +E +o pipefail |
| 69 | trap ERR |
| 70 | } |
| 71 | |
| 72 | ####################################### |
| 73 | # err-exit: Print stack trace and exit |
| 74 | # |
| 75 | # Use this instead of the exit command to be more informative. |
| 76 | # |
| 77 | # usage: err-exit [-EXIT_CODE] [MESSAGE] |
| 78 | # |
| 79 | # EXIT_CODE Default: $? if it is nonzero, otherwise 1. |
| 80 | # MESSAGE Print MESSAGE to stderr. Default: |
| 81 | # ${BASH_SOURCE[1]}:${BASH_LINENO[0]}: `$BASH_COMMAND' returned $? |
| 82 | # |
| 83 | # Globals |
| 84 | # |
| 85 | # err-cleanup If set, this command will run just before exiting. |
| 86 | # |
| 87 | ####################################### |
| 88 | err-exit() { |
| 89 | local err=$? |
| 90 | # This has to come before most things or vars get changed |
| 91 | local msg="${BASH_SOURCE[1]}:${BASH_LINENO[0]}: \`$BASH_COMMAND' returned $err" |
| 92 | set +x |
| 93 | if [[ $1 == -* ]]; then |
| 94 | err=${1#-} |
| 95 | shift |
| 96 | elif (( ! err )); then |
| 97 | err=1 |
| 98 | fi |
| 99 | if [[ $1 ]]; then |
| 100 | msg="$1" |
| 101 | fi |
| 102 | printf "%s\n" "$msg" >&2 |
| 103 | err-bash-trace 2 |
| 104 | set -e # err trap does not work within an error trap |
| 105 | if type -t err-cleanup >/dev/null; then |
| 106 | err-cleanup |
| 107 | fi |
| 108 | printf "%s: exiting with status %s\n" "$0" "$err" >&2 |
| 109 | exit $err |
| 110 | } |
| 111 | |
| 112 | ####################################### |
| 113 | # Print stack trace |
| 114 | # |
| 115 | # usage: err-bash-trace [FRAME_START] |
| 116 | # |
| 117 | # This function is called by the other functions which print stack |
| 118 | # traces. |
| 119 | # |
| 120 | # It does not show function args unless you first run: |
| 121 | # shopt -s extdebug |
| 122 | # which err-catch does for you. |
| 123 | # |
| 124 | # FRAME_START Optional variable to set before calling. The frame to |
| 125 | # start printing on. default=1. If ${#FUNCNAME[@]} <= |
| 126 | # FRAME_START + 1, don't print anything because we are at |
| 127 | # the top level of the script and better off printing a |
| 128 | # general message, for example see what our callers print. |
| 129 | # |
| 130 | ####################################### |
| 131 | err-bash-trace() { |
| 132 | local -i argc_index=0 frame i frame_start=${1:-1} |
| 133 | local source_loc |
| 134 | if (( ${#FUNCNAME[@]} <= frame_start + 1 )); then |
| 135 | return 0 |
| 136 | fi |
| 137 | for ((frame=0; frame < ${#FUNCNAME[@]}; frame++)); do |
| 138 | argc=${BASH_ARGC[frame]} |
| 139 | argc_index+=$argc |
| 140 | if ((frame < frame_start)); then continue; fi |
| 141 | if (( ${#BASH_SOURCE[@]} > 1 )); then |
| 142 | source_loc="${BASH_SOURCE[frame]}:${BASH_LINENO[frame-1]}:" |
| 143 | fi |
| 144 | printf " from %sin \`%s" "$source_loc" "${FUNCNAME[frame]}" >&2 |
| 145 | if shopt extdebug >/dev/null; then |
| 146 | for ((i=argc_index-1; i >= argc_index-argc; i--)); do |
| 147 | printf " %s" "${BASH_ARGV[i]}" >&2 |
| 148 | done |
| 149 | fi |
| 150 | echo \' >&2 |
| 151 | done |
| 152 | return 0 |
| 153 | } |
| 154 | |
| 155 | ####################################### |
| 156 | # Internal function for err-catch. Prints stack trace from interactive |
| 157 | # shell trap. |
| 158 | # |
| 159 | # Usage: see err-catch-interactive |
| 160 | ####################################### |
| 161 | _err-bash-trace-interactive() { |
| 162 | if (( ${#FUNCNAME[@]} <= 1 )); then |
| 163 | return 0 |
| 164 | fi |
| 165 | |
| 166 | for pattern in "${err_catch_ignore[@]}"; do |
| 167 | # shellcheck disable=SC2053 |
| 168 | if [[ ${BASH_SOURCE[1]} == $pattern ]]; then |
| 169 | return 0 |
| 170 | fi |
| 171 | done |
| 172 | |
| 173 | local ret bash_command argc pattern i last |
| 174 | last=$_err_func_last |
| 175 | _err_func_last=${#FUNCNAME[@]} |
| 176 | # We have these passed to us because they are lost inside the |
| 177 | # function. |
| 178 | ret=$1 |
| 179 | bash_command="$2" |
| 180 | argc=$(( $3 - 1 )) |
| 181 | shift 3 |
| 182 | argv=("$@") |
| 183 | # The trap returns a nonzero, then gets called again. This condition |
| 184 | # tells us if we are the first. |
| 185 | if (( _err_func_last > last )); then |
| 186 | printf "ERR: \`%s\' returned %s\n" "$bash_command" $ret >&2 |
| 187 | fi |
| 188 | printf " from \`%s" "${FUNCNAME[1]}" >&2 |
| 189 | if shopt extdebug >/dev/null; then |
| 190 | for ((i=argc; i >= 0; i--)); do |
| 191 | printf " %s" "${argv[i]}" >&2 |
| 192 | done |
| 193 | fi |
| 194 | printf "\' defined at %s:%s\n" "${BASH_SOURCE[1]}" "$(declare -F "${FUNCNAME[1]}"|awk "{print \$2}")" >&2 |
| 195 | if [[ -t 1 ]]; then |
| 196 | return $ret |
| 197 | else |
| 198 | # Part of an outgoing pipe, avoid getting get us stuck in a weird |
| 199 | # subshell if we returned nonzero, which would happen in a situation |
| 200 | # like this: |
| 201 | # |
| 202 | # tf() { while read -r line; do :; done < <(asdf); }; |
| 203 | # tf |
| 204 | # |
| 205 | # Note: exit $ret also avoids the stuck subshell problem, and I |
| 206 | # can't notice any difference, but this seems more proper. |
| 207 | return 0 |
| 208 | fi |
| 209 | } |
| 210 | |
| 211 | # Credits etc: |
| 212 | # |
| 213 | # Related: see my bash script template repo at https://iankelling.org/git. |
| 214 | # |
| 215 | # |
| 216 | # Please email me if you have a patches, bugs, feedback, or if you use |
| 217 | # it or republish it since I'm not aware of any users yet |
| 218 | # Ian Kelling <ian@iankelling.org>. |
| 219 | # |
| 220 | # Tested on bash 4.4.20(1)-release (x86_64-pc-linux-gnu). If you test |