Linux & Bash · Part I · Module 21 · From Script to Reusable Tool
Exit Codes as a Contract
Chapter 15.6 taught the convention: 0 worked, 1 failed, 2 you called it wrongly. Every script since has followed it. This chapter makes it an interface rather than a habit — because the exit status is the only part of a tool's output a machine can branch on, which gives it the same obligations as the name of a flag.
Advanced18 min readLinuxPart IBashExit StatusInterfaceScripting
Part I · Module 21 · Chapter 21.4 · Exit Codes as a Contract
A human reading your tool's output can tell a licence failure from a compile error from a test failure. An automated caller cannot read the output at all. It gets one integer.
1. The Status Is an Interface
Which numbers are yours, and what yours promise
Here is what a caller does with it, and it is the whole argument:
./check-env.sh
case $? in
0) run_regression ;;
2) note "fix your invocation"; exit 2 ;;
3) note "licence server unreachable — retrying in 10m"; sleep 600; retry ;;
4) note "tools not installed on this host — moving the job"; requeue ;;
*) note "unexpected status"; exit 1 ;;
esacFour different reactions to four different numbers. The caller cannot grep your output for "licence" — your wording will change, your output may be on stderr, and in a scheduler it may not be captured at all. The number is the API.
2. The Numbers That Are Not Yours
Measured, each produced deliberately:
$ touch ne.sh && chmod 644 ne.sh && ./ne.sh
bash: ./ne.sh: Permission denied
$ echo $?
126
$ bash -c 'nosuchcommand'
bash: nosuchcommand: command not found
$ echo $?
127
$ bash -c 'kill -TERM $$'
$ echo $?
143| Value | Means | Set by |
|---|---|---|
| 0 | success | you |
| 1–125 | yours to define | you |
| 126 | found but not executable | the shell |
| 127 | command not found | the shell |
| 128+N | killed by signal N | the shell |
| 130 | interrupted (128+2) | the shell |
| 143 | terminated (128+15) | the shell |
| 255 | often "out of range" | see below |
So 1 to 125 is your range, and using anything above it means a caller cannot distinguish your meaning from the shell's. A script that returns 127 for "config not found" is indistinguishable from a script that does not exist — which is precisely the case a caller most needs to tell apart.
Two more measurements worth having:
$ bash -c 'exit 256'; echo $?
0
$ bash -c 'exit -1'; echo $?
255exit 256 is 0. The status is one byte, so 256 wraps to zero and a failure is reported as success. Chapter 15.6 measured it for scripts and Chapter 18.3 for functions; it is the reason for the rule that follows.
3. Choosing Your Numbers
Three or four meanings is almost always enough, and the first three are conventional:
| Status | Meaning |
|---|---|
| 0 | it worked |
| 1 | the work failed — tests failed, the build broke |
| 2 | you called it wrongly — bad arguments, bad configuration |
| 3+ | your own distinctions |
0, 1 and 2 are worth treating as fixed, because they are what every tool in this course has used and what a reader will assume. The interesting decisions are above 2, and the test for each one is: would a caller do something different?
exit: 0 every test passed
1 at least one test failed
2 bad invocation or configuration
3 no licence available — a retry may succeed
4 the environment is not set up — a retry will not helpThat table earns its keep because 3 and 4 lead to opposite actions: retry later versus move the job to a different host. A caller that cannot tell them apart either retries forever or gives up immediately, and both are wrong half the time.
By contrast, splitting 1 into "a test failed" and "two tests failed" earns nothing — no caller branches on it, and the count is already in the output.
4. Document It Where the Caller Looks
Three places, and the cost of all three is a dozen lines:
In the help text, because that is what a human reads:
Exit status:
0 every test passed
1 at least one test failed
2 bad invocation or configuration
3 no licence available (a retry may succeed)
4 the environment is not set up (a retry will not help)In the header comment, so head is documentation:
#!/usr/bin/env bash
# run-suite — run a regression suite
#
# usage: run-suite [-v] [-s SEEDS] <testlist>
#
# exit: 0 all passed · 1 a test failed · 2 bad invocation
# 3 no licence (retryable) · 4 environment not set upAnd as named constants in the script itself, which is the part people skip:
readonly EX_OK=0
readonly EX_FAIL=1
readonly EX_USAGE=2
readonly EX_NOLICENCE=3
readonly EX_NOENV=4die_usage() { note "$*"; usage; exit "$EX_USAGE"; }
have vcs || { note "vcs is not on PATH"; exit "$EX_NOENV"; }exit "$EX_NOENV" says what it means at the call site, and readonly means a later edit cannot quietly reassign it — Chapter 18.5's local -r at script level. The constants also give you one place to grep when you want to know every exit path: grep -n 'exit "\$EX_' run-suite.
5. Changing a Code Breaks Callers Silently
This is why it is a contract rather than a convention. A caller written against your table:
./check-env.sh
case $? in
0) run_regression ;;
3) sleep 600; retry ;;
4) requeue ;;
*) note "unexpected status $?"; exit 1 ;;
esacNow you decide 3 and 4 were the wrong way round and swap them. Nothing errors. The caller's case still matches both numbers — it just does the wrong thing with each, retrying forever on a host where the tools are not installed and requeueing when a ten-minute wait would have worked.
And if you renumber — 3 becomes 5 — the caller falls through to *), logs "unexpected status", and exits 1. Which at least says something, but says it about the wrong layer.
So treat the numbers like flag names:
- Add freely. A new code is a new meaning; a caller's
*)arm catches it until they care. - Never reuse. If 3 stops meaning "no licence", retire 3 and use 5. A retired number is cheaper than a silently wrong branch.
- Never renumber. There is no deprecation warning for an integer.
- And give callers a catch-all to land in, by documenting that unlisted non-zero values mean "failed, reason not categorised".
6. The Status Only Arrives If the Caller Lets It
One interaction worth stating, because it undoes everything above. Chapter 20.2 measured that set -e is suspended at a tested call site — and the corollary is that your carefully chosen status is only visible if the caller captures it:
$ cat caller.sh
set -e
./check-env.sh # exits 4
echo "NOT REACHED"
$ bash caller.sh; echo "caller rc=$?"
caller rc=4Good — a bare call propagates it. But:
$ cat caller2.sh
set -e
if ./check-env.sh; then echo "env ok"; else echo "env not ok"; fi
echo "and we continue"
$ bash caller2.sh; echo "caller rc=$?"
env not ok
and we continue
caller rc=0The if consumed the status and flattened four distinct meanings into one boolean. That is the caller's choice and it is often the right one — but it means a tool with a five-value contract should say, in its help text, that case $? is the way to use it, because if tool; then throws the contract away and nothing warns you.
Exit status (use `case $?` to distinguish; `if` collapses these to pass/fail):
0 every test passed
...7. A Complete Pair
A tool with a real contract, and a caller that uses it:
#!/usr/bin/env bash
# check-env — verify this host can run a regression
#
# usage: check-env [-v]
#
# exit: 0 ready
# 2 bad invocation
# 3 no licence available (retryable — try another host or wait)
# 4 tools not installed (not retryable on this host)
# 5 scratch space exhausted (retryable after cleanup)
#
# Use `case $?` to distinguish these; `if check-env; then` collapses
# them to pass/fail and discards the reason.
set -o errexit
set -o nounset
set -o pipefail
readonly EX_OK=0
readonly EX_USAGE=2
readonly EX_NOLICENCE=3
readonly EX_NOTOOLS=4
readonly EX_NOSPACE=5
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
usage() { printf 'usage: %s [-v]\n' "${0##*/}" >&2; }
have() { command -v "$1" > /dev/null 2>&1; }
verbose=0
while getopts ':v' opt; do
case $opt in
v) verbose=1 ;;
\?) note "unknown option: -$OPTARG"; usage; exit "$EX_USAGE" ;;
esac
done
shift $(( OPTIND - 1 ))
[ $# -eq 0 ] || { usage; exit "$EX_USAGE"; }
say() { [ "$verbose" -eq 0 ] || note "$*"; }
# ---- the checks, each mapped to one documented status -------------------
say 'checking for the tools'
if ! have make; then
note 'make is not on PATH'
exit "$EX_NOTOOLS"
fi
say 'checking scratch space'
# stand-in: a sentinel file means "full"
if [ -f ./SCRATCH_FULL ]; then
note 'scratch space is exhausted'
exit "$EX_NOSPACE"
fi
say 'checking for a licence'
# stand-in: a sentinel file means "no licence"
if [ -f ./NO_LICENCE ]; then
note 'no licence available'
exit "$EX_NOLICENCE"
fi
say 'all checks passed'
exit "$EX_OK"#!/usr/bin/env bash
# driver — use check-env's contract
set -o nounset
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
./check-env
case $? in
0) note 'ready — starting the regression'; printf 'STARTED\n' ;;
2) note 'driver called check-env wrongly — this is a bug in driver'; exit 2 ;;
3) note 'no licence; would sleep 600 and retry'; printf 'RETRY\n' ;;
4) note 'tools missing; would requeue on another host'; printf 'REQUEUE\n' ;;
5) note 'scratch full; would clean up and retry'; printf 'CLEANUP\n' ;;
*) note "unexpected status from check-env"; exit 1 ;;
esac$ ./driver
check-env: all checks passed
driver: ready — starting the regression
STARTED
$ touch NO_LICENCE && ./driver
check-env: no licence available
driver: no licence; would sleep 600 and retry
RETRY
$ rm NO_LICENCE && touch SCRATCH_FULL && ./driver
check-env: scratch space is exhausted
driver: scratch full; would clean up and retry
CLEANUP
$ rm SCRATCH_FULL
$ PATH=/usr/bin:/bin ./driver # make is still present here
check-env: all checks passed
driver: ready — starting the regression
STARTED
$ ./check-env extra-arg
usage: check-env [-v]
$ echo $?
2
$ ./check-env -z
check-env: unknown option: -z
usage: check-env [-v]
$ echo $?
2And the measured consequence of a caller using if instead:
$ cat if-driver
#!/usr/bin/env bash
if ./check-env; then echo "ready"; else echo "not ready"; fi
$ touch NO_LICENCE && ./if-driver
check-env: no licence available
not ready
$ rm NO_LICENCE && touch SCRATCH_FULL && ./if-driver
check-env: scratch space is exhausted
not readyTwo completely different situations, one word of output. The reason is still on stderr for a human, and the caller has thrown away the only part a machine could act on. Which is why the header comment says so explicitly.
Five decisions:
Five statuses, and each one leads the caller somewhere different — start, fix the driver, wait, requeue, clean up. That is the test from §3, applied; there is no status here that two callers would treat identically.
3 and 5 are retryable, 4 is not, and the help text says which. That distinction is the entire value of having more than one failure code, and it is the kind of thing only the tool knows.
Named constants, readonly. exit "$EX_NOLICENCE" at the call site, and grep -n 'exit "\$EX_' check-env lists every exit path in the file.
2 means "the caller has a bug", which is why driver exits 2 itself on receiving 2 rather than retrying — a usage error from a script calling another script is not a transient condition.
The *) arm exists so that adding a status 6 later does not break driver; it logs and fails rather than falling through silently. §5's rule about giving callers somewhere to land.
Practice
- Produce 126, 127 and 143 deliberately, one command each. Then produce 130 and say what sent it.
- Run
exit 256andexit 300in a subshell and print the status. Explain both using the one-byte rule. - Write a script that ends
exit "$failed"wherefailedis 256. Confirm it reports success, then fix it with the bare-test ending. - Write a five-row exit table for a tool of your own. For each row, name the different thing a caller would do — and delete any row where you cannot.
- Add
readonly EX_*constants to a script with three exit paths. Then rungrep -n 'exit "\$EX_'and say what that gives you thatgrep exitdid not. - Write
driverwith acase $?against five statuses, then rewrite it withif tool; then. Run both against two different failure causes and compare the output. - Swap the meanings of two statuses in a tool without changing the numbers. Run the original caller and describe what it now does wrong.
- Renumber a status from 3 to 5. Run the original caller and note which arm it lands in.
- Find
sysexits.hon your machine — it may not be in/usr/include; tryxcrun --show-sdk-pathorman 3 sysexits. ReadEX_USAGE. Then measure whatgrep,diffandgitactually return for a usage error. What does that tell you about adopting it? - Add a status 6 to
check-envwithout touchingdriver. Confirmdriverstill behaves sanely, and name the arm that saved you.
Check Your Understanding
- Why is the exit status the only part of a tool's output an automated caller can use?
- Which values are not yours, and what does the shell use each for?
- What is the highest status you should return, and why?
- What happens to
exit 256, and what rule follows from it? - What is the test for whether a new status earns its place?
- Why must you never reuse or renumber a status?
- A caller writes
if tool; then. What does that do to a five-value contract?
A simulator is the extreme case of this contract being ignored: Chapter 27.1 measures three stages all returning zero on a design with no design-under-test in it.
For the sharpest violation of this contract, Chapter 29.2 measures a compile that reported 256 errors, produced no executable, and exited 0 — because the status was the error count truncated to one byte.
8. Remember
- A regression is where this contract earns its keep: Chapter 32.7 measures four values divided by who acts, and the one-character mistakes that destroy them —
exit 256reading as0, and a pipe toteereading as success. - The exit status is the only part of your output a machine can branch on, which makes it an interface.
- 1 to 125 are yours. 126 is not executable, 127 is not found, 128+N is signal N — 130 interrupt, 143 terminate.
exit 256is 0. Never return a count; put it in the output and let the status carry the category.- 0, 1 and 2 are conventional: worked, the work failed, you called it wrongly.
- A new status earns its place only if a caller would do something different. Otherwise it is the same status.
- Retryable versus not is the distinction worth encoding — only the tool knows which its failures are.
- Document the table in three places: the help text, the header comment, and
readonly EX_*constants. grep -n 'exit "\$EX_'then lists every exit path in the file.- Add freely, never reuse, never renumber. There is no deprecation warning for an integer.
- Give callers a catch-all to land in, and document that unlisted non-zero means "failed, uncategorised".
if tool; thencollapses the whole contract to pass/fail. Say in the help text thatcase $?is the way to use it.
Chapter 21.5 takes the two properties that decide whether a colleague is willing to run your tool at all: that running it twice is safe, and that it can tell them what it would do before it does it.
Where this fits
Part of the Linux & Bash curriculum.
