Commit | Line | Data |
---|---|---|
78a1a75c | 1 | #!/bin/bash |
729475db IK |
2 | # Bash Error Handler |
3 | # Copyright (C) 2020 Ian Kelling <ian@iankelling.org> | |
fc2d9041 | 4 | # SPDX-License-Identifier: GPL-3.0-or-later |
729475db IK |
5 | # |
6 | # This program is free software: you can redistribute it and/or modify | |
7 | # it under the terms of the GNU General Public License as published by | |
8 | # the Free Software Foundation, either version 3 of the License, or | |
9 | # (at your option) any later version. | |
10 | # | |
11 | # This program is distributed in the hope that it will be useful, | |
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 | # GNU General Public License for more details. | |
15 | # | |
16 | # You should have received a copy of the GNU General Public License | |
17 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | ||
19 | ||
20 | # This is a single file library, just source this file. When an error | |
21 | # happens, we print a stack trace then exit. In an interactive shell, we | |
22 | # return from functions instead of exiting. If err-cleanup is a command, | |
23 | # it runs before the stack trace. Functions are documented inline below | |
24 | # for additional use cases. | |
25 | # | |
47a6d5f5 IK |
26 | # Note: occasionally the line numbers are off a bit (at least in Bash |
27 | # 5.0). This appears to be a bash bug. I plan to report it next time it | |
28 | # happens to me. | |
29 | # | |
729475db IK |
30 | # Please email me if you use this or have anything to contribute. I'm |
31 | # not aware of any users yet Ian Kelling <ian@iankelling.org>. | |
32 | # | |
47a6d5f5 IK |
33 | # Tested on bash 4.4.20(1)-release (x86_64-pc-linux-gnu) and |
34 | # 5.0.17(1)-release (x86_64-pc-linux-gnu). | |
729475db IK |
35 | # |
36 | # Related: see my bash script template repo at https://iankelling.org/git. | |
acd03e1e | 37 | |
acd03e1e | 38 | |
b4d7f86a IK |
39 | # TODO: investigate to see if we can format output betting in case of |
40 | # subshell failure. Right now, we get independent trace from inside and | |
41 | # outside of the subshell. Note, errexit + inherit_errexit doesn't have | |
42 | # any smarts around this either. | |
43 | ||
022af184 IK |
44 | if ! test "$BASH_VERSION"; then echo "error: shell is not bash" >&2; exit 1; fi |
45 | ||
e58bbdf6 IK |
46 | ####################################### |
47 | # err-catch: Setup trap on ERR to print stack trace and exit (or return | |
48 | # if the shell is interactive). This is the most common use case so we | |
49 | # run it after defining it, you can call err-allow to undo that. | |
50 | # | |
51 | # This also sets pipefail because it's a good practice to catch more | |
52 | # errors. | |
53 | # | |
54 | # Note: In interactive shell, stack calling line number is not | |
55 | # available, so we print function definition lines. | |
56 | # | |
2a9b54de IK |
57 | # Note: This works like set -e, which has one unintuitive feature: If |
58 | # you use a function as part of a conditional, eg: func && come_cmd, a | |
59 | # failed command within func won't trigger an error. | |
60 | # | |
e58bbdf6 IK |
61 | # Globals |
62 | # | |
63 | # err_catch_ignore Array containing glob patterns to test against | |
64 | # filenames to ignore errors from in interactive | |
65 | # shell. Initialized to ignore bash-completion | |
66 | # scripts on debian based systems. | |
67 | # | |
68 | # err-cleanup If set, this command will run just before exiting. | |
69 | # | |
70 | # _err_func_last Used internally in err-bash-trace-interactive | |
71 | # | |
72 | ####################################### | |
73 | err-catch() { | |
74 | set -E; | |
75 | if [[ $- == *i* ]]; then | |
76 | if ! test ${err_catch_ignore+defined}; then | |
77 | err_catch_ignore=( | |
78 | '/etc/bash_completion.d/*' | |
79 | '*/bash-completion/*' | |
80 | ) | |
81 | fi | |
82 | declare -i _err_func_last=0 | |
b4d7f86a IK |
83 | if [[ $- != *c* ]]; then |
84 | shopt -s extdebug | |
85 | fi | |
e58bbdf6 | 86 | # shellcheck disable=SC2154 |
b4d7f86a | 87 | trap '_err-bash-trace-interactive $? "${PIPESTATUS[*]}" "$BASH_COMMAND" ${BASH_ARGC[0]} "${BASH_ARGV[@]}" || return $?' ERR |
e58bbdf6 IK |
88 | else |
89 | # Man bash on exdebug: "If set at shell invocation, arrange to | |
90 | # execute the debugger". We want to avoid that, but I want this file | |
91 | # to be sourceable from bash startup files. noninteractive ssh and | |
92 | # sources .bashrc on invocation. login_shell sources things on | |
93 | # invocation. | |
94 | # | |
95 | # extdebug allows us to print function arguments in our stack trace. | |
96 | if ! shopt login_shell >/dev/null && [[ ! $SSH_CONNECTION ]]; then | |
97 | shopt -s extdebug | |
98 | fi | |
99 | trap err-exit ERR | |
100 | fi | |
101 | set -o pipefail | |
102 | } | |
103 | # This is the most common use case so run it now. | |
104 | err-catch | |
105 | ||
106 | ####################################### | |
107 | # Undo err-catch/err-catch-interactive | |
108 | ####################################### | |
109 | err-allow() { | |
110 | shopt -u extdebug | |
111 | set +E +o pipefail | |
112 | trap ERR | |
113 | } | |
114 | ||
115 | ####################################### | |
116 | # err-exit: Print stack trace and exit | |
117 | # | |
118 | # Use this instead of the exit command to be more informative. | |
119 | # | |
120 | # usage: err-exit [-EXIT_CODE] [MESSAGE] | |
121 | # | |
122 | # EXIT_CODE Default: $? if it is nonzero, otherwise 1. | |
123 | # MESSAGE Print MESSAGE to stderr. Default: | |
124 | # ${BASH_SOURCE[1]}:${BASH_LINENO[0]}: `$BASH_COMMAND' returned $? | |
125 | # | |
126 | # Globals | |
127 | # | |
128 | # err-cleanup If set, this command will run just before exiting. | |
129 | # | |
130 | ####################################### | |
131 | err-exit() { | |
2a9b54de IK |
132 | # vars have _ prefix so that we can inspect existing set vars without |
133 | # too much overwriting of them. | |
268a68ae | 134 | local _err=$? _pipestatus="${PIPESTATUS[*]}" |
b4d7f86a | 135 | |
e58bbdf6 | 136 | # This has to come before most things or vars get changed |
2a9b54de IK |
137 | local _msg="${BASH_SOURCE[1]}:${BASH_LINENO[0]}: \`$BASH_COMMAND' returned $_err" |
138 | local _cmdr="$BASH_COMMAND" # command right. we chop of the left, keep the right. | |
139 | ||
268a68ae | 140 | if [[ $_pipestatus && $_pipestatus != "$_err" ]]; then |
2a9b54de | 141 | _msg+=", PIPESTATUS: $_pipestatus" |
b4d7f86a | 142 | fi |
e58bbdf6 IK |
143 | set +x |
144 | if [[ $1 == -* ]]; then | |
2a9b54de | 145 | _err=${1#-} |
e58bbdf6 | 146 | shift |
2a9b54de IK |
147 | elif (( ! _err )); then |
148 | _err=1 | |
e58bbdf6 IK |
149 | fi |
150 | if [[ $1 ]]; then | |
2a9b54de | 151 | _msg="$1" |
e58bbdf6 | 152 | fi |
2a9b54de IK |
153 | |
154 | ## Begin printing vars from within BASH_COMMAND ## | |
155 | local _var _chars _l | |
e0ee742e | 156 | local -A _vars |
2a9b54de IK |
157 | while [[ $_cmdr ]]; do |
158 | _chars="${#_cmdr}" | |
159 | _cmdr="${_cmdr#*$}" | |
160 | _cmdr="${_cmdr#{}" | |
3df3919e | 161 | if (( _chars == ${#_cmdr} )); then |
2a9b54de IK |
162 | break |
163 | fi | |
164 | _var="${_cmdr%%[^a-zA-Z0-9_]*}" | |
2a9b54de IK |
165 | if [[ ! $_var || $_var == [0-9]* ]]; then |
166 | continue | |
167 | fi | |
e0ee742e | 168 | _vars[${_var}]=t |
2a9b54de | 169 | done |
e0ee742e IK |
170 | #echo "iank ${_vars[*]}" |
171 | #set |& grep ^password | |
2a9b54de IK |
172 | # in my small test, this took 50% longer than piping to grep. |
173 | # That seems a small enough penalty to stay in bash here. | |
174 | if (( ${#_vars[@]} )); then | |
175 | set |& while read -r _l; do | |
e0ee742e IK |
176 | for _var in "${!_vars[@]}"; do |
177 | case $_l in | |
178 | ${_var}=*) printf "%s\n" "$_l" >&2 ;; | |
179 | esac | |
2a9b54de IK |
180 | done |
181 | done | |
182 | fi | |
183 | ## End printing vars from within BASH_COMMAND ## | |
184 | ||
185 | printf "%s\n" "$_msg" >&2 | |
e58bbdf6 IK |
186 | err-bash-trace 2 |
187 | set -e # err trap does not work within an error trap | |
188 | if type -t err-cleanup >/dev/null; then | |
189 | err-cleanup | |
190 | fi | |
2a9b54de IK |
191 | printf "%s: exiting with status %s\n" "$0" "$_err" >&2 |
192 | exit $_err | |
e58bbdf6 | 193 | } |
a5c2ac43 | 194 | |
a5c2ac43 IK |
195 | ####################################### |
196 | # Print stack trace | |
197 | # | |
e58bbdf6 | 198 | # usage: err-bash-trace [FRAME_START] |
a5c2ac43 | 199 | # |
62aed8de IK |
200 | # This function is called by the other functions which print stack |
201 | # traces. | |
202 | # | |
a5c2ac43 IK |
203 | # It does not show function args unless you first run: |
204 | # shopt -s extdebug | |
e58bbdf6 | 205 | # which err-catch does for you. |
a5c2ac43 | 206 | # |
e58bbdf6 IK |
207 | # FRAME_START Optional variable to set before calling. The frame to |
208 | # start printing on. default=1. If ${#FUNCNAME[@]} <= | |
209 | # FRAME_START + 1, don't print anything because we are at | |
210 | # the top level of the script and better off printing a | |
211 | # general message, for example see what our callers print. | |
5a600375 | 212 | # |
a5c2ac43 IK |
213 | ####################################### |
214 | err-bash-trace() { | |
e58bbdf6 IK |
215 | local -i argc_index=0 frame i frame_start=${1:-1} |
216 | local source_loc | |
217 | if (( ${#FUNCNAME[@]} <= frame_start + 1 )); then | |
218 | return 0 | |
acd03e1e | 219 | fi |
5a600375 | 220 | for ((frame=0; frame < ${#FUNCNAME[@]}; frame++)); do |
acd03e1e IK |
221 | argc=${BASH_ARGC[frame]} |
222 | argc_index+=$argc | |
e58bbdf6 | 223 | if ((frame < frame_start)); then continue; fi |
acd03e1e | 224 | if (( ${#BASH_SOURCE[@]} > 1 )); then |
e58bbdf6 | 225 | source_loc="${BASH_SOURCE[frame]}:${BASH_LINENO[frame-1]}:" |
acd03e1e | 226 | fi |
e58bbdf6 | 227 | printf " from %sin \`%s" "$source_loc" "${FUNCNAME[frame]}" >&2 |
5a600375 | 228 | if shopt extdebug >/dev/null; then |
acd03e1e | 229 | for ((i=argc_index-1; i >= argc_index-argc; i--)); do |
e58bbdf6 | 230 | printf " %s" "${BASH_ARGV[i]}" >&2 |
acd03e1e | 231 | done |
78a1a75c | 232 | fi |
e58bbdf6 | 233 | echo \' >&2 |
acd03e1e | 234 | done |
a5c2ac43 | 235 | return 0 |
78a1a75c | 236 | } |
364203b2 | 237 | |
a5c2ac43 | 238 | ####################################### |
e58bbdf6 IK |
239 | # Internal function for err-catch. Prints stack trace from interactive |
240 | # shell trap. | |
a5c2ac43 | 241 | # |
5f3350ec IK |
242 | # Usage: see err-catch-interactive |
243 | ####################################### | |
5f3350ec | 244 | _err-bash-trace-interactive() { |
55f98600 IK |
245 | if (( ${#FUNCNAME[@]} <= 1 )); then |
246 | return 0 | |
247 | fi | |
248 | ||
249 | for pattern in "${err_catch_ignore[@]}"; do | |
250 | # shellcheck disable=SC2053 | |
251 | if [[ ${BASH_SOURCE[1]} == $pattern ]]; then | |
252 | return 0 | |
253 | fi | |
254 | done | |
255 | ||
3fb24829 IK |
256 | local ret bash_command argc pattern i last |
257 | last=$_err_func_last | |
258 | _err_func_last=${#FUNCNAME[@]} | |
5f3350ec IK |
259 | # We have these passed to us because they are lost inside the |
260 | # function. | |
261 | ret=$1 | |
b4d7f86a IK |
262 | pipestatus="$2" |
263 | bash_command="$3" | |
264 | argc=$(( $4 - 1 )) | |
265 | shift 4 | |
5f3350ec | 266 | argv=("$@") |
3fb24829 | 267 | # The trap returns a nonzero, then gets called again. This condition |
b4d7f86a IK |
268 | # tells us if is that has happened by checking if we've gone down a |
269 | # stack level. | |
270 | if (( _err_func_last >= last )); then | |
271 | printf "ERR: \`%s\' returned %s" "$bash_command" $ret >&2 | |
272 | if [[ $pipestatus != "$ret" ]]; then | |
273 | printf ", PIPESTATUS: %s" "$pipestatus" >&2 | |
274 | fi | |
275 | echo >&2 | |
5f3350ec | 276 | fi |
e58bbdf6 | 277 | printf " from \`%s" "${FUNCNAME[1]}" >&2 |
3fb24829 IK |
278 | if shopt extdebug >/dev/null; then |
279 | for ((i=argc; i >= 0; i--)); do | |
e58bbdf6 | 280 | printf " %s" "${argv[i]}" >&2 |
3fb24829 | 281 | done |
5f3350ec | 282 | fi |
e58bbdf6 IK |
283 | printf "\' defined at %s:%s\n" "${BASH_SOURCE[1]}" "$(declare -F "${FUNCNAME[1]}"|awk "{print \$2}")" >&2 |
284 | if [[ -t 1 ]]; then | |
285 | return $ret | |
286 | else | |
287 | # Part of an outgoing pipe, avoid getting get us stuck in a weird | |
288 | # subshell if we returned nonzero, which would happen in a situation | |
289 | # like this: | |
290 | # | |
291 | # tf() { while read -r line; do :; done < <(asdf); }; | |
292 | # tf | |
293 | # | |
294 | # Note: exit $ret also avoids the stuck subshell problem, and I | |
295 | # can't notice any difference, but this seems more proper. | |
296 | return 0 | |
5f3350ec | 297 | fi |
9ce3dca1 | 298 | } |