- 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()