blog

git clone https://git.ce9e.org/blog.git

commit
25b82f88cb7d2a89263b9a5b34762f7929f72b20
parent
53bc22fe15218039d259c842b4014560dd0f1fab
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2026-07-13 12:28
add article on trap

Diffstat

A _content/posts/2026-07-13-trap/index.md 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A _content/posts/2026-07-13-trap/test.py 44 ++++++++++++++++++++++++++++++++++++++++++++

2 files changed, 187 insertions, 0 deletions


diff --git a/_content/posts/2026-07-13-trap/index.md b/_content/posts/2026-07-13-trap/index.md

@@ -0,0 +1,143 @@
   -1     1 ---
   -1     2 title: Using trap for cleanup in shell scripts
   -1     3 date: 2026-07-13
   -1     4 tags: [linux]
   -1     5 description: "trap … EXIT does not trigger if the script is terminated by a signal"
   -1     6 ---
   -1     7 
   -1     8 It is great when programming languages offer robust ways to cleanup resources.
   -1     9 Python has [context managers](https://peps.python.org/pep-0343/), rust has the
   -1    10 [drop trait](https://doc.rust-lang.org/std/ops/trait.Drop.html), go has
   -1    11 [defer](https://go.dev/blog/defer-panic-and-recover), and shell scripts have
   -1    12 `trap`:
   -1    13 
   -1    14 ```sh
   -1    15 tmpdir="$(mktemp -d)"
   -1    16 trap 'rm -rf -- "$tmpdir"' EXIT
   -1    17 ```
   -1    18 
   -1    19 However, I recently realized that this fails if the process is terminated by
   -1    20 `Ctrl-C`. Let's look into that.
   -1    21 
   -1    22 ## trap for signal handlers
   -1    23 
   -1    24 Before we look into `trap … EXIT`, let's first look into its other use case:
   -1    25 registering signal handlers. As a quick refresher, signals on UNIX can
   -1    26 interrupt a process at any time. For some signals (e.g. SIGINT), processes can
   -1    27 register handlers that replace the default behavior. SIGKILL and SIGSTOP cannot
   -1    28 be caught.
   -1    29 
   -1    30 In C, you would register a signal handler using the `signal()` system call. In
   -1    31 a shell script, you use `trap` instead. For example, `trap 'echo foo' TERM`
   -1    32 will output "foo" whenever the process receives a SIGTERM. Since this handler
   -1    33 replaces the default one, the process will not terminate.
   -1    34 
   -1    35 There are quite a few signals that are supposed to terminate a process:
   -1    36 SIGHUP, SIGINT, SIGQUIT, SIGKILL, and SIGTERM. The differences between them are
   -1    37 [not completely obvious](https://stackoverflow.com/questions/4042201). For this
   -1    38 article, I will concentrate on SIGINT and SIGTERM, because they are the most
   -1    39 common ones. SIGINT is the one that is sent when you press `Ctrl-C`.`
   -1    40 
   -1    41 trap's action argument is given as a string, which makes quoting a bit awkward.
   -1    42 For example `trap "echo '$foo'" TERM` and `trap 'echo "$foo"' TERM` are
   -1    43 different because one expands the variable on definition, while the other
   -1    44 expands the variable on execution. This also tends to confuse linters like
   -1    45 [shellcheck](https://github.com/koalaman/shellcheck/issues/3287). For these
   -1    46 reasons, it is quite common to move the actual cleanup code to a function:
   -1    47 
   -1    48 ```sh
   -1    49 on_sigterm() {
   -1    50     echo "$foo"
   -1    51 }
   -1    52 trap on_sigterm TERM
   -1    53 ```
   -1    54 
   -1    55 ## trap on EXIT
   -1    56 
   -1    57 Apart from registering signals, `trap` has one special condition: EXIT.
   -1    58 [POSIX](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#trap)
   -1    59 has this to say:
   -1    60 
   -1    61 > The EXIT condition shall occur when the shell terminates normally (exits),
   -1    62 > and may occur when the shell terminates abnormally as a result of delivery of
   -1    63 > a signal (other than SIGKILL) whose trap action is the default.
   -1    64 
   -1    65 I tested 4 different shells (bash, dash, zsh, busybox) ([script](./test.py)) to
   -1    66 see how it behaves in practice:
   -1    67 
   -1    68 -   All tested shells will execute the EXIT handler on normal completion, explicit `exit`, and errors with `set -e`.
   -1    69 -   No shell executes the EXIT handler on SIGKILL (expected because SIGKILL cannot be caught).
   -1    70 -   Only bash executes the EXIT handler on SIGINT and SIGTERM.
   -1    71 -   The exit code is not affected by cleanup (unless the cleanup code exits explicitly)
   -1    72 
   -1    73 Bash is closest to what I expect: EXIT should mean *every* exit, including
   -1    74 signals. So how can we get that behavior in all shells?
   -1    75 
   -1    76 The proper solution is probably to have a separate trap for each relevant
   -1    77 signal that removes the trap, calls that cleanup code, and then re-raises the
   -1    78 signal. To avoid executing the cleanup code twice in bash we also need to reset
   -1    79 the EXIT handler:
   -1    80 
   -1    81 ```sh
   -1    82 trap cleanup EXIT
   -1    83 trap 'cleanup; trap - INT; trap - EXIT; kill -INT $$'
   -1    84 trap 'cleanup; trap - TERM; trap - EXIT; kill -TERM $$'
   -1    85 ```
   -1    86 
   -1    87 Another option is to convert the relevant signals to explicit `exit` with an
   -1    88 arbitrary exit code. While it loses out on any default behavior, it is much
   -1    89 simpler:
   -1    90 
   -1    91 ```sh
   -1    92 trap cleanup EXIT
   -1    93 trap 'exit 130' INT TERM
   -1    94 ```
   -1    95 
   -1    96 ## Multi-Stage Cleanup
   -1    97 
   -1    98 Cleanup gets interesting once we have multiple cleanup steps in a single
   -1    99 script. Consider for example a script that needs to mount multiple locations in
   -1   100 a specific order, and unmount them in reverse order during cleanup. If any of
   -1   101 these steps fail, we need to unmount only the locations that have already been
   -1   102 mounted.
   -1   103 
   -1   104 One approach I have used before is to have a mutable cleanup expression,
   -1   105 although I don't particularly like it because of the use of `eval`:
   -1   106 
   -1   107 ```sh
   -1   108 cleanup='true'
   -1   109 trap 'eval "$cleanup"' EXIT
   -1   110 trap 'exit 130' INT TERM
   -1   111 defer() {
   -1   112     cleanup="$1; $cleanup"
   -1   113 }
   -1   114 ```
   -1   115 
   -1   116 A flexible option that works consistently across the shells that I have tested
   -1   117 is using subshells:
   -1   118 
   -1   119 ```sh
   -1   120 echo 'enter script'
   -1   121 trap "echo 'exit script'" EXIT
   -1   122 trap 'exit 130' INT TERM
   -1   123 (
   -1   124     echo 'enter subshell'
   -1   125     trap "echo 'exit subshell'" EXIT
   -1   126     trap 'exit 130' INT TERM
   -1   127 )
   -1   128 ```
   -1   129 
   -1   130 Cleanup in functions is less consistent: bash has the special `RETURN`
   -1   131 condition, while zsh reuses `EXIT` for that case. Other shells do not seem to
   -1   132 support traps on the function level, so I didn't look into it any further.
   -1   133 
   -1   134 ## Conclusion
   -1   135 
   -1   136 `trap … EXIT` is a very useful tool for doing cleanup in shell scripts.
   -1   137 Unfortunately, it is no universal “run on everything” hook.
   -1   138 
   -1   139 I do think that cleanup on signals such as SIGINT and SIGTERM is important.
   -1   140 Bash handles these cases as I would expect, but I do not want to restrict
   -1   141 myself to a single shell. On the other hand, I also do not want to
   -1   142 over-complicate things. `trap 'exit 130' INT TERM` seems to provide a good
   -1   143 balance of these different goals.

diff --git a/_content/posts/2026-07-13-trap/test.py b/_content/posts/2026-07-13-trap/test.py

@@ -0,0 +1,44 @@
   -1     1 import subprocess
   -1     2 
   -1     3 SCRIPTS = [
   -1     4     # different ways to exit
   -1     5     'trap "echo EXIT" EXIT',
   -1     6     'trap "echo EXIT" EXIT; exit 1',
   -1     7     'set -e; trap "echo EXIT" EXIT; false',
   -1     8     'trap "echo EXIT" EXIT; kill -INT $$',
   -1     9     'trap "echo EXIT" EXIT; kill -TERM $$',
   -1    10     'trap "echo EXIT" EXIT; kill -KILL $$',
   -1    11 
   -1    12     # modify exit code during cleanup
   -1    13     'trap "echo EXIT; true" EXIT; exit 1',
   -1    14     'trap "echo EXIT; exit 2" EXIT; exit 1',
   -1    15 
   -1    16     # proposed solution 1
   -1    17     'trap "echo EXIT" EXIT; trap "trap - INT; trap - EXIT; echo INT; kill -INT $$" INT; kill -INT $$',
   -1    18     'trap "echo EXIT" EXIT; trap "trap - TERM; trap - EXIT; echo TERM; kill -TERM $$" TERM; kill -TERM $$',
   -1    19 
   -1    20     # proposed solution 2
   -1    21     'trap "echo EXIT" EXIT; trap "exit 130" INT TERM; kill -INT $$',
   -1    22     'trap "echo EXIT" EXIT; trap "exit 130" INT TERM; kill -TERM $$',
   -1    23 
   -1    24     # subshell
   -1    25     'trap "echo EXIT" EXIT; (trap "echo SUBSHELL EXIT" EXIT)',
   -1    26 
   -1    27     # functions
   -1    28     'trap "echo EXIT" EXIT; foo() { trap "echo INNER EXIT" EXIT; }; foo',
   -1    29     'trap "echo EXIT" EXIT; foo() { trap "echo RETURN" RETURN; }; foo',
   -1    30 ]
   -1    31 
   -1    32 for script in SCRIPTS:
   -1    33     results = {}
   -1    34     for shell in ['dash', 'bash', 'zsh', 'busybox sh']:
   -1    35         proc = subprocess.run(
   -1    36             shell.split(), input=script, capture_output=True, encoding='utf-8'
   -1    37         )
   -1    38         key = f'[{proc.returncode}] {proc.stdout!r}'
   -1    39         results.setdefault(key, [])
   -1    40         results[key].append(shell)
   -1    41     print(script)
   -1    42     for stdout, shells in results.items():
   -1    43         print(f'{stdout} ({", ".join(shells)})')
   -1    44     print()