Bash debugger with support for arbitrary breakpoints

In the comments to the article on debugging bash scripts, I suggested that the suggested debugging approach could be extended to include support for breakpoints. After some thought, I slightly supplemented the code suggested in the comments to the article and this is what happened:

#!/bin/bash

__dbg__breakpoints=()
__dbg__trace=2
__dbg__trap() {
    local __dbg__cmd __dbg__cmd_args __dbg__set="$(set +o)" \
        __dbg__do_break=false
    set +eu
    ((__dbg__trace == 1)) \
        && echo "+(${BASH_SOURCE[1]}:${BASH_LINENO[0]}): $BASH_COMMAND"
    for __dbg__breakpoint in "${__dbg__breakpoints[@]}"; do
        eval "$__dbg__breakpoint" && __dbg__do_break=true && break
    done
    ((__dbg__trace == 2)) || $__dbg__do_break && {
        ((__dbg__trace == 0)) \
            && echo "+(${BASH_SOURCE[1]}:${BASH_LINENO[0]}): $BASH_COMMAND"
        ((__dbg__trace == 2)) && __dbg__trace=0
        while read -p "bdb> "  __dbg__cmd __dbg__cmd_args; do
            case $__dbg__cmd in
                '') eval "$__dbg__set" && return 0 ;;
                trace) ((__dbg__trace ^= 1)) ;;
                bl) printf "%s\n" "${__dbg__breakpoints[@]}" \
                    | grep . | cat -n ;;
                ba) __dbg__breakpoints+=("$__dbg__cmd_args") ;;
                bd) unset __dbg__breakpoints[$((__dbg__cmd_args - 1))] \
                    && __dbg__breakpoints=("${__dbg__breakpoints[@]}") ;;
                *) eval "$__dbg__cmd $__dbg__cmd_args" ;;
            esac
        done
    }
}

set -T
trap "__dbg__trap" debug

. "$@"

To demonstrate how the debugger works, I will use the following script

#!/bin/bash

set -eu

print_arg() {
    local j=$((i+1))
    echo "$j: $1"
    i=$j
}

i=0
while (( $# )); do
    print_arg "$1"
    shift
done

Let’s run the script under the debugger

$ ./bdb.sh ./bdb-test.sh aa "bb cc" dd ee
bdb>

Immediately after launch, we see a debugger prompt and if we just press enter, the script will continue to run normally.

1: aa
2: bb cc
3: dd
4: ee
$

Looking at the source of the debugger, you will see that 4 internal commands are available to us:

If you enter an empty command, i.e. just press enter, the debugger will continue executing the script being debugged. Non-empty input that is not an internal command will be executed in the current context of the script being debugged.

Let’s add a simple breakpoint that will fire on every line.

$ ./bdb.sh ./bdb-test.sh aa "bb cc" dd ee
bdb> ba true
bdb>
+(./bdb-test.sh:3): set -eu
bdb>
+(./bdb-test.sh:11): i=0
bdb>
+(./bdb-test.sh:12): (( $# ))

With each press of enter, the next line of our script is executed, and we have the opportunity to intrude into the execution process. For example, we can change the value of the variable i. In addition, let’s remove the breakpoint that fires on every line, and add a condition to break on a specific line instead.

bdb> i=10
bdb> bl
     1  true
bdb> bd 1
bdb> bl
bdb> ba ((BASH_LINENO == 14))
bdb>
11: aa
+(./bdb-test.sh:14): shift

Note that instead of 1: aa, the script outputs 11: aa. This happened because we intervened in the execution process and changed the value of the variable i. The stop happened on line 14, as we wanted. Let’s now break at the moment we enter the print_arg function.

bdb> bl
     1  ((BASH_LINENO == 14))
bdb> bd 1
bdb> ba [ ${FUNCNAME[1]} == print_arg ]
bdb>
+(./bdb-test.sh:5): print_arg "$1"
bdb> echo $j

bdb>
+(./bdb-test.sh:6): local j=$((i+1))
bdb> echo $j

bdb>
+(./bdb-test.sh:7): echo "$j: $1"
bdb> echo $j
12

After the stop, we checked the state of the j variable, but since we stopped right before the function entry, this variable has not yet been defined. The break condition will be triggered on every line inside the print_arg function. Let’s see when the j variable becomes available to us. As expected, the variable appeared after the definition. It is important to note that j is a local variable, which does not prevent us from having full access to it from the debugger.

Now let’s stop when the value of the variable i becomes equal to 13. And also enable tracing to look at the progress.

bdb> bd 1
bdb> ba ((i == 13))
bdb> trace
bdb>
12: bb cc
+(./bdb-test.sh:8): i=$j
+(./bdb-test.sh:14): shift
+(./bdb-test.sh:12): (( $# ))
+(./bdb-test.sh:13): print_arg "$1"
+(./bdb-test.sh:5): print_arg "$1"
+(./bdb-test.sh:6): local j=$((i+1))
+(./bdb-test.sh:7): echo "$j: $1"
13: dd
+(./bdb-test.sh:8): i=$j
+(./bdb-test.sh:14): shift

With our curiosity satisfied, we can remove the break condition, turn off tracing, and hit enter to let the script exit.

bdb> bd 1
bdb> trace
bdb>
14: ee
$

This is a very short demo that only showed the most basic scenarios for using the debugger. Conditions can check more than just the state of variables. You can also check the presence or absence of files, the presence or absence of certain lines in files, whether a certain user is logged in, etc.

If you have any questions about the implementation of the debugger or suggestions for improvement, let’s discuss it in the comments. The code is available at github.

Similar Posts

Leave a Reply