A script that stops suddenly leaves behind temporary files, locks, and half-built directories. This episode covers the BASH signal model, installing traps for EXIT and SIGINT/SIGTERM, the mktemp + trap cleanup practice, concurrency-proof lockfiles, and trap pitfalls you can't afford to miss.

In episode 18 we covered Error Handling & Robustness — exit codes, set -euo pipefail, and when to intentionally allow failure — so your scripts now dare to stop mid-way when something goes wrong. That's good news for honesty, but it raises a new question just as important: when the script stops suddenly, who cleans up its remains?
Imagine a professional kitchen. The head chef tells an assistant to prep ingredients: chop vegetables, boil water, heat oil. Then suddenly the fire alarm rings — everyone evacuates. When the fire is out and the team returns, the kitchen is full of burnt pans and scattered ingredients. A good head chef doesn't just run; they have a procedure: turn off the stove, close the gas, before leaving. trap in BASH is that emergency procedure — code guaranteed to run before the script leaves, no matter the reason for leaving.
In the real world, the "burnt pans" left behind by a BASH script are: temporary files in /tmp that were never deleted, lockfiles that lock other scripts forever, directories newly created halfway, or a terminal left in a strange mode. A script that leaves such trash isn't done just because its process died — it bequeaths problems to the next script and to the humans who have to clean it up.
In this episode we'll dissect the signal model in Linux (SIGINT, SIGTERM, SIGHUP, and others), install trap to execute handlers when a signal arrives or when the script exits, and practice it in the three most important production scenarios: temporary file cleanup with mktemp, a lockfile to prevent two scripts from running at once, and terminal recovery when a script is interrupted. We'll also discuss the classic pitfalls — including the thing that trips beginners most often: trap EXIT runs even when the script succeeds.
A signal is a short message the operating system sends to a process to tell it about an event that needs responding to. Don't imagine signals as something complicated — think of a school bell: there's a bell for entry, a break bell, and a going-home bell. Each tells the student (the process) what's supposed to happen. A process can listen, ignore, or — for some signals — do nothing at all.
The fastest way to see the list of signals on your system:
kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE
9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT
17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTPThe ones you'll most often meet in the scripting world:
| Signal | Number | Common source | Trap-able? | Default behavior |
|---|---|---|---|---|
| SIGHUP | 1 | Terminal closed / parent process died | Yes | Terminates the process |
| SIGINT | 2 | Ctrl+C in the terminal | Yes | Terminates the process |
| SIGQUIT | 3 | Ctrl+\ | Yes | Terminates + core dump |
| SIGTERM | 15 | kill <pid>, systemctl stop | Yes | Terminates "politely" |
| SIGKILL | 9 | kill -9 <pid> | No | Force-kills |
| SIGSTOP | 19 | Ctrl+Z | No | Pauses the process |
| EXIT | — | Process exits by any means | Yes (BASH-specific) | — |
The key fact to memorize: SIGKILL (9) and SIGSTOP (19) can't be trapped, ignored, or handled. They're the kernel's final "red card" — no cleanup procedure will ever run. This is why kill -9 is always described as the last resort: it gives the process no chance to clean up anything. When the system needs a graceful shutdown, it sends SIGTERM first and gives it time; kill -9 is the emergency breaker.
trap: Installing the Safety Nettrap is the command that tells BASH: "when a certain signal arrives (or when the script exits), run this command first." Its basic syntax:
#!/bin/bash
cleanup() {
echo "Membersihkan..."
rm -rf "$TMP_DIR"
}
trap cleanup EXIT SIGINT SIGTERMThis reads: "when the script exits (EXIT), is Ctrl+C'd (SIGINT), or is asked to stop (SIGTERM), call the cleanup function." There are two forms of using trap:
| Form | Meaning |
|---|---|
trap 'command' SIGNAL_NAME | Install a handler for the signal |
trap - SIGNAL_NAME | Remove the handler, restore default behavior |
trap 'command' EXIT | Run before the script exits (success or failure) |
Note the question that often comes up: why EXIT? EXIT isn't a real signal from the kernel — it's a BASH-specific "process exiting" event. Because it covers every exit path (normal, error, signal), trap ... EXIT is the most reliable place for cleanup: one line, covering everything.
Important
The point that surprises people most: trap 'handler' EXIT runs even when the script finishes successfully. If you create trap 'echo BYE' EXIT, you'll always see BYE printed — whether the script ends normally or because of an error. This isn't a bug; it's actually the core feature. Cleanup must not distinguish "exited because of success" from "exited because of failure" — both leave trash behind. If you only want to handle failures, use a trap on a specific signal (e.g. trap cleanup SIGINT), not on EXIT.
There's a second important nuance: the timing of handler evaluation. Note the difference:
trap 'rm -f "$TMP_DIR"' EXIT — the handler is wrapped in single quotes, so $TMP_DIR is evaluated when the trap runs (at exit). The variable's latest value is used.trap "rm -f $TMP_DIR" EXIT — the handler is wrapped in double quotes, so $TMP_DIR is evaluated when the trap line executes (when installed). The stored value is a snapshot from that moment.Because $TMP_DIR is set after the trap line, the single-quoted form is the correct one. This is one of the subtlest hidden traps — and it makes the pitfalls list at the end of the episode.
mktemp + Trap CleanupThe most basic and most common case: a script that creates a temporary file in /tmp, then has to make sure that file is gone even if the script fails halfway. The mktemp command creates a temporary file/directory with a unique random name — far safer than guessing names like /tmp/tmp1.log that can collide:
#!/bin/bash
TMP_DIR="/tmp/mylog.$$"
mkdir -p "$TMP_DIR"
cp -r /var/log/nginx "$TMP_DIR"
tar czf /backup/logs.tar.gz -C "$TMP_DIR" .
echo "Selesai tanpa membersihkan!"The script above works — and after it finishes, /tmp/mylog.<PID> is left behind forever. Now the correct version:
#!/bin/bash
set -euo pipefail
cleanup() {
echo "Membersihkan file sementara..."
rm -rf "${TMP_DIR:-}"
}
TMP_DIR="$(mktemp -d)"
trap cleanup EXIT SIGINT SIGTERM
cp -r /var/log/nginx "$TMP_DIR"
tar czf /backup/logs.tar.gz -C "$TMP_DIR" .
echo "Backup selesai."What changed:
mktemp -d replaces the manual name guessing — the directory name is guaranteed unique and tucked away in a safe location.trap cleanup EXIT SIGINT SIGTERM is installed immediately after the TMP_DIR variable is set. Its position matters: you don't want the trap installed before there's anything to clean up.${TMP_DIR:-} inside cleanup uses an empty default value — so if TMP_DIR was never set, rm -rf "" won't happen (which could be dangerous).Warning
Never write rm -rf "${TMP_DIR}/" without verifying that TMP_DIR is populated — a small mistake like an empty variable can turn the command into rm -rf /. The rm -rf "${TMP_DIR:-}" pattern plus a [[ -n "${TMP_DIR:-}" ]] guard is the industry standard for this reason. One wrong character in cleanup is more dangerous than the trash you're trying to remove.
The next classic scenario: a cron job is scheduled to run every hour, but a single execution can take more than an hour. If cron triggers a second execution before the first finishes, two processes will fight over the same resources — two database dumps, two tar processes on the same directory. The result is corruption and chaos.
The standard solution: a lockfile. The script creates a marker file at the start and removes it at the end. If the lockfile already exists, the script knows another process is running and stops politely:
#!/bin/bash
set -euo pipefail
LOCKFILE="/var/lock/backup.job.lock"
cleanup() {
rmdir "$LOCKFILE" 2>/dev/null || true
echo "Lock dilepaskan."
}
if ! mkdir "$LOCKFILE" 2>/dev/null; then
echo "Job lain sedang berjalan. Keluar." >&2
exit 1
fi
trap cleanup EXIT SIGINT SIGTERM
# ... pekerjaan backup yang lama di sini ...
sleep 30
echo "Backup selesai."Why mkdir and not just creating a file with touch? Because mkdir is atomic: only one process can successfully create a directory at a time. if ! mkdir ...; then means "if the directory already exists (or creation failed), stop." This is a primitive guaranteed safe by the kernel — no race between processes.
Also note rmdir (not rm -rf) in cleanup: rmdir only removes empty directories, so if someone accidentally put content inside the lock, cleanup safely refuses (|| true keeps that failure from stopping the script). It's an extra security layer.
Tip
For more serious needs — cross-process locks with expiry, or when mkdir feels too crude — you can use flock from the util-linux utilities: flock /var/lock/backup.lock script_lain.sh locks a file and blocks the second process until the first finishes. flock is even safer because the kernel releases the lock automatically when the process dies (including kill -9), something a manual lockfile can't guarantee. Know both: mkdir for simple scripts, flock for strict production needs.
There's one case where cleanup isn't about files, but about terminal state. Some programs (for example interactive log readers or apps that disable character echo) change terminal settings while running. If such a script is Ctrl+C'd halfway without cleaning up, the terminal can be left in a strange state — characters not visible as you type, or input not being accepted.
trap can restore the terminal settings before exiting:
#!/bin/bash
set -euo pipefail
cleanup() {
stty echo # aktifkan kembali echo karakter
tput cnorm # kembalikan kursor yang terlihat
echo ""
echo "Terminal dipulihkan."
}
trap cleanup SIGINT SIGTERM EXIT
stty -echo # matikan echo saat skrip berjalan
printf "Ketik sesuatu (tidak terlihat): "
read -r input
trap - SIGINT # tugas selesai, lepaskan trap
echo ""
echo "Terima kasih, $input"Notice the line trap - SIGINT: after the dangerous part is done, we release the handler so the default behavior (stop immediately on Ctrl+C) returns to normal. A trap doesn't have to live forever — release it when it's no longer needed, exactly like closing the gas valve after the fire is out.
1. trap EXIT runs even on success. This is the trap that surprises people most often: the handler in trap ... EXIT executes every time the script exits — success, error, or due to a signal. If you only want to respond to failure, don't use EXIT; use a specific signal. If you want cleanup, EXIT is actually the best choice — because trash must be cleaned no matter the final outcome.
2. Handler evaluation timing. A handler in single quotes is evaluated when run (uses the variable's latest value); double quotes are evaluated when installed (old snapshot). Use single quotes almost always. The bug "trap doesn't delete the newly created file" usually has its root here.
3. Traps don't handle all signals by default. trap cleanup EXIT does cover a lot, but it doesn't catch SIGKILL and SIGSTOP (impossible) and doesn't catch other signals unless you register them. If your script can be stopped with systemctl stop (which sends SIGTERM) and with Ctrl+C (SIGINT), register both: trap cleanup EXIT SIGINT SIGTERM.
4. kill -9 can't be trapped. There is no way to clean up on SIGKILL. That's not a flaw in your script — it's kernel design. The consequence: don't rely on traps for data integrity; use a flock lockfile (released by the kernel) or make sure recovery can happen on the next execution.
5. Trap handlers writing output to stdout. If the script runs as part of a pipeline (for example printed by cron), output from a trap handler using echo can corrupt the output format. For informative handlers, write to stderr (echo "message" >&2) or to a log file — not stdout.
Caution
If a trap handler calls an external command that doesn't exist (for example a my-backup-tool that isn't installed), that command fails and — with set -e — can stop the script with a confusing error. Make sure cleanup only calls commands that are guaranteed available (rm, rmdir, stty, tput), and if in doubt, add || true inside the handler. The handler is the "firefighter"; it must not itself catch fire.
In this episode 19 you've installed an emergency procedure in your scripts' kitchen. You understand the Linux signal model (SIGINT for Ctrl+C, SIGTERM for kill/systemd, the untrappable SIGKILL), installed trap to run handlers on EXIT or on signals, and practiced it in three production scenarios: mktemp + trap cleanup for guaranteed-clean temporary files, an mkdir lockfile to prevent two jobs from running at once, and terminal recovery when a script is interrupted.
The key takeaways:
trap 'handler' EXIT SIGINT SIGTERM is the safety net: one line, covering every exit path.trap EXIT runs also on success — that's a feature for cleanup, not a surprise to fight.kill -9 and SIGKILL can't be trapped — design scripts that can recover without traps.mkdir lockfile is atomic and safe for simple concurrency; use flock for stricter needs.Now your scripts know when to stop and how to leave the room neatly. But there's one skill more valuable than all of that: the ability to see what a script actually does while it runs. In episode 20 we'll move into Debugging Techniques: tracing execution line by line with bash -x, checking syntax without running with bash -n, enriching traces with PS4, and installing manual breakpoints with trap DEBUG. See you in episode 20!