Skip to content

atexit for bash

 3 min A LIFO exit-handler stack from jira-nice, with a nonstandard signal map and a broken pop-by-count path.
Published

Jun 4, 2018

A bare bash trap holds one command string, and assigning another handler for the same signal replaces it. This is a real atexit—a LIFO stack of handlers with deregistration—from jira-nice, my go-jira personalization layer. The pop-by-count path has a bug; the historical code and its correction are both below.

trap 'exit $((127+1))' HUP
trap 'exit $((127+2))' INT
trap 'exit $((127+3))' QUIT
trap 'exit $((127+15))' TERM
trap 'rc=$?; set +e; _atexit' EXIT
__atexit=()
_atexit () {          # (debug logging elided)
  indices=("${!__atexit[@]}")
  for ((i=${#indices[@]}-1; i>=0; i--)); do
    ${__atexit[indices[i]]}
  done
  return $rc
}

The signal traps do no cleanup. Each one exits with 127+signum, and exiting triggers the EXIT trap. That mapping distinguishes the four traps from each other, but it is one below the shell convention 128+signum; a caller applying the conventional decoding reads the wrong signal.

All four signals and every normal exit still funnel through one code path: capture the status, drop errexit so a failing handler can’t abort the handlers behind it, then run the stack in reverse registration order. Cleanup logic lives in exactly one place. The status preserves the script’s custom code, not the standard signal identity; a corrected implementation would use 128+signum.

Registration overloads one function on its argument shape:

atexit () {
  if [[ $1 =~ ^-([0-9]*)$ ]]; then
    # Pop N objects from the stack.
    local indices=("${!__atexit[@]}")
    local length=${#__atexit[@]}
    local offset=$(($length-${BASH_REMATCH[1]:-$length}))
    for ((i=(offset>=0 && offset || 0); i<length; i++)); do
      unset __atexit[i]
    done
  else
    # Append N objects to the stack.
    __atexit+=("${@}")
  fi
}

Plain arguments push handlers. -N is supposed to pop the last N without running them. A bare - pops everything—the empty capture group defaults the count to the full length. The runner snapshots "${!__atexit[@]}", the actual index list, instead of assuming indices run 0 through N-1—the safe way to walk a bash array that unset may have made sparse.

The pop loop doesn’t do what its comment says. In bash arithmetic, offset>=0 && offset || 0 returns a boolean, not the value of offset. Every positive offset becomes 1. With five registered handlers, -0, -1, and -2 all leave only the first handler. Even “pop nothing” drops four.

A corrected pop branch uses a ternary and walks the saved indices. It also reads counts as decimal, so -08 doesn’t become an invalid octal number:

local indices=("${!__atexit[@]}")
local length=${#indices[@]}
local count=${BASH_REMATCH[1]}
if [[ -n $count ]]; then
  count=$((10#$count))
else
  count=$length
fi
local offset=$((length-count))
local i
for ((i=(offset > 0 ? offset : 0); i<length; i++)); do
  unset "__atexit[${indices[i]}]"
done

This replaces the body of the if branch above. The index snapshot makes it work on sparse arrays too. Popping past the beginning clears the stack; -0 leaves it alone.

The canonical customer is tmpdir:

tmpdir () {
  if [[ -z $JIRA_TMP ]]; then
    JIRA_TMP=$(mktemp -d)
    debug "[$JIRA_TMP] Created \$JIRA_TMP."
    atexit tmpdir_clean
  fi
}

Lazy in both directions: no temp directory exists until something asks for one, and the cleanup handler registers exactly once, at creation. Any sourced custom command can call tmpdir and leave removal to the registered handler.

The gotcha: handlers are expanded unquoted when invoked, so each must be a single word. In practice that means: write a function, push its name.