Using trap to Run a Command after Your Shell Script Exits
This can be handy to delete temp files that your script creates or to always write a log file out if the script works or fails.
For example, let’s say you have a pretty involved shell script that runs 5
different functions. Each of these functions have commands that might fail,
such as using curl
to get an HTTP response from a site or maybe gathering
stats about a file that may or may not exist.
You might want to ensure that your script always writes a log entry but it
would be really tedious to wrap each command that might fail with an if
condition to call your log function. That’s a lot of boilerplate of using set +e && RUN_COMMAND && set -e
and then potentially calling your log function
with different arguments.
With trap
you can solve this is in a very clean way, here’s an example
script. First we’ll see what the code looks like with comments and then we’ll
break down what trap
is doing:
#!/usr/bin/env bash
set -o errexit
set -o pipefail
set -o nounset
# Default values for our log entry.
PATH_INPUT="${1:-}"
PATH_EXISTS="false"
PATH_FILE_COUNT="-1"
# EXIT is special, it captures just about any way a program can exit.
trap "log" EXIT
# We can trap specific signals too, in this case INT == CTRL+C.
trap "echo 'I HEARD YOU LIKE CTRL+C'" INT
# This will not work, this is only shown here to go over why it doesn't work:
#
# Unix systems block being able to trap KILL (9) which makes sense. If a
# script could block it, a bad actor could prevent their script from exiting
# even if you forcefully kill it in the harshest way possible.
trap "echo 'WE CHEATED DEATH?'" KILL
# For this example we're logging in JSON format. A multi-line string is nice
# to keep things readable.
log() {
cat << EOF
{
"timestamp": "$(date +'%Y-%m-%dT%H:%M:%S')",
"path": "${PATH_INPUT}",
"path_exists": ${PATH_EXISTS},
"path_file_count": ${PATH_FILE_COUNT}
}
EOF
}
# This command may or may not work depending on if the path exists, but since we
# have set -o errexit enabled we can be sure it exists if the `ls` command
# worked because the script would have halted if `ls` failed.
list_contents() {
ls -la "${PATH_INPUT}"
PATH_EXISTS="true"
}
# Likewise, PATH_FILE_COUNT will only be set with a value >= 0 when the path exists.
count_files() {
PATH_FILE_COUNT="$(find "${PATH_INPUT}" -type f | wc -l)"
}
# Calling our functions.
list_contents
count_files
# The script will remain running forever until you exit it somehow. This lets
# us demonstrate trapping CTRL+C or other signals.
sleep infinity &
wait
So, the idea with trap is you can pass it a command to evaluate along with 1 or
more signals to react to. I didn’t demo it in the script but you can pass in
multiple signals per command such as trap "whoami" INT TERM
. You can also
chain commands together like trap "whoami && echo 'COOL STORY'" EXIT
.
If you’re running bash
you can run trap -l
to get a full list of signals.
If you’re running zsh
you can run bash -c "trap -l"
to get the list. The
command is a bit different in both shells.
Overall we want to put our traps before any commands that might fail so they
are defined. If we put the traps on the bottom of the script then set -e
will
halt the script before the traps are evaluated. That’s why I defined them near
the top of the file.
I like this pattern because each function doesn’t need to worry about calling
log
and it’s a safe bet no matter what the script does it’ll get called.
Of course an exception here is KILL
(9
). There’s not much we can do here
to guarantee something runs after this signal happens. In case you’re curious,
if you do run shellcheck
on the script it will warn you that KILL
is
untrappable.
This is a good reminder that you can’t always depend on something happening and
you should at least think through scenarios on how your script can clean up
after itself during an edge case such as the power being cut off on the machine
with no warning or the system owner running a kill -9 <PID>
on your script.
We only demo’d writing log entries but you can use trap
in the same exact way
for deleting temp files that your script uses or anything you want such as
broadcasting the status of something to Slack in a pretty resilient way.
Personally I find myself using EXIT
the most but I’m sure there’s a time and
place for using more specific signals.
The video below goes into more detail and demos the script in various states.
# Demo Video
Timestamps
- 0:25 – A few use cases
- 1:41 – Running the script without trap
- 3:53 – Trapping all exit signals
- 5:23 – Trapping CTRL+C (SIGINT) specifically
- 6:42 – SIGKILL (kill -9) is untrappable on Unix / Linux
- 8:18 – Trapping SIGTERM
- 9:03 – Going over coding patterns on how to use trap
- 13:35 – List all of the possible signals with trap
References
What was the last thing you used trap for? Let me know below.