Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Bash & the POSIX Shell  Module 12 – Processes, jobs and signals

Signals, kill and timeout

Send signals with kill, read 128-plus-n exit statuses, stop a runaway command with timeout, and make a launcher script hand TERM on to its child.

  • Intermediate
  • 15 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Send TERM, KILL and other signals with kill, test a process with kill -0 and signal a whole process group
  • Read a status above 128 as 128 plus a signal number and name the signal with kill -l
  • Use timeout with -k and -s to stop a runaway command and distinguish the statuses 124, 125, 126, 127 and 137
  • Write a wrapper script that forwards TERM to its child and waits twice for the child's real status
  • Explain why exec keeps the process id so that a signal reaches the program, and how PID 1 treats signals

Before you start

What you’ll learn · Before you start

What you will learn

  • Send TERM, KILL and other signals with kill, test a process with kill -0 and signal a whole process group
  • Read a status above 128 as 128 plus a signal number and name the signal with kill -l
  • Use timeout with -k and -s to stop a runaway command and distinguish the statuses 124, 125, 126, 127 and 137
  • Write a wrapper script that forwards TERM to its child and waits twice for the child's real status
  • Explain why exec keeps the process id so that a signal reaches the program, and how PID 1 treats signals

Before you start

On this page

A script calls a probe that sometimes hangs for hours, and its caller needs the probe stopped after 20 seconds with a status that says “stopped for time”, not “failed”. Stopping a process means sending it a signal, and three questions decide what follows: which signal, which process receives it, and what that process does with it.

You will read the common signals and their numbers, send them with kill and test a process with kill -0, turn a status of 128 plus n back into a signal name, put a limit on a command with timeout, and write a launcher script that either hands TERM on to the program it starts or becomes that program with exec.

A signal is a numbered message with a default action

A signal: A short message the kernel delivers to a process, with a name, a number and a default action such as ending it. A program can handle or ignore most signals; KILL and STOP cannot be caught. is a short message that the kernel delivers to a process. The keyboard produces some (Ctrl-C sends INT), the kernel produces others (SEGV for a bad memory access), and any process may send one to another with kill. Every signal has a name, a number and a default action: end the process, end it with a memory dump, ignore the signal, stop the process, or let a stopped one continue. A program may keep the default, ignore the signal, or install a handler that runs instead. In a shell the trap: A Bash builtin that registers code to run on a signal or a shell event: EXIT when the shell exits, ERR when a command's failure would end a set -e script, RETURN when a function returns, DEBUG before every simple command. trap action signal …; the action is one shell word. More in “Traps and cleanup: EXIT, ERR and signals” builtin installs the handler, as the traps lesson shows. Two signals are different: signal(7) says “The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored.”

Look at the last column of the table first, because it separates the signals a handler can answer from the two it cannot. Six of the twelve change numbers between systems, so the table gives this Mac’s numbers (they come from kill -l, run below) beside Linux’s on x86-64 (from signal(7)).

Signal Number: this Mac / Linux x86-64 Default action Can a handler catch it?
HUP 1 / 1 end the process yes
INT 2 / 2 end the process yes
QUIT 3 / 3 end it with a memory dump yes
KILL 9 / 9 end the process no
USR1 30 / 10 end the process yes
USR2 31 / 12 end the process yes
PIPE 13 / 13 end the process yes
TERM 15 / 15 end the process yes
CHLD 20 / 17 ignore yes
CONT 19 / 18 continue a stopped process yes
STOP 17 / 19 stop the process no
TSTP 18 / 20 stop the process yes

HUP means the terminal went away, INT is Ctrl-C, TSTP is Ctrl-Z, TERM is the polite request that kill sends by default, and KILL is the forced stop. PIPE ends a writer whose reader has gone, which is the story behind status 141 in the pipelines lesson and the SIGPIPE: Signal 13, sent to a process that writes to a pipe nobody has open for reading any more, for example after head has exited. By default it ends the writer, and Bash reports that as status 128 + 13 = 141. More in “Pipelines, PIPESTATUS and pipefail” entry. Because six numbers move between systems, a script that runs on both writes names (kill -TERM, trap … TERM) and converts with kill -l when it must. This session lists the signals and converts in both directions:

kill -l lists this Mac's signals and converts numbers, names and statuses Python · names_session.py
from session import run

# The names and numbers this Mac's Bash uses. Linux numbers differ for some signals (signal(7)).
run("""
# the whole list, as number) name pairs
kill -l
# number to name, name to number
kill -l 15
kill -l TERM
# a status above 128 names the signal that killed the process
kill -l 143
kill -l 137
""")

Output

# the whole list, as number) name pairs
$ kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGEMT	 8) SIGFPE	 9) SIGKILL	10) SIGBUS
11) SIGSEGV	12) SIGSYS	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGURG	17) SIGSTOP	18) SIGTSTP	19) SIGCONT	20) SIGCHLD
21) SIGTTIN	22) SIGTTOU	23) SIGIO	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGINFO	30) SIGUSR1
31) SIGUSR2
# number to name, name to number
$ kill -l 15
TERM
$ kill -l TERM
15
# a status above 128 names the signal that killed the process
$ kill -l 143
TERM
$ kill -l 137
KILL

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 names_session.py

kill -l 15 printed TERM and kill -l TERM printed 15. The last two lines turned 143 and 137 into names: the Bash manual says kill -l takes a status above 128 to be that of a process ended by a signal, so it subtracts 128 for you. The third section builds on that.

Key idea

TERM asks and KILL forces: a process can catch, ignore or answer TERM with its own cleanup, while KILL and STOP are handled by the kernel and never reach the program.

Checkpoint

Check: What a signal is, its number and its default action

  1. Question 1 of 2 Which two signals can no handler catch, block or ignore?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: KILL and STOP

    A handler works for every signal in the table except KILL and STOP, which is why TERM is tried first and KILL is the last resort.

  2. Question 2 of 2 A script must send user signal 1 to a worker on this Mac and on Linux x86-64. Which line does that on both?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: kill -USR1 "$pid"

    Six common numbers move between systems (USR1, USR2, CHLD, CONT, STOP, TSTP). Write names in scripts and use kill -l when a number has to become a name.

kill sends TERM unless told otherwise, and a minus sign reaches a whole group

kill PID sends TERM. Another signal is named with -s NAME, -NAME, -NUMBER or -n NUMBER, and these spellings all mean the same thing. send.sh starts a sleeper for each spelling, signals it, and prints what wait returns. It also uses kill -0, which sends nothing: it only asks the kernel whether the process exists and whether you may signal it.

send.sh: one signal in five spellings, and kill -0 as a test Shell · send.sh
#!/bin/bash
# send.sh: send one signal to a sleeper in several spellings, and test a process with kill -0.

alive() {   # alive PID LABEL: kill -0 sends no signal; it only asks whether PID exists and may be signalled
    if kill -0 "$1" 2>/dev/null; then echo "$2: alive"; else echo "$2: gone"; fi
}

try() {     # try LABEL KILL-OPTIONS...: start a sleeper, signal it with those options, print what wait says
    local label=$1 pid
    shift
    sleep 47 &
    pid=$!
    kill "$@" "$pid"
    { wait "$pid"; } 2>/dev/null
    echo "$label -> wait says $?"
}

sleep 47 &
sleeper=$!
alive "$sleeper" "before any signal"
kill -0 "$sleeper"
echo "kill -0 returned $?"
kill "$sleeper"
{ wait "$sleeper"; } 2>/dev/null
echo "kill PID -> wait says $?"
alive "$sleeper" "after kill"

try 'kill -s TERM PID' -s TERM
try 'kill -TERM PID' -TERM
try 'kill -15 PID' -15
try 'kill -n 15 PID' -n 15
try 'kill -s KILL PID' -s KILL
Every spelling of TERM ends with 143, -s KILL with 137, and kill -0 tests without signalling Python · send_session.py
from session import run, files

files("send.sh")

run("""
bash send.sh
""")

Output

$ bash send.sh
before any signal: alive
kill -0 returned 0
kill PID -> wait says 143
after kill: gone
kill -s TERM PID -> wait says 143
kill -TERM PID -> wait says 143
kill -15 PID -> wait says 143
kill -n 15 PID -> wait says 143
kill -s KILL PID -> wait says 137

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 send_session.py

All four spellings of TERM gave 143, and -s KILL gave 137; the next section reads those numbers. The kill -0 lines show a test, not a promise: it returned 0 while the sleeper ran, and the helper reported gone after the kill. The process could also have exited a moment after the test, and the kernel may give its PID to an unrelated process later. A failed kill -0 does not prove that the process is gone either, since it also fails for a process that belongs to someone else (on this Mac, kill -0 1 printed Operation not permitted). Use it on processes you started.

kill PID reaches one process, but a job is often several. Every process belongs to a A set of processes, usually one job, that share a group ID. The group's ID is the PID of its first process, and a signal sent to the group reaches every member.. A child starts in its parent’s group (credentials(7)), and a shell with job control gives each job a group of its own. A script runs with job control off, so its children share the script’s group; set -m turns it on, and then a background job leads a group named by its first PID. A minus sign in front of that number names the group, and -- keeps kill from reading -12345 as a signal option, as the POSIX kill page writes it. group.sh starts one job that starts two sleepers, kills only the job’s first process, and then kills the whole group:

group.sh: kill the leader, count survivors, then kill the group Shell · group.sh
#!/bin/bash
# group.sh: a job that starts two sleepers. Kill only the job's leader, then kill the whole group.
set -m                      # job control: every background job becomes a process group of its own

survivors() {               # how many of the recorded sleepers still exist
    local n=0 p
    while read -r p; do
        if kill -0 "$p" 2>/dev/null; then n=$((n + 1)); fi
    done < sleepers.txt
    echo "$n"
}

: > sleepers.txt
bash -c 'sleep 47 & echo $! >> sleepers.txt; sleep 47 & echo $! >> sleepers.txt; wait' &
leader=$!
until [ "$(wc -l < sleepers.txt)" -ge 2 ]; do sleep 0.05; done
echo "sleepers running: $(survivors)"

kill -TERM "$leader"
{ wait "$leader"; } 2>/dev/null
echo "leader killed, wait says $?, sleepers still running: $(survivors)"

kill -TERM -- "-$leader"    # a minus sign before the number names the process group
for _ in $(seq 100); do [ "$(survivors)" = 0 ] && break; sleep 0.05; done
echo "after kill -- -PGID, sleepers running: $(survivors)"
Killing the leader leaves two sleepers; kill -- -PGID ends them Python · group_session.py
from session import run, files

files("group.sh")

run("""
# a job with two sleepers: kill the leader, then the whole group
bash group.sh
""")

Output

# a job with two sleepers: kill the leader, then the whole group
$ bash group.sh
sleepers running: 2
leader killed, wait says 143, sleepers still running: 2
after kill -- -PGID, sleepers running: 0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 group_session.py

The first kill ended the leader (143), and both sleepers went on running with no parent left to stop them. Then kill -TERM -- -$leader reached every member of the group and none remained. Without set -m there is no such group: a copy of group.sh without that line, run on this Mac, answered the group kill with No such process, because the job’s first process was not a group leader.

Killing the leader and expecting the helpers to follow

Wrong: kill "$pid" for a job that started helpers, then assuming everything stopped. Right: signal the group with kill -TERM -- -PGID when job control is on, or let the leader forward the signal to its children, which the fifth section writes.

Checkpoint

Check: Sending signals with kill, testing with kill -0, and process groups

  1. Question 1 of 2 What does kill -0 "$pid" do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Sends no signal; it only reports, by its status, whether the process exists and may be signalled

    A zero status says the process was there and signalling it was allowed at that instant. A non-zero status can also mean the process belongs to someone else, so read the message when it matters.

  2. Question 2 of 2 A job started under set -m has leader PID 5000 and two helpers in its group. Which command ends all three with TERM?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: kill -TERM -- -5000

    Without set -m the job is not a group of its own, and the group form fails: a copy of group.sh without that line answered with No such process.

A process killed by signal n reports 128 plus n

In the last session wait returned 143 and 137 for sleepers that never called exit. The Bash manual gives the rule: when a fatal signal ends a command, Bash reports 128 plus that signal’s number as its status. The exit status lesson lists the conventional exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it”; this section reads the 128 range in both directions.

selfkill.sh lets a child shell send itself one signal. Given the word handler, it first installs one function for INT and TERM that prints caught and the name, then exits with 128 plus the signal’s number, computed with kill -l. deaths.sh runs the child once per signal and prints the status the parent sees:

selfkill.sh: send this shell one signal, with or without a handler Shell · selfkill.sh
#!/bin/bash
# selfkill.sh SIGNAL [handler]: send this shell one signal. With "handler", INT and TERM are caught by
# one function that reports the signal and exits with 128 plus its number.
on_signal() {
    echo "caught $1"
    exit $((128 + $(kill -l "$1")))
}
if [ "${2:-}" = handler ]; then
    trap 'on_signal INT' INT
    trap 'on_signal TERM' TERM
fi
kill -s "$1" $$
echo "survived $1"
deaths.sh: run the child once per signal and print its status Shell · deaths.sh
#!/bin/bash
# deaths.sh [handler]: run selfkill.sh once per signal and print the status the parent sees.
for sig in HUP INT TERM KILL USR1; do
    { bash selfkill.sh "$sig" "$@"; } 2>/dev/null
    status=$?
    printf '%-4s status %3d = 128 + %-2d  kill -l says %s\n' \
        "$sig" "$status" "$((status - 128))" "$(kill -l "$status")"
done
HUP, INT, TERM, KILL and USR1 deaths read as 128 + n, and the same numbers from a handler Python · status_session.py
from session import run, files

files("selfkill.sh", "deaths.sh")

run("""
# each child shell kills itself with one signal; the parent prints the status it sees
bash deaths.sh
# the same, with one handler for INT and TERM that exits with 128 plus the number
bash deaths.sh handler
""")

Output

# each child shell kills itself with one signal; the parent prints the status it sees
$ bash deaths.sh
HUP  status 129 = 128 + 1   kill -l says HUP
INT  status 130 = 128 + 2   kill -l says INT
TERM status 143 = 128 + 15  kill -l says TERM
KILL status 137 = 128 + 9   kill -l says KILL
USR1 status 158 = 128 + 30  kill -l says USR1
# the same, with one handler for INT and TERM that exits with 128 plus the number
$ bash deaths.sh handler
HUP  status 129 = 128 + 1   kill -l says HUP
caught INT
INT  status 130 = 128 + 2   kill -l says INT
caught TERM
TERM status 143 = 128 + 15  kill -l says TERM
KILL status 137 = 128 + 9   kill -l says KILL
USR1 status 158 = 128 + 30  kill -l says USR1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 status_session.py

In the first run HUP gave 129, INT 130, TERM 143 and KILL 137, each 128 plus the signal’s number, and kill -l turned every status back into its name. USR1 gave 158 because its number on this Mac is 30; the same death on Linux x86-64 reports 138 (128 + 10), another reason to translate with kill -l instead of remembering numbers. In the second run INT and TERM printed caught and still ended with 130 and 143. A program that answers a signal with a graceful shutdown: Stopping a program in an orderly way: it catches a request such as TERM, finishes or abandons its current work, releases what it holds and exits, instead of being killed on the spot. and exits 128 + n looks, to its caller, like a program the signal killed. HUP and KILL had no handler, so their lines did not change, and no handler can run for KILL.

Calling kill -l on every non-zero status

A program can exit 130 or 143 on its own, as the handler did, and for a number below 128 kill -l prints the name of that signal number instead: kill -l 15 printed TERM, though an exit 15 is no kill. Test status > 128 first, and read the name as the probable cause, not as proof.

Checkpoint

Check: Reading 128 plus n and naming the signal

  1. Question 1 of 2 After a command, status=$? holds 143. Which line prints the name of the signal that probably ended it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: kill -l "$status"

    kill -l with a number below 128 prints the name of that signal number, so test status > 128 before treating the result as a death by signal.

  2. Question 2 of 2 A worker catches TERM, finishes its item and runs exit 143. The caller sees 143. What can the caller conclude?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: That the worker ended after TERM or chose that status itself; the number does not say which

    The convention lets the caller treat a clean, handled stop and a kill the same way. If the difference matters, log it inside the handler.

timeout stops a runaway command and says how with 124 or 137

The probe from the opening needs a limit that nobody has to watch. timeout DURATION COMMAND runs the command and, when the duration is up, sends it TERM. It comes from GNU coreutils, and POSIX lists it too (“First released in Issue 8”, the 2024 edition). macOS has no timeout, so the sessions here run GNU coreutils 9.12’s. A duration is a number with an optional suffix of s, m, h or d (seconds by default), and 0 means no limit. Read the clock of timeout -k 5 30 cmd from the top in the figure and note which status each ending leads to:

Timeline · 5 events in order The life of timeout -k 5 30 cmd Walk through the clock of one command, from its start to the status timeout returns.
  1. 0 s

    timeout starts the command starts

    timeout runs cmd as its child, in a process group of its own, and starts the 30-second clock. If cmd ends before the clock does, timeout returns the status of cmd.

  2. 30 s

    The time is up, TERM goes out first signal

    timeout sends TERM to cmd and to its group, so cmd's own children get it too. TERM can be caught or ignored, so cmd may still be running.

  3. 30 s to 35 s

    cmd obeys TERM and exits status 124

    If cmd stops within the 5 seconds of -k, timeout returns 124. The 124 only says that the limit was reached; the status cmd chose is dropped unless --preserve-status is given.

  4. 35 s

    Five seconds later, KILL last resort

    If cmd is still alive, -k makes timeout send KILL, which cannot be caught or ignored. The kernel ends cmd at once.

  5. After the KILL

    timeout ends with 137 status 137

    GNU timeout then returns 137 (128 + 9), not 124. A caller can tell a command that obeyed TERM (124) from one that had to be killed (137).

The timeline has 5 events, from 0 s to After the KILL.

The session runs the pieces with sleep 47 as the hanging command. A limit of 1 second marks a case where the time must run out, and 30 seconds a case where the command must finish in time:

timeout returns 124 on a hang, 137 when KILL was needed, and the command's own status otherwise Python · timeout_session.py
from session import run, write

write("plain.txt", "")

run("""
# time runs out: timeout stops the command and returns 124
timeout 1 sleep 47; echo "status $?"
# the command finished in time: its own status comes through
timeout 30 sh -c 'exit 3'; echo "status $?"
# --preserve-status keeps the signal status of the command instead of 124
timeout --preserve-status 1 sleep 47; echo "status $?"
# -s picks the signal; KILL cannot be ignored, so timeout ends 137
timeout -s KILL 1 sleep 47; echo "status $?"
# a command that ignores TERM needs -k: after 1 more second, KILL goes out
timeout -v -k 1 1 bash -c 'trap "" TERM; sleep 47'; echo "status $?"
""")

run("""
# timeout failing by itself: 125 for its own error, 126 cannot run, 127 not found
timeout --nonsense 1 true; echo "status $?"
timeout 30 ./plain.txt; echo "status $?"
timeout 30 no-such-tool; echo "status $?"
""")

Output

# time runs out: timeout stops the command and returns 124
$ timeout 1 sleep 47; echo "status $?"
status 124
# the command finished in time: its own status comes through
$ timeout 30 sh -c 'exit 3'; echo "status $?"
status 3
# --preserve-status keeps the signal status of the command instead of 124
$ timeout --preserve-status 1 sleep 47; echo "status $?"
status 143
# -s picks the signal; KILL cannot be ignored, so timeout ends 137
$ timeout -s KILL 1 sleep 47; echo "status $?"
status 137
# a command that ignores TERM needs -k: after 1 more second, KILL goes out
$ timeout -v -k 1 1 bash -c 'trap "" TERM; sleep 47'; echo "status $?"
timeout: sending signal TERM to command ‘bash’
timeout: sending signal KILL to command ‘bash’
status 137
# timeout failing by itself: 125 for its own error, 126 cannot run, 127 not found
$ timeout --nonsense 1 true; echo "status $?"
timeout: unrecognized option '--nonsense'
Try 'timeout --help' for more information.
status 125
$ timeout 30 ./plain.txt; echo "status $?"
timeout: failed to run command ‘./plain.txt’: Permission denied
status 126
$ timeout 30 no-such-tool; echo "status $?"
timeout: failed to run command ‘no-such-tool’: No such file or directory
status 127

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 timeout_session.py

The first line returned 124, which says only that the limit was reached. A command that finishes in time keeps its own status (exit 3 gave 3). --preserve-status swaps 124 for the status the command ended with: sleep died of TERM, so 143. -s KILL sends KILL at once and timeout reported 137. The -v -k 1 1 line shows why -k exists: the command ignored TERM with trap "" TERM, -v logged both signals, and one second after the first signal timeout sent KILL and ended with 137. The last three lines are failures of timeout itself or of the command it could not start, with the numbers a shell uses for the same causes:

Status Meaning
124 the limit was reached and the first signal was sent (unless --preserve-status)
125 timeout itself failed, for example an unknown option
126 the command was found but cannot be run
127 the command was not found
137 KILL was sent: -k ran out, or -s KILL (128 + 9)
anything else the command’s own status

Timing out a shell function

Wrong: timeout 5 my_function. timeout starts a program, so a function it cannot find ends with 127 and “failed to run command”. Right: put the code in a script and run timeout 5 bash script.sh.

-s SIGNAL changes the first signal, and -k DURATION adds KILL after that much more time. By default GNU timeout sends the signal to the command’s whole process group, so helpers the command started stop with it; --foreground is for a timeout that is not run from an interactive prompt, and the POSIX page describes -f as “Only time out the utility itself, not its descendants.”

Key idea

A command that obeys TERM ends as 124; one that has to be killed ends as 137. -k sets how long timeout waits for TERM to work before it uses KILL.

Checkpoint

Check: timeout and its statuses 124, 125, 126, 127 and 137

  1. Question 1 of 3 timeout 1 sleep 47 returns a status. Which one, and what does it say?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: 124, which says that the time limit was reached and the first signal was sent

    A command that finishes in time keeps its own status; 124 is only for the case where the clock ran out.

  2. Question 2 of 3 A command ignores TERM. What does timeout -k 1 1 command do, and what does it return?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: It sends TERM after 1 second and KILL 1 second later, and returns 137

    The -k duration counts from the moment the first signal was sent, not from the start of the command.

  3. Question 3 of 3 What does --preserve-status change?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: After a timeout, timeout returns the status the command ended with instead of 124

    Use it when the caller already treats the command's own statuses, and does not need 124 to tell a timeout apart.

A launcher must forward TERM to its child, or become the child with exec

Many scripts only start another program: a wrapper that sets the environment and runs the real one, or a service script that starts a worker. A signal sent to the launcher’s PID goes to the launcher, not to the program. The fix that keeps the launcher in charge is to run the program in the background, remember its PID and trap TERM to forward it. Step through one TERM sent to such a wrapper:

Interactive · message by message A wrapper hands TERM to its child Step through one TERM sent to wrap.sh. Watch which wait returns first, and with which status.
A wrapper hands TERM to its child, step 7 of 7. Messages so far: wrap.sh to app.sh: start in background; Sender to wrap.sh: TERM; wrap.sh to app.sh: TERM; wrap.sh to Sender: exit status 7.Senderhas the PIDwrap.shwait 2: 7app.shexited 7start in backgroundTERMTERMexit status 7

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The wrapper starts the child. wrap.sh sets its TERM trap, starts app.sh in the background, keeps the child's PID in child and calls wait on it. app.sh sets its own trap and logs ready.
  2. TERM reaches the wrapper only. The sender has one PID, the wrapper's. Without the trap, this TERM would end the wrapper with 143 and leave app.sh running with nobody to stop it.
  3. The first wait returns at once. A trapped signal interrupts wait: it returns 143 (128 + 15, the signal that arrived) while the child is still running. That number is not the child's status.
  4. The trap forwards the signal. Right after wait returns, the shell runs the trap action, kill -TERM with the child's PID. The signal now reaches the program that has to stop.
  5. The child shuts down. app.sh runs its handler, saves its state and exits 7. The wrapper has nothing to do while this happens.
  6. The second wait has the answer. The wrapper calls wait again. The child is gone now, so the call returns its real status, 7, and the wrapper exits with it.
  7. The sender sees status 7. wait on the wrapper in the sender gives 7. A caller that read 143 here would have taken a clean shutdown for a kill.

The wrapper waits twice for a reason. While Bash waits for a foreground command, it “will not execute the trap until the command completes”, so the wrapper waits on a background job instead. When a trapped signal arrives, the wait builtin “will return immediately with an exit status greater than 128”, and the trap runs right after. That first status is the signal’s, 128 + 15 for TERM, and the child is still shutting down. A second wait collects the real status. app.sh is the program; it saves its state on TERM and exits 7:

app.sh: the supervised program, which exits 7 on TERM Shell · app.sh
#!/bin/bash
# app.sh: the program being supervised. On TERM it saves its state and exits 7.
trap 'echo "TERM received, saving state" >> app.log; exit 7' TERM
echo "ready" >> app.log
while :; do sleep 0.1; done
wrap.sh: forward TERM to the child, wait twice, leave with the child's status Shell · wrap.sh
#!/bin/bash
# wrap.sh: run app.sh as a child, hand TERM on to it, and leave with the child's own status.
trap 'echo "forwarding TERM" >> wrap.log; kill -TERM "$child"' TERM
bash app.sh &
child=$!
echo "started the child" >> wrap.log
wait "$child"
status=$?
echo "first wait returned $status" >> wrap.log
wait "$child"
status=$?
echo "second wait returned $status" >> wrap.log
exit "$status"

nowrap.sh is wrap.sh without the trap line, and drive.sh starts a wrapper, waits until the wrapper and app.sh are both up, sends TERM to the wrapper only, and prints both logs. The session runs it on both wrappers:

The trap forwards TERM and the wrapper exits 7; without it the child is left running Python · forward_session.py
from session import run, files

files("app.sh", "wrap.sh", "nowrap.sh", "drive.sh")

run("""
# TERM goes to the wrapper only; the wrapper hands it on and waits twice
bash drive.sh wrap.sh
# the same wrapper without the trap
bash drive.sh nowrap.sh
""")

Output

# TERM goes to the wrapper only; the wrapper hands it on and waits twice
$ bash drive.sh wrap.sh
wrap.sh exited with 7
--- wrap.log
started the child
forwarding TERM
first wait returned 143
second wait returned 7
--- app.log
ready
TERM received, saving state
# the same wrapper without the trap
$ bash drive.sh nowrap.sh
nowrap.sh exited with 143
--- wrap.log
started the child
--- app.log
ready
app still running after its wrapper died: yes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 forward_session.py

The first run is the figure on real logs: first wait returned 143, then second wait returned 7, the app’s own line about TERM, and a wrapper that exited 7. The two logs are separate files on purpose, because the wrapper’s first line and the app’s handler run at nearly the same moment and a shared file would order them differently from run to run. Without the trap, TERM ended the wrapper with 143 and app.sh kept running, never signalled; drive.sh found it with kill -0 and ended it.

The other fix is to make the launcher disappear. exec COMMAND replaces the shell with the command: the same process, the same PID, and no parent left to take the signal. (The file descriptors lesson covers the form of exec that only changes redirections.) In the next session start-plain.sh runs the service as a child, start-exec.sh ends with exec, and launch.sh sends TERM to the PID it started and reports whether launcher and service shared it:

With exec the launcher and the service are one process; without it TERM never reaches the service Python · exec_session.py
from session import run, files

files("serve.sh", "start-plain.sh", "start-exec.sh", "launch.sh")

run("""
# TERM goes to the PID that was started, in both runs
bash launch.sh start-plain.sh
bash launch.sh start-exec.sh
""")

Output

# TERM goes to the PID that was started, in both runs
$ bash launch.sh start-plain.sh
launcher and service share one PID: no
start-plain.sh exited with 143
serve.sh: ready
service still running: yes
$ bash launch.sh start-exec.sh
launcher and service share one PID: yes
start-exec.sh exited with 0
serve.sh: ready
serve.sh: TERM received, shutting down
service still running: no

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exec_session.py

With exec, TERM went straight to the service’s handler, which logged its line, and wait returned the service’s own 0. Without it the launcher died of TERM (143) and the service stayed up, unaware. Use exec when the launcher has nothing left to do after the program ends, and a forwarding wrapper when it must still act or report afterwards.

Containers: what the documentation says, since none ran here

No container ran for this lesson; the shell mechanism above is what ran. Docker’s Dockerfile reference says the shell form of ENTRYPOINT starts the command as a subcommand of /bin/sh -c, so the program “will not receive Unix signals”, and it shows exec "$@" at the end of a starter script. pid_namespaces(7) adds that other processes can send a container’s PID 1 only the signals it has installed a handler for.

Checkpoint

Check: Forwarding TERM with two waits, and exec

  1. Question 1 of 2 A wrapper has trap 'kill -TERM "$child"' TERM and calls wait "$child". TERM arrives at the wrapper while the child is still running. What status does the first wait return?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 143 status

    A trapped signal interrupts wait, which returns 128 plus the signal's number: 128 + 15. The log of wrap.sh read first wait returned 143, and only the second wait returned the child's own 7.

  2. Question 2 of 2 A launcher script ends with exec bash serve.sh. TERM is sent to the PID the launcher had. Where does the signal go?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: To serve.sh, because exec replaced the launcher with it under the same PID

    The same exec without the word gave the opposite result: the launcher died of TERM (143) and the service stayed up.

Practise: stop a worker and cap a hang

Write worker.sh, a loop that appends tick 1, tick 2 and so on to worker.log, one every 0.2 seconds, traps TERM, appends bye and exits 143. Write limit.sh, which runs timeout 1 sleep 30 and prints stopped by timeout when the status is 124 and finished otherwise. Check both in your own shell without a clock. Start the worker in the background, wait until worker.log holds a tick 1 line, send TERM with kill, and wait for it: wait must return 143, the last line of the log must be bye, and no tick line may appear after it. Then bash limit.sh must print stopped by timeout once, and with true in place of sleep 30 it must print finished.

Practice that adapts to you

Practise this lesson

14 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 A process has no handler for TERM and receives it. What happens?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: It ends, which is the default action of TERM

    The polite request becomes a plain kill when nobody handles it, which is why the status is 143 when it does.

  2. Question 2 of 14 A script runs trap 'echo caught' KILL and then sends itself KILL. What does the script do?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: It ends at once and prints nothing; the trap may be accepted, but it never runs

    Protection against KILL lives outside the program: a supervisor that restarts it, or work that is safe to repeat.

  3. Question 3 of 14 Which of these commands does not send TERM to PID 4321?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: kill -0 4321

    Four spellings of one signal ended with 143 in the session; the fifth line, kill -0, is the odd one because it delivers nothing.

  4. Question 4 of 14 A script starts a background job under set -m. The job starts two helper programs and waits for them. The script runs kill -TERM on the job's PID. What is left running?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: The two helpers

    To reach the helpers, signal the group (kill -TERM -- -PGID), or write a leader that forwards the signal.

  5. Question 5 of 14 kill -0 "$pid" returned a non-zero status. Which conclusion is safe?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: Either no such process exists or you may not signal it, and the error message says which

    Run kill -0 only on processes you started, and treat the answer as true at that instant.

  6. Question 6 of 14 wait "$pid" returned 137. Which signal ended the process?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: KILL, signal 9

    The sum is the whole rule. Subtract 128 and look the number up with kill -l.

  7. Question 7 of 14 On this Mac a child that kills itself with USR1 reports 158. USR1 is signal 10 on Linux x86-64. What status does the same death report there?

    Type a number.

    Show the answer: Question 7 of 14

    Answer: 138 status

    The rule is 128 plus the number on the system that runs the command: 128 + 10 on Linux x86-64, 128 + 30 on this Mac. That is why kill -l is the safer way to read a status.

  8. Question 8 of 14 A step ended with status 15. What does kill -l 15 print, and does it prove a signal ended the step?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: TERM, but it proves nothing: 15 is not above 128, so it may be the step's own exit value

    Check status > 128 before reading a status as a signal.

  9. Question 9 of 14 timeout 30 ./job.sh finishes after 2 seconds with exit 4. What does timeout return?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: 4, the command's own status

    Only a timeout, a failure of timeout or a KILL replaces the command's status.

  10. Question 10 of 14 timeout 30 ./deploy.sh is run and deploy.sh exists but has no execute permission. What is the status?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: 126, as a shell reports for a command that is found but cannot be run

    timeout reuses the numbers 126 and 127 that shells use, so the same checks work with or without it.

  11. Question 11 of 14 A command ignores TERM and is run as timeout -k 5 30 cmd. How many seconds after the start does KILL go out?

    Type a number.

    Show the answer: Question 11 of 14

    Answer: 35 seconds

    TERM goes out at 30 seconds, and the -k duration of 5 seconds counts from that moment: 30 + 5. The status is 137.

  12. Question 12 of 14 A script defines check_disk() as a shell function and runs timeout 10 check_disk. What happens, and what is the fix?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: It ends with 127, because timeout starts a program and finds none; run the code as a script with timeout 10 bash check.sh

    A function exists only inside the shell that defined it, so the timed command has to be a program or a script.

  13. Question 13 of 14 A wrapper starts a program in the background and waits for it, with no trap. TERM is sent to the wrapper's PID. What happens?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: The wrapper ends with 143 and the program keeps running, never signalled

    The program is orphaned. A forwarding trap, or exec, keeps the signal on its way to the program.

  14. Question 14 of 14 A launcher starts a program and, after the program ends for any reason, appends a summary line to a report file. Which shape does it need?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: A background child with a TERM trap that forwards the signal, and a wait that returns the child's status before the summary is written

    Use exec when the launcher has nothing left to do, and a forwarding wrapper with two waits when it must still act afterwards.

Interview questions

Three jobs ended with statuses 130, 137 and 143. What does each number tell you, and what produced it?

Each is 128 plus a signal number, the status Bash reports for a command that a signal killed. 130 is SIGINT (2): Ctrl-C or kill -INT. 143 is SIGTERM (15): the polite stop that kill, a service manager or a scheduler sends, which a program can catch to clean up. 137 is SIGKILL (9): it cannot be caught, so the program got no chance to clean up, and typical senders are kill -9, the kernel’s out-of-memory killer and a timeout whose -k period ran out. Two cautions apply. A program can also choose to exit 130 or 143 after handling a signal, so the number alone does not prove that one arrived. And the numbers follow the system’s signal numbering, so kill -l 137 is the safe way to translate a status.

Two harder questions are about a log line that disagrees with what is still running, and about a stop that takes longer than it should and says nothing while it does. Sketch the process tree for each case on paper before you open its first step.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Cron runs timeout 600 ./backup.sh every 15 minutes. The log shows status 124, written ten minutes after the limit, and the next run began while this one was still going. backup.sh traps TERM to delete temp files and starts rsync children. Explain what timeout did and why the run overran, then fix the setup.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A service takes ten seconds to stop and logs no shutdown message. Its Dockerfile has ENTRYPOINT ./entrypoint.sh in shell form, and entrypoint.sh ends with python app.py. Explain why TERM never reaches the app, and fix both files.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How do TERM and KILL differ?

    TERM asks: a process can catch it, ignore it or answer with its own cleanup. KILL forces: the kernel handles it, like STOP, and the program never sees it.

  2. Why should a script name signals instead of numbering them?

    Several common signal numbers differ between macOS and Linux, while names such as TERM, INT and HUP mean the same on both.

  3. What do kill PID, kill -0 PID and kill -- -PGID do?

    kill sends TERM unless told otherwise. kill -0 sends nothing and only tests that the process exists and may be signalled. A minus sign before the number reaches every process of that process group.

  4. Why can killing a job's first process leave its helpers running?

    The signal reaches only that process. Its children go on unless the whole group is signalled with kill -TERM -- -PGID, which a script has per job only with set -m, or unless the leader forwards the signal.

  5. What do the statuses 130, 137 and 143 mean?

    Each is 128 plus the number of the signal that killed the command: 130 is INT, as from Ctrl-C; 137 is KILL, which cannot be caught; 143 is TERM. kill -l 143 names the signal.

  6. Which statuses does timeout end with?

    124 when the time limit was reached and the command obeyed TERM, 137 when KILL was needed, the command's own status when it finished in time, and 125 to 127 for timeout's own failures.

  7. How does a launcher script make sure its program receives TERM?

    Either it traps TERM, forwards it to the child and waits twice, since the first wait returns 128 plus the signal at once, or it replaces itself with exec so the program is the process that gets the signal.

Key takeaways

  • A signal is a numbered message with a default action: TERM can be caught, ignored or answered with cleanup, KILL and STOP cannot, and six common numbers differ between macOS and Linux, so scripts write names.
  • kill sends TERM unless told otherwise, kill -0 only tests that a process exists and may be signalled, and kill -- -PGID reaches every process of a group, which a script has per job only with set -m.
  • A command killed by signal n reports 128 + n (130 for INT, 137 for KILL, 143 for TERM), kill -l STATUS names the signal, and a handler that exits 128 + n gives the same number as a kill.
  • timeout returns 124 when the limit was reached, 137 when KILL was needed (-k, -s KILL), the command’s own status otherwise, and 125, 126 or 127 for its own failures; --preserve-status keeps the command’s status.
  • A launcher either traps TERM, forwards it to its child and waits twice (the first wait returns 128 + the signal at once, the second returns the child’s status), or replaces itself with exec so that the program gets the signal.

Terms in this lesson

graceful shutdown
Stopping a program in an orderly way: it catches a request such as TERM, finishes or abandons its current work, releases what it holds and exits, instead of being killed on the spot.
signal
A short message the kernel delivers to a process, with a name, a number and a default action such as ending it. A program can handle or ignore most signals; KILL and STOP cannot be caught.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.