Debugging bash scripts

Unlike many programming languages, shell(bash) scripting has no official tool for debugging. Besides the classic “prints”, I was not aware that bash has some built-in mechanisms for tracing, until I decided to “read the manual”. As I was struggling with understanding some large shell scripts (that I did not write myself), I found some valuable information just by running man bash.

To my surprise, bash does have a debugger mode/profile, which can be seen on the very first page of the manual:

--debugger
    Arrange  for  the  debugger profile to be executed before the shell starts.  Turns on extended debugging mode
    nn(see the description of the extdebug option to the shopt builtin below).

What does --debugger do? Well, it turns on the extended debugging mode, which is equivalent to setting a shell option called extdebug. A search for the keyword extdebug can give us the answer:

extdebug
        If  set  at shell invocation, or in a shell startup file, arrange to execute the debugger profile be‐
        fore the shell starts, identical to the --debugger option.  If set  after  invocation,  behavior  in‐
        tended for use by debuggers is enabled:

       1.     The -F option to the declare builtin displays the source file name and line number correspond‐
              ing to each function name supplied as an argument.

       2.     If the command run by the DEBUG trap returns a non-zero value, the next command is skipped and
              not executed.

       3.     If  the  command  run  by the DEBUG trap returns a value of 2, and the shell is executing in a
              subroutine (a shell function or a shell script executed by the  .  or  source  builtins),  the
              shell simulates a call to return.

       4.     BASH_ARGC and BASH_ARGV are updated as described in their descriptions above.

       5.     Function tracing is enabled: command substitution, shell functions, and subshells invoked with
                             ( command ) inherit the DEBUG and RETURN traps.

       6.     Error tracing is enabled: command substitution, shell functions, and subshells invoked with  (
                             command ) inherit the ERR trap.

The features 4-6 caught my interest. By doing more research, I found out that a shell option can be toggled either using the shopt built-in command or using +/-O (+ is for untoggling the option and - is for toggling, weird right?) when launching the bash executable. Our option can be set using shopt -s extdebug from a script.

The extdebug option is tightly related to the trap command that allows setting event listeners on different types of events and signals. The DEBUG trap in particular is executed before every command is executed:

If a sigspec is DEBUG, the command arg is executed before every simple command, for command, case command, select command, every arithmetic for command, and before the first command executes in a shell function (see SHELL GRAMMAR above).

I did more digging for BASH_ARGC and BASH_ARGV, and I found more than I was looking for in the shell variables section:

 BASH_ARGC
              An array variable whose values are the number of parameters in each frame of the current bash execution  call
              stack.   The  number  of  parameters  to  the current subroutine (shell function or script executed with . or
              source) is at the top of the stack.  When a subroutine is executed, the number of parameters passed is pushed
              onto  BASH_ARGC.   The  shell sets BASH_ARGC only when in extended debugging mode (see the description of the
              extdebug option to the shopt builtin below).  Setting extdebug after the  shell  has  started  to  execute  a
              script, or referencing this variable when extdebug is not set, may result in inconsistent values.
BASH_ARGV
              An  array  variable containing all of the parameters in the current bash execution call stack.  The final pa‐
              rameter of the last subroutine call is at the top of the stack; the first parameter of the initial call is at
              the  bottom.   When  a  subroutine is executed, the parameters supplied are pushed onto BASH_ARGV.  The shell
              sets BASH_ARGV only when in extended debugging mode (see the description of the extdebug option to the  shopt
              builtin  below).  Setting extdebug after the shell has started to execute a script, or referencing this vari‐
              able when extdebug is not set, may result in inconsistent values.

BASH_LINENO
              An array variable whose members are the line numbers in source files where each corresponding member of FUNC‐
              NAME was invoked.  ${BASH_LINENO[$i]} is the line number in  the  source  file  (${BASH_SOURCE[$i+1]})  where
              ${FUNCNAME[$i]} was called (or ${BASH_LINENO[$i-1]} if referenced within another shell function).  Use LINENO
              to obtain the current line number.

BASH_COMMAND
        The  command currently being executed or about to be executed, unless the shell is executing a command as the
        result of a trap, in which case it is the command executing at the time of the trap.  If BASH_COMMAND is  un‐
        set, it loses its special properties, even if it is subsequently reset.


FUNCNAME
              An array variable containing the names of all shell functions currently in the execution call stack.  The el‐
              ement  with  index 0 is the name of any currently-executing shell function.  The bottom-most element (the one
              with the highest index) is "main".  This variable exists only when a shell function  is  executing.   Assign‐
              ments  to FUNCNAME have no effect.  If FUNCNAME is unset, it loses its special properties, even if it is sub‐
              sequently reset.

              This variable can be used with BASH_LINENO and BASH_SOURCE.  Each element of FUNCNAME has corresponding  ele‐
              ments  in  BASH_LINENO  and BASH_SOURCE to describe the call stack.  For instance, ${FUNCNAME[$i]} was called
              from the file ${BASH_SOURCE[$i+1]} at line number ${BASH_LINENO[$i]}.  The caller builtin displays  the  cur‐
              rent call stack using this information.

The above environment variables provide us with some contextual information about the line where the command is executed within the script file, the current function name as well as the function arguments if there are any. We have now enough information to establish some debugging infrastructure of our own.

By creating a debug trap that prints the above variables, we can instrument any script to display those informations while running:

shopt -s extdebug

_debug_() {
  echo -e "----------- DEBUG INFO ------------------\n"
  echo -e "line $BASH_LINENO, executing: $(echo -e $BASH_COMMAND | tr -d '\r\n')\n"
  echo -e "current function call stack:\n"
  for func_name in ${FUNCNAME[@]}; do
      if [ "$func_name" != "_debug_" ]; then
        echo -e "$func_name"
      fi
  done
  echo -e "current function arguments: ${BASH_ARGV[*]}"
  echo -e "\n------------------\n"
}

trap '_debug_' DEBUG

Pasting those statements in every script we want to debug can be tedious, so we can create a wrapper script that accepts the script to debug as an argument:

echo "debugging script $1: $@"

shopt -s extdebug

script_args=$@

_debug_() {
  echo -e "----------- DEBUG INFO ------------------\n"
  echo -e "line $BASH_LINENO, executing: $(echo -e $BASH_COMMAND | tr -d '\r\n')\n"
  echo -e "current function call stack:"
  for func_name in ${FUNCNAME[@]}; do
      if [ "$func_name" != "_debug_" ] && [ "$func_name" != "source" ]; then
        echo -e "$func_name"
      fi
  done
  echo  "current function arguments: "
   # we need to do some processing here to avoid printing the script args
   # and also the source function args
   for arg in ${BASH_ARGV[@]}; do
    found=false
    for script_arg in $script_args; do
      if [ "$arg" == "$script_arg" ]; then
        found=true
        break
      fi
    done
    if [ "$found" != "true" ]; then
      echo -e "$arg"
    fi 
   done
  echo -e "\n------------------\n"
}

trap '_debug_' DEBUG

#we need to pass parameters as well, in case the script is taking parameters
#$0 is the name of the executed script and $1 is the name of our input script
#our parameters start from index 2
source $1 ${@:2}

Last but not least, we can also add some colors to our debugging information to distinguish between the script output and the debug output.

In the bash manual (section PROMPTING), it is mentioned that an escape AINSI character can be used by inserting a \e. AINSI escape characters are used to control the color, fonts,…etc in terminals. We need to put our debug text between two escape sequences. To set the color of our debug output to red, we can add \e[31m (the red code is 31m) to the beginning of our first print, and \e[0 (special sequence to indicate reset of the original attributes) to the end of the last print.

_debug_() {
echo -e "\e[31m----------- DEBUG INFO ------------------\n"
// other stuff...
echo -e "\n------------------\e[0m\n"
}

Something important that we should not forget is output redirection. We should make sure that our debug output is redirected to standard error to avoid interfering with the debugged script output. In case the debugged script is interacting with standard input/output, debugging should have no side effects. In the REDIRECTION section in the bash manual, it is mentioned that redirection from standard output to standard error can be done by adding 1>&2 at the end of a command, and therefore, we need then to add 1>&2 to each of our echo commands:

echo "debugging script $1: $@" 1>&2

shopt -s extdebug

script_args=$@

_debug_() {
  echo -e "\e[31m----------- DEBUG INFO ------------------\n" 1>&2
  echo -e "line $BASH_LINENO, executing: $(echo -e $BASH_COMMAND | tr -d '\r\n')\n" 1>&2
  echo -e "current function call stack:" 1>&2
  for func_name in ${FUNCNAME[@]}; do
      if [ "$func_name" != "_debug_" ] && [ "$func_name" != "source" ]; then
        echo -e "$func_name" 1>&2
      fi
  done
  echo  "current function arguments: "
   for arg in ${BASH_ARGV[@]}; do
    found=false
    for script_arg in $script_args; do
      if [ "$arg" == "$script_arg" ]; then
        found=true
        break
      fi
    done
    if [ "$found" != "true" ]; then
      echo -e "$arg" 1>&2
    fi 
   done
  echo -e "\n------------------\e[0m\n" 1>&2
}

trap '_debug_' DEBUG

source $1 ${@:2}

Et voilà! We now have a debugging tool that we can use for tracing any bash script: debug.sh the_script_to_debug.sh