Exit shell script from a subshell

- 4 min read

Recently, I was writing a bash script that had many fatal error conditions where I wanted to output a message to stderr then kill the script with an error code. The problem was some of the commands were in subshells, making it impossible to create a clean way to accomplish my goal.

What are subshells

When a command is executed in shell/bash inside a set of parenthesis it spawns a new process which doesn’t have access to quit the parent script.

Example script:

#!/bin/sh
echo "output before exit"

# This subshell will not cause the parent process to exit
(exit 1)

echo "output after exit"

Result of running the script:

output before exit
output after exit

Use signals to exit process from subshell

Before going any further, here is an example script that allows the subshell to terminate the process:

#!/bin/sh

trap "exit 1" 10
PROC="$$"

fatal(){
  echo "$@" >&2
  kill -10 $PROC
}

echo "output before exit"
(fatal "no more output, script exiting")
echo "output after exit"

Result of running the script:

output before exit
no more output, script exiting

What are signals

Processes in *nix based operating systems can respond to signals to perform an action from outside the process. This is the mechanism that allows you to exit a command line process by pressing ctrl-c. When you press ctrl-c in your terminal, an interrupt signal (SIGINT) is sent to the current process. The process then responds to that signal by exiting (if it’s a well behaved process).

You can see all the signals defined on your system by running trap -l, which will give you output like the following:

 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

Next to each signal (such as SIGINT) is the number (2 in the case of SIGINT). The script above that allowed the subshell to exit utilized “10”, the SIGUSR1 signal.

How to respond to a signal using trap

A shell script can listen for a signal by using trap. To demonstrate how trap works we’ll create a script that responds to the SIGINT signal and echos a message:

#!/bin/sh

# 2 is the SIGINT signal
trap 'echo "received SIGINT"' 2
# This could also be written as:
# trap 'echo "received SIGINT"' INT

# Note, SIGINT will be sent to child jobs, so the sleep
# process will be interrupted when SIGINT is received.
sleep 30

echo "echo after sleep finishes"

Running this script then pressing ctrl-c will result in the following:

^Creceived SIGINT
echo after sleep finishes

See trap --help for additional usage.

Putting it together to exit a script from a subshell

In a script, we’ll first create a trap for SIGUSR1 (a user defined signal) to act upon that will exit the process with exit code 1:

trap "exit 1" 10

Then, we’ll store the process ID of the script being executed so that we can send a signal to it:

PROC=$$

Lastly, we’ll define a function that the process and subshell processes can invoke that will echo a message to stderr and send our SIGUSR1 signal to the script’s process using kill:

fatal(){
  echo "$@" >&2
  kill -10 $PROC
}

What we end up with is the following:

#!/bin/sh

# fatal uses SIGUSR1 to allow clean fatal errors
trap "exit 1" 10
PROC=$$
fatal(){
  echo "$@" >&2
  kill -10 $PROC
}

Example

I’ve saved the following as test.sh and made it executable with chmod +x test.sh:

#!/bin/sh

# fatal uses SIGUSR1 to allow clean fatal errors
trap "exit 1" 10
PROC=$$
fatal(){
  echo "$@" >&2
  kill -10 $PROC
}

test -n "$1" || fatal "you must provide a path to a file as the first argument"

test -f $1 || fatal "'${1}' isn't a file"

(fatal "'${1}' pointed to a file, good job, but we're proving the subshell fatals too")

echo "This should never output"

When executing the script:

  • ./test.sh gives:

    you must provide a path to a file as the first argument
    
  • ./test.sh . gives:

    '.' isn't a file
    
  • ./test.sh ./test.sh gives:

    'test.sh' pointed to a file, good job, but we're proving the subshell fatals too
    

All exit with exit code 1.