A bash entrypoint script sets `trap shutdown TERM` and then runs a long-lived server as its last foreground command. When the supervisor sends SIGTERM, nothing happens until the server exits on its own. Why is the handler delayed, and how do you restructure the script so it runs at once and forwards the signal to the server?
answer
- handlers run between commands, not during one
- the shell finishes the foreground command first
- wait is the interruptible block
- interrupted wait returns above 128
- forward, then wait again to reap
basics
~20 sBash finishes the foreground command before running a trap handler, so a trap set around a blocking command fires only after it returns. Run the server in the background instead and block in wait, which a trapped signal interrupts immediately.
solid answer
~50 sBash does not interrupt a running foreground command to service a trap: if a signal arrives while the shell is waiting for that command, the handler is queued and runs only once the command completes. A server that blocks for hours therefore delays the handler for hours. The fix is to stop blocking in a foreground command: start the server with `&`, record its PID, and block in `wait` instead — the `wait` builtin is documented to return immediately with a status above 128 when a trapped signal arrives, and the handler runs right then. The handler forwards with `kill -TERM "$child"`, and because that first `wait` returned early without reaping anything, you call `wait` on the same PID a second time to collect the server's real exit status and exit with it. If the wrapper has nothing to do after startup, `exec` the server instead so there is no wrapper left to forward anything.
code
bash · 16 lines#!/usr/bin/env bash
set -uo pipefail
child=0
shutdown() { [ "$child" -ne 0 ] && kill -TERM "$child" 2>/dev/null; }
trap shutdown TERM INT
"$@" &
child=$!
rc=0
wait "$child" || rc=$? # returns >128 immediately when the trap fires
if [ "$rc" -gt 128 ]; then
wait "$child" || rc=$? # second wait reaps the child's real status
fi
exit "$rc"go deeper
Recall that a trap handler runs between commands, so a signal arriving during a long-running command is not acted on until that command finishes. Know that the child does not get your handler.
Explain the mechanism: bash defers the trap while waiting on a foreground command, and wait is the documented exception that returns above 128 when a trapped signal arrives. Show the background-plus-wait rewrite.
Demonstrate that you have shipped this: forward explicitly, wait a second time to reap, propagate the child's real status, and know that under a grace period a delayed handler simply becomes a SIGKILL.
Own the shutdown contract across services — grace periods, what an exit status means to the supervisor, and when a shell wrapper should exist at all rather than being replaced by the process it launches.
## Why the handler is late Bash's rule is stated plainly in its manual: if the shell is waiting for a command to complete and receives a signal for which a trap has been set, the trap is not executed until the command completes. Signals are noted when they arrive, but the handler is a shell command and the shell only runs shell commands between commands. You can watch it happen in five seconds: ```bash #!/usr/bin/env bash trap 'echo "handler at ${SECONDS}s"' TERM sleep 5 echo "done at ${SECONDS}s" ``` Send SIGTERM one second in and the handler prints at second 5, not second 1 — and the script then carries on and exits 0, because a handler that does not exit simply returns to where the script was. Substitute a real server for `sleep 5` and the orchestrator's grace period expires long before your handler runs; you get SIGKILLed, and the shutdown you carefully wrote never happened. ## Why `wait` is the exception The same manual carves out one case: while the shell is waiting on an asynchronous command via the `wait` builtin, a trapped signal makes `wait` return immediately with a status greater than 128, and the trap then runs. That is the entire mechanism the standard entrypoint pattern rests on. `wait` is the one blocking operation in bash you can interrupt. So the restructure is mechanical: whatever was the blocking foreground command becomes a background job plus a `wait`. ```bash "$@" & child=$! wait "$child" ``` ## Forwarding, and then reaping properly The handler's job is to pass the signal on. The child did not inherit your handler — it does not have one at all unless it installed its own — so nothing reaches it until you send it: ```bash shutdown() { kill -TERM "$child" 2>/dev/null; } trap shutdown TERM INT ``` Now the subtlety that trips people. The first `wait` returned 128+N *without* reaping the child; the child is still shutting down. If you exit at that point you kill your own wrapper while the server is mid-flush, and — worse — you exit with a status you invented. Call `wait` on the same PID again: the second call blocks until the child really ends and yields its true exit status. ```bash rc=0 wait "$child" || rc=$? # 143 the moment the trap fires if [ "$rc" -gt 128 ]; then wait "$child" || rc=$? # now the server's own status fi exit "$rc" ``` Note what strict mode does here: under `set -e`, the first `wait` returning 143 would abort the script before you ever forward anything, which is why the pattern captures the status with `|| rc=$?` rather than letting it fail. ## Exit honestly Whatever supervises this wrapper reads its exit status. A handler that catches TERM, forwards it, and then exits 0 tells the supervisor the run finished cleanly. Propagate the child's status, or re-raise so the wrapper dies the way it was told to: ```bash trap - TERM kill -TERM "$$" # wrapper exits 143, as if it had never trapped ``` ## When not to write any of this All of it exists only because there is a shell sitting between the supervisor and the server. If the wrapper's only remaining job is to run the server, replace the shell with it — the server then receives the supervisor's signals directly, with no forwarding logic to get wrong. Reach for the background-and-wait pattern when the wrapper genuinely must do something after the child stops, or must supervise more than one child. One honest limitation: forwarding to `"$child"` signals that one process. If the server spawns its own children, they are unaffected, and reaching a whole process group is a question about process groups rather than about bash traps.
- Why does the pattern call `wait` on the same PID twice?The first call is interrupted by the trap and returns 128+N without the child having ended, so it tells you nothing about the child. After the handler forwards the signal, the second `wait` blocks until the process really terminates and yields its true exit status. Skipping it means exiting while the server is still flushing, and reporting a status you made up.
- When would you not write a forwarding wrapper at all?When the wrapper has nothing left to do after launching the process. Replacing the shell with the program removes the middleman entirely, so the supervisor's signals reach the real process directly and there is no handler to get wrong. Keep the wrapper only when it must run work after the child stops, or supervise more than one child.
- Your handler forwards SIGTERM but the server's own child processes keep running. What is going on?`kill -TERM "$child"` signals exactly one process. Anything that process spawned has its own PID and is untouched, so it may survive as an orphan. Reaching a whole tree means signalling a process group or having the server manage its own children — a process-group concern rather than a bash trap concern.
- What does the supervisor conclude if your handler forwards the signal and then exits 0?That the wrapper completed successfully. Exit status is the only channel the supervisor has, so a fabricated 0 hides the fact that the work was terminated early — restart policies, CI gates and alerting all read it. Propagate the child's status, or clear the trap and re-raise with `kill -TERM "$$"` so the wrapper exits 143.
saying these in an interview costs you the question
- Assumes bash interrupts a running command to run the trap
- Thinks the child inherits the parent's TERM handler
- Exits 0 from the handler after forwarding the signal
- Treats the first wait's 143 as the child's exit status
- Adds sleeps in the handler instead of waiting on the child