Skip to content

Shell, git, make, processes: exit codes, signals, environment, pipes

Modulelang.02 · practice · C and shell · Pass 0 · 3 to 4 h
You buildprimers/lang.02/: a Makefile for the given two-file C program (main.c, greet.c, greet.h), and worker.sh, a script that holds a lock file, traps SIGTERM and SIGINT, cleans up, and exits 128 + the signal number
Contractnone: a primer exercise, not part of the system
Testscourse/tests/lang.02/, run by its check script (what they check: section 4)
Needsa terminal with bash, make, cc, git, and uv (ol doctor); lang.01 comes first on the path
Used byno code call site (a primer). It is the concept prerequisite of craft.01 (your CI gate is processes and exit codes), every ol verdict (an exit code), M03.1 and rt.01 (your C Makefile), L10.0 (your engine shuts down on SIGTERM), and dep.00 (Kubernetes stops a pod with SIGTERM, then SIGKILL)
MilestoneMS-P0 (page: paths/course-p00-setup/milestone.md)
Optional depthThe Linux Command Line, Shotts, ch. 6, 10, 24 to 27 (free at linuxcommand.org); GNU Make manual, ch. 2 and 4 (free); man 7 signal; Pro Git, ch. 1 to 3 (free at git-scm.com)
  • Every process ends with an exit status from 0 to 255; 0 means success. ol check, CI, and Kubernetes all decide by that one number.
  • 128 + n means “ended by signal n”. A trap that cleans up and exits 143 after SIGTERM keeps that information; a script without a trap is simply killed.
  • Bash runs a trap only between commands. Sleep in the background and wait, or a 30 s sleep delays your shutdown by up to 30 s.
  • SIGKILL cannot be trapped, so anything you clean up on exit can be left behind; the next start must detect stale state.
  • make rebuilds a target when a prerequisite is newer, and it only knows the prerequisites you list: headers included.
Terminal window
ol start lang.02 # records the start; there is no stub: you write every file
ol tests lang.02 # read the test catalog first
ol check lang.02 # exit code is the verdict

Next, in craft.01, you set up the gate every later change to your system passes through: CI jobs that run commands and decide pass or fail by their exit status. From then on, ol check speaks to you in exit codes (0 pass, 1 fail, 3 blocked, 4 contract drift), your C code in Pass 1 is built by a Makefile you write, and your Rust engine and Go gateway run as processes that Kubernetes stops by sending SIGTERM and, 30 seconds later, SIGKILL. A server that ignores SIGTERM loses in-flight requests; a build that does not track headers ships objects compiled against an old struct layout; a script that ignores a failing command in a pipe reports green on a broken build. This primer teaches the process model those all share, on two small artifacts you can test in seconds.

A process is a running program. It has a numeric id (its PID), a parent process, an environment (2.3), and three open files numbered 0, 1, 2 (2.4). A shell runs a command by creating a child process and waiting for it. When the child ends, it reports an exit status, an integer from 0 to 255 (only the low 8 bits of exit(n) survive, so exit 256 reports 0). The shell exposes it and a few other facts as special parameters:

SymbolMeaningType / shape
$?exit status of the last commandinteger 0 to 255
$$PID of the current shell (in a script: the script’s own PID)integer
$!PID of the last command started in the background with &integer
$1, $2, …the script’s argumentsstrings
nna signal number: 2 for SIGINT, 9 for SIGKILL, 15 for SIGTERMinteger
128+n128 + nthe status a shell reports for a child ended by signal nninteger

Conventions every tool in this course follows: 0 success; 1 a general failure; 2 wrong usage (bad arguments); 126 found but not executable; 127 command not found; 128+n128 + n ended by signal nn, so 130 after Ctrl-C, 137 after SIGKILL (what an out-of-memory kill looks like), 143 after SIGTERM.

Status drives control flow: a && b runs b only if a exited 0; a || b only if it did not. Three settings make scripts fail fast instead of carrying on after an error: set -e (exit when a command fails), set -u (an unset variable is an error), and set -o pipefail (2.4).

A signal is a small asynchronous message the kernel delivers to a process: “stop now”, “you were interrupted”. kill -TERM 4242 sends SIGTERM to PID 4242; Ctrl-C in a terminal sends SIGINT. Each signal has a default action, and for these three it is “terminate”:

SignalNumberSent byCan a process catch it?
SIGINT2Ctrl-Cyes
SIGTERM15kill with no option, Kubernetes, systemd, CI cancellationyes
SIGKILL9kill -9, the out-of-memory killer, Kubernetes after the grace periodno: the process ends, nothing of it runs

kill -0 PID sends nothing; it only tells you whether the process exists (status 0) or not.

In bash, trap 'commands' TERM replaces the default action with your commands. The rule that surprises everyone: bash runs a trap only after the command it is currently running finishes. While a foreground sleep 30 runs, a SIGTERM waits. The builtin wait, however, returns as soon as a trapped signal arrives. So a script that must stop promptly starts its long waits in the background (sleep 30 &) and then waits for them. Once the trap runs, the background child is still alive; the trap must kill it (jobs -p lists the PIDs of the shell’s background children), or it lives on as an orphan.

The environment is a list of NAME=value strings each process carries. A child gets a copy of its parent’s environment when it starts, so a child can never change its parent’s variables. In bash, export NAME=value puts a variable into the environment of every later child; NAME=value cmd sets it for one command only. ${NAME:-default} reads a variable and substitutes default when it is unset or empty. Configuration by environment variable is how your services are configured in Kubernetes (TL_* variables, 2.16 of the design) and how ol points every command at your repo (OL_COURSE_HOME).

Every process starts with file descriptors 0 (stdin), 1 (stdout), and 2 (stderr). Programs print results to stdout and diagnostics to stderr, so the two can be separated: cmd > out.txt 2> err.txt; 2>&1 sends stderr wherever stdout goes. A pipe a | b connects a’s stdout to b’s stdin; both run at once. The status of a pipeline is the status of its last command: grep x missing-file | wc -l exits 0 even though grep failed, unless set -o pipefail, which makes the pipeline fail when any part fails.

C is built in two stages. Compiling turns one .c file into one object file: cc -c greet.c -o greet.o. A header (greet.h) declares what another file defines, and #include "greet.h" pastes it into every .c that uses it. Linking combines object files into a program: cc main.o greet.o -o greet. Compiling separately is what makes rebuilds cheap: change greet.c and only greet.o needs compiling again.

make automates exactly that decision. A Makefile is a list of rules:

target: prerequisite1 prerequisite2
recipe line run by the shell (it must start with a TAB character)

To build a target, make first brings each prerequisite up to date, then runs the recipe if the target file does not exist or is older than any prerequisite (by modification time). Plain make builds the first target in the file. Pieces you will use:

PieceMeaning
CC, CFLAGSvariables for the compiler and its flags; make CC=clang overrides them from the command line
$@, $<, $^in a recipe: the target, the first prerequisite, all prerequisites
%.o: %.ca pattern rule: how to make any .o from the .c of the same name
main.o: greet.ha rule with no recipe: adds a prerequisite to main.o, because make cannot see #include
.PHONY: cleanclean is a name, not a file; run its recipe every time it is asked for

git records snapshots. A commit is a full snapshot of the tracked files, plus a message, an author, a time, and a pointer to its parent commit, all named by a hash of that content. You choose what goes into the next snapshot by staging: git add file copies the file’s current content into the staging area, and git commit -m "..." turns the staging area into a commit. git status shows what changed and what is staged; git log walks the parents from the newest commit (HEAD); git diff shows unstaged changes. .gitignore lists files git should never offer to track (build outputs like *.o, .venv/, .ol/). A branch is a movable name for a commit; a remote is another copy of the repository you git push to. git runs hooks, scripts in a hooks directory, at fixed moments: craft.01 uses the commit-msg hook, which gets the message file and can reject the commit with a nonzero exit status.

Who gets rebuilt. The program has three source files. Both .c files include greet.h:

greet <- main.o <- main.c, greet.h
<- greet.o <- greet.c, greet.h

Starting from a complete build, make rebuilds exactly the targets downstream of the file you change:

You changeRecompiledRelinkedWhy
nothingnothingnoevery target is newer than its prerequisites
greet.cgreet.oyesgreet.o is now older than greet.c; greet is then older than greet.o
main.cmain.oyessame, on the other side
greet.hmain.o and greet.oyesboth list greet.h as a prerequisite
greet.h, header rules forgottennothingnomake never learns that the header matters: stale objects

A worker’s life. A session with the reference worker.sh (state in /tmp/w, a tick every 30 s):

$ WORKER_STATE_DIR=/tmp/w ./worker.sh & # PID 4242
ready
tick
$ cat /tmp/w/worker.lock
4242
$ kill -TERM 4242 # trap runs at once: wait returns early
$ wait 4242; echo $?
143 # 128 + 15
$ ls /tmp/w/worker.lock
ls: /tmp/w/worker.lock: No such file or directory

Exit statuses to recognize: 128 + 2 = 130 (SIGINT), 128 + 9 = 137 (SIGKILL; no trap ran, so a lock file stays behind), 128 + 15 = 143 (SIGTERM).

The program. ./greet Ada prints hello, Ada and exits 0; ./greet with no argument prints usage: ./greet NAME on stderr and exits 2.

Make primers/lang.02/ in your repo and put these three files in it exactly as written. You do not change them; you write the build for them.

primers/lang.02/greet.h
#ifndef GREET_H
#define GREET_H
#include <stddef.h>
/* Write "hello, <name>" into buf (at most cap bytes, NUL-terminated).
Returns the length it wanted to write, as snprintf does. */
int greet(char *buf, size_t cap, const char *name);
#endif
primers/lang.02/greet.c
#include <stdio.h>
#include "greet.h"
int greet(char *buf, size_t cap, const char *name) {
return snprintf(buf, cap, "hello, %s", name);
}
/* primers/lang.02/main.c: exit codes 0 ok, 1 runtime error, 2 usage error */
#include <stdio.h>
#include "greet.h"
int main(int argc, char **argv) {
char buf[64];
if (argc != 2) {
fprintf(stderr, "usage: %s NAME\n", argv[0]);
return 2;
}
int n = greet(buf, sizeof buf, argv[1]);
if (n < 0 || (size_t)n >= sizeof buf) {
fprintf(stderr, "%s: name too long\n", argv[0]);
return 1;
}
puts(buf);
return 0;
}

The Makefile (primers/lang.02/Makefile) must:

  1. build greet from main.o and greet.o when you run plain make, compiling each .c to its own .o and linking with $(CC);
  2. rebuild only what is downstream of a changed file (the table in section 3), headers included;
  3. compile and link through $(CC), so make CC=clang works;
  4. have a clean target that removes greet and the objects, declared .PHONY.

The worker (primers/lang.02/worker.sh, executable, starting with #!/usr/bin/env bash) must:

  1. keep its state in ${WORKER_STATE_DIR:-${TMPDIR:-/tmp}} and tick every ${WORKER_INTERVAL:-30} seconds;
  2. on start, if worker.lock exists in the state directory and the PID in it is alive (kill -0), print why on stderr and exit 1 without touching the lock; if that PID is gone, remove the stale lock and continue;
  3. write its own PID ($$) to worker.lock, set its traps, then print ready as its first line on stdout;
  4. on SIGTERM exit 143, on SIGINT exit 130, in both cases after removing the lock and killing any background child, promptly even while it waits out a 30 s interval.

The check. ol check lang.02 runs course/tests/lang.02/check in your repo. It refuses early if make, cc, uv, or one of the five files is missing, then runs the tests with pytest (uv run --no-project --with pytest). The make tests work in a scratch copy of the directory, so your repo stays clean.

TestKINDChecksWhy it matters downstream
test_make_builds_a_program_that_runsunitmake builds greet; ./greet Ada prints hello, Ada with status 0; no argument gives status 2your C library is built the same way in Pass 1
test_second_make_rebuilds_nothingunitan up-to-date build does no workfast edit-build loops
test_editing_one_source_rebuilds_only_its_objectunitgreet.c changed: only greet.o and the linkincremental builds
test_editing_the_header_rebuilds_both_objectsboundarygreet.h changed: both objectsno stale struct layouts against tinyllm.h
test_make_uses_the_cc_variableunitcompile and link go through $(CC)sanitizer builds and CI choose the compiler
test_clean_works_even_when_a_file_named_clean_existsboundaryclean is .PHONYphony targets always run
test_worker_is_an_executable_scriptunitexecute bit and a #! lineentry points are exec’d, by CI and by Kubernetes
test_worker_writes_its_pid_to_the_lockunitworker.lock holds the worker’s PID after readythe section 3 session
test_sigterm_exits_143_and_removes_the_lockunitSIGTERM: status 143, lock gonegraceful shutdown in L10.0 and dep.00
test_sigint_exits_130_and_removes_the_lockunitSIGINT: status 130, lock goneCtrl-C behaves too
test_trap_runs_promptly_during_a_long_sleepboundarywith a 30 s interval, SIGTERM ends it within 3 sshutdown inside the grace period
test_no_process_is_left_behindboundaryno process of the worker’s group outlives itno orphans per restart
test_second_start_refuses_while_the_first_runsunita second start exits 1 and leaves the lock aloneone owner of the lock
test_stale_lock_after_sigkill_is_recoveredfaultafter SIGKILL the lock remains; the next start takes it overcrash recovery without a human
PitfallSymptomCaught by
1. A rule whose target never exists as a file (for example build: producing greet)every make relinks or recompiles everythingtest_second_make_rebuilds_nothing
2. One rule cc main.c greet.c -o greetevery change recompiles every filetest_editing_one_source_rebuilds_only_its_object
3. No main.o: greet.h and greet.o: greet.hafter a header change the objects are stale and disagree with ittest_editing_the_header_rebuilds_both_objects
4. A recipe that hardcodes gccmake CC=clang and sanitizer builds are ignoredtest_make_uses_the_cc_variable
5. clean not declared .PHONYa stray file called clean makes make clean do nothingtest_clean_works_even_when_a_file_named_clean_exists
6. No trapthe shell dies with the signal (status shown as -15 by Python), the lock staystest_sigterm_exits_143_and_removes_the_lock
7. A foreground sleep "$interval"SIGTERM waits up to 30 s for the sleep to finishtest_trap_runs_promptly_during_a_long_sleep
8. The trap forgets the background sleepone orphaned process per restarttest_no_process_is_left_behind
9. Trusting any existing lockafter one SIGKILL the worker never starts againtest_stale_lock_after_sigkill_is_recovered
10. Spaces instead of a TAB before a recipe lineMakefile:9: *** missing separator. Stop.test_make_builds_a_program_that_runs
DirectionModuleHow it uses this
Forwardcraft.01CI jobs are processes whose exit status is the gate; the commit-msg hook is a script that rejects with a nonzero status
ForwardM03.1the C exercise’s Makefile builds standalone test binaries from source files and headers
Forwardrt.01the same Makefile gains SANITIZE=1 (a variable, 2.5) to add AddressSanitizer
ForwardL10.0your Rust engine handles SIGTERM: stop accepting, finish in-flight streams, exit
Forwarddep.00Kubernetes sends SIGTERM, waits the grace period (30 s by default), then SIGKILL; status 137 in kubectl describe means SIGKILL
Forwardops.00the first drill reads a crashlooping pod’s exit status and restart count

A primer has no code call site, so no module’s ol check blocks on it. MS-P0 does: it requires a fresh pass of lang.02.

Your pieceProduction equivalentWhat it addsWhere to look
header rules in the Makefilecc -MMD -MP dependency files, Ninjathe compiler writes the header list for you; a faster schedulerGNU Make manual, “Generating Prerequisites Automatically”
the worker’s lock fileflock(1), fcntl locksthe kernel releases the lock when the process dies, so there is no stale lock and no check-then-write raceman 1 flock (util-linux)
the TERM traptini, docker run --inita minimal PID 1 that forwards signals and reaps orphans inside a containerkrallin/tini README.md
exit 143 on SIGTERMKubernetes pod terminationpreStop hooks, terminationGracePeriodSeconds, readiness removal before SIGTERMkubernetes.io, “Pod Lifecycle”, termination of Pods