From the bash manpage we have the following documentation
If the name is neither a shell function nor a builtin, and contains no slashes, bash searches each element of the PATH for a directory containing an executable file by that name. Bash uses a hash table to remember the full pathnames of executable files (see hash under SHELL BUILTIN COMMANDS below). A full search of the directories in PATH is performed only if the command is not found in the hash table. If the search is unsuccessful, the shell searches for a defined shell function named command_not_found_handle. If that function exists, it is invoked in a separate execution environment with the original command and the original command’s arguments as its arguments, and the function’s exit status becomes the exit status of that subshell. If that function is not defined, the shell prints an error message and returns an exit status of 127. - bash manpage
Simple exit does NOT work with command_not_found_handle
⚠️ Simple exit in command_not_found_handle will not work
simple Exit in Handle Will Not Work
The catch is that command_not_found_handle will run in separate execution.
So even when we add the command_not_found_handle with an exit
[$$=21132/21132] START[$$=21132/21133] command_not_found_handle invoked with arguments=[i_dont_exist hi1 hi2][$$=21132/21132] DONE
We still end up running over the command not found issue. Albeit at least now we have a piece of code that executes on this event.
We can see by $BASHPID value 21133 that command_not_found_handle ran in a separate process, then our main function. So when we call exit in command_not_found_handle we are exiting process 21133 NOT 21132, which is running our main function.
👍 Use interrupt with command_not_found_handle - for your Terminal Bash Env👍
Use Interrupt with Handle - for your Terminal Bash Env
While regular exit will not work from the child process that command_not_found_handle runs, interrupting the $$ process will.
Below is how the handle looks like that will halt the main when functions are NOT found.
#!/usr/bin/env bashecho_with_pid(){ echo "[\$\$=$$/$BASHPID] $*"}# 🏁 This handler will prevent 'not found commands' from continuing scripts in BASH 🏁command_not_found_handle() { echo "command_not_found_handle: invoked with arguments=[$*]." echo "command_not_found_handle: Interrupting process group [$$]." # Send an interrupt signal (SIGINT) to the entire process group # of the current shell session # # Breakdown: # - kill: command to send signals to processes # - -INT: the interrupt signal (same as Ctrl+C) # - -$$: negative PID targets the process group # - $$: shell variable containing current shell's PID # - negative sign: targets entire process group, not just single process kill -INT -$$}# Export this handler so it's available in:# Child bash scripts that your script executes (e.g., ./some_script.sh)## Without export: Only THIS script uses the handler# With export: ALL child bash processes inherit and use this handlerexport -f command_not_found_handlemain() { echo_with_pid "START" i_dont_exist hi1 hi2 echo_with_pid "DONE"}main "${@}"
Output is below, where we can see that ‘DONE’ was prevented from running.
[$$=23624/23624] START[$$=23624/23625] command_not_found_handle: invoked with arguments=[i_dont_exist hi1 hi2].[$$=23624/23625] command_not_found_handle: Interrupting process group [23624].
Interrupting and CI/CD
Important: This interrupt-based approach works well for interactive terminal use, but should not be used in CI/CD pipelines (e.g., jenkins). The interrupt signal can terminate your CI/CD runner unexpectedly, causing what appears to be a crash without clear error messages.
Alternative for CI/CD: Instead of interrupting, have command_not_found_handle log errors to a file. Then check this log file either:
At the end of your script, or
Before executing the next step
This ensures errors are visible without risking runner termination.
#!/usr/bin/env bashcommand_not_found_handle() { # Exit echo_with_pid "command_not_found_handle: invoked with arguments=[$*]." echo_with_pid "command_not_found_handle: Interrupting process group [$$]." # Send an interrupt signal (SIGINT) to the entire process group # of the current shell session # # Breakdown: # - kill: command to send signals to processes # - -INT: the interrupt signal (same as Ctrl+C) # - -$$: negative PID targets the process group # - $$: shell variable containing current shell's PID # - negative sign: targets entire process group, not just single process kill -INT -$$}echo_with_pid(){ echo "[\$\$=$$/$BASHPID] $*"}foo2() { echo_with_pid "foo2-enter" i_dont_exist echo_with_pid "foo2-exit"}foo1() { echo_with_pid "foo1-enter" foo2 echo_with_pid "foo1-exit"}main() { echo_with_pid "START" foo1 echo_with_pid "DONE"}if main; then echo "Main succeeded"else echo "Main failed"fi
As we would like, the handler get’s called which allows to intercept.
❯/tmp/scratch.sh[$$=39900/39900] START[$$=39900/39900] foo1-enter[$$=39900/39900] foo2-enter[$$=39900/39901] command_not_found_handle: invoked with arguments=[i_dont_exist].[$$=39900/39901] command_not_found_handle: Interrupting process group [39900].
The handler even works when we have non-zero branch right on the non existing function
#!/usr/bin/env bashcommand_not_found_handle() { echo_with_pid "command_not_found_handle: invoked with arguments=[$*]." echo_with_pid "command_not_found_handle: Interrupting process group [$$]." kill -INT -$$}echo_with_pid(){ echo "[\$\$=$$/$BASHPID] $*"}main() { echo_with_pid "START" i_dont_exist b-1 b-2 || echo "If it doesnt exist" echo_with_pid "DONE"}main "${@}" || exit 1
Output:
❯/tmp/scratch.sh[$$=42511/42511] START[$$=42511/42512] command_not_found_handle: invoked with arguments=[i_dont_exist b-1 b-2].[$$=42511/42512] command_not_found_handle: Interrupting process group [42511].
You could be quite surprised to find out the following output, which shows that command_not_found_handle did trigger, BUT somehow the FINISH echo was allowed to execute:
START-[$$=2706051/2706051] 🤷 [$$=2706051/2706052] command_not_found_handle: invoked with arguments=['iDontExist']. 🤷 [$$=2706051/2706052] command_not_found_handle: Interrupting process group [2706051]. 🤷 FunctionChain: [main:7 (scratch.sh)-->main:3 (scratch.sh)-->command_not_found_handle]FINISH-[$$=2706051/2706051]
The fix: Add shebang
The fix is simple, add #!/usr/bin/env bash and it works as expected:
command_not_found_handle handler with kill -INT -$$ allows us to create a safer bash environment & scripts without having to use set -e, which has its own set of challenges (more on set -ehere)