Bash Function Traps

Bash EXIT traps are indispensable when writing complex scripts that need to clean up after themselves. An EXIT trap works a lot like a try/finally pair in a language like C++ or Python: it allows you to specify code that should be run when a script exits.

Here’s a concrete example showing its use. This script creates a backup of a Bitcoin data directory, encrypts it with GPG, and then copies the encrypted backup to a Google Cloud Storage bucket. The script uses an EXIT trap to ensure that the local backup and the local encrypted backup are always deleted when the script exits:

# exit when any command returns an error
set -e

# make sure our cleanup code runs on script exit
trap 'rm -f ~/backup.dat{,.gpg}' EXIT

# create the raw wallet backup
bitcoin-cli backupwallet ~/backup.dat

# encrypt the backup and copy to google cloud storage
gpg --batch --yes -r myself -e ~/backup.dat
gsutil cp ~/backup.dat.gpg gs://my-backups/

Unfortunately, there is no mechanism to create traps that are scoped to functions. For instance, suppose we had wrapped our backup code in a function defined in our ~/.bashrc file. If the function sets an EXIT trap, there are two problems:

Normally when you’re writing shell scripts these aren’t major problems. You should know what traps have been defined, and deferring cleanup until the script actually exits isn’t generally a problem. However, this behavior does become problematic when defining functions intended for interactive use, e.g. functions defined in one’s ~/.bashrc file. The problem is that even if you did want to use an EXIT trap, it might be hours or days (or really any arbitrarily long amount of time) before the user actually exits the

I would love it if some day a future Bash version implements a new type of trap scoped to functions (e.g. FUNCTIONEXIT), but that doesn’t exist. Here are two possible workarounds.

Workaround #1

The most straightforward way to work around this problem is to write two functions. The first has the core logic, and by convention will be prefixed with an underscore. The second function calls the core function, saves the exit status, does cleanup, and then return the original exit status.

# user should not call this directly
_backup() {
  # create the raw wallet backup
  bitcoin-cli backupwallet ~/backup.dat

  # encrypt the backup and copy to google cloud storage
  gpg --batch --yes -r myself -e ~/backup.dat
  gsutil cp ~/backup.dat.gpg gs://my-backups/
}

# version the user is expected to call
backup() {
  # invoke _backup and save the exit status
  _backup
  local s=$?

  # do the cleanup logic
  rm -f ~/backup.dat{,.gpg}

  # return the exit status of _backup
  return $s
}

Workaround #2

Here’s another way to solve the problem, which I like more. The idea is to use a regular EXIT trap, but run the core logic in a subshell:

backup() {
  ( # everything within these parens run in a subshell
  trap 'rm -f ~/backup.dat{,.gpg}' EXIT

  # create the raw wallet backup
  bitcoin-cli backupwallet ~/backup.dat

  # encrypt the backup and copy to google cloud storage
  gpg --batch --yes -r myself -e ~/backup.dat
  gsutil cp ~/backup.dat.gpg gs://my-backups/
  ) # end of the subshell expression
}

In case you’re not familiar with the subshell syntax, enclosing one or more bash statements in parentheses causes those statements to run in a subshell. This isolates the code to its own process, and makes this pattern much simpler.