fix PIPESTATUS printing
[errhandle.git] / err
CommitLineData
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
44if ! 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#######################################
73err-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.
104err-catch
105
106#######################################
107# Undo err-catch/err-catch-interactive
108#######################################
109err-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#######################################
131err-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#######################################
214err-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}