Frontend Labs
⋯

34. Processes: subprocess done right, exit codes, and signals

34.1 Running commands

import subprocess

r = subprocess.run(
    ["ls", "-l", "/etc/hostname"],   # a LIST of arguments: no shell involved
    capture_output=True,
    text=True,                       # decode stdout/stderr as str
    timeout=10,                      # never wait forever
    check=True,                      # raise CalledProcessError on non-zero exit
)
print(r.returncode, r.stdout.strip())

subprocess.run does fork + exec (or posix_spawn, a faster path CPython uses when it can) and waits, as in OS Ch 6.

Never use shell=True with any input you did not write. It passes the string to /bin/sh, so shell metacharacters in the input become commands:

import subprocess
filename = "notes.txt; rm -rf ~"          # attacker-controlled
# WRONG: subprocess.run(f"cat {filename}", shell=True)  -> runs `rm -rf ~`
subprocess.run(["cat", filename], check=False)   # safe: one argument, no shell

That is OS command injection, the same class as SQL injection in the Security guide Ch 42. If you truly need shell features (pipes, globs), build them in Python, or quote with shlex.quote and keep the input on an allow-list.

Streaming output from a long-running command:

import subprocess

with subprocess.Popen(["ping", "-c", "3", "127.0.0.1"], stdout=subprocess.PIPE, text=True) as p:
    for line in p.stdout:              # line by line as it arrives
        print("ping>", line.rstrip())
print("exit", p.returncode)

Exit codes follow the shell convention: 0 success, 1–125 errors, and a process killed by signal N shows up as returncode == -N in Python (the shell shows 128 + N).

34.2 Signals and graceful shutdown

Kubernetes, systemd, and Docker stop a process by sending SIGTERM, waiting a grace period, then sending SIGKILL (OS Ch 20). A worker that ignores SIGTERM loses its in-flight work every deploy.

# labs/worker.py: finish the current job, then exit cleanly on SIGTERM / Ctrl-C
import os
import signal
import threading
import time

stop = threading.Event()

def on_signal(signum, frame):
    print(f"got {signal.Signals(signum).name}; finishing current job", flush=True)
    stop.set()

signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)

print(f"worker pid={os.getpid()}", flush=True)
job = 0
while not stop.is_set():
    job += 1
    print(f"job {job} start", flush=True)
    stop.wait(timeout=2.0)            # stand-in for real work; wakes early on stop
    print(f"job {job} done", flush=True)
print("clean exit")
python3 labs/worker.py & sleep 3; kill -TERM %1; wait

Python-specific rules:

  • Python runs signal handlers in the main thread, between bytecode instructions. A handler cannot interrupt a long C call (a big NumPy operation) until it returns. Keep handlers tiny: set a flag or an Event, and let the main loop act on it.
  • Ctrl-C raises KeyboardInterrupt by default; that is a SIGINT handler Python installs for you.
  • asyncio has its own API: loop.add_signal_handler(signal.SIGTERM, cb).

34.3 PID 1 in containers

If your Python program is PID 1 in a container (CMD ["python", "app.py"] with exec form), the kernel applies no default signal actions to it: SIGTERM is ignored unless you install a handler, and orphaned child processes are never reaped and pile up as zombies. Either handle SIGTERM explicitly, or run a tiny init as PID 1 (docker run --init, or tini) so signals are forwarded and zombies reaped. The OS guide Ch 74 has the full story with Go; the rules are identical for Python.

34.4 Project: a tiny supervisor

# labs/supervisor.py: run a command, restart it on crash with backoff, forward SIGTERM
import signal
import subprocess
import sys
import time

def main(cmd: list[str]) -> int:
    child: subprocess.Popen | None = None
    stopping = False

    def forward(signum, frame):
        nonlocal stopping
        stopping = True
        if child and child.poll() is None:
            child.send_signal(signum)

    signal.signal(signal.SIGTERM, forward)
    signal.signal(signal.SIGINT, forward)

    backoff = 0.5
    while True:
        started = time.monotonic()
        child = subprocess.Popen(cmd)
        code = child.wait()
        if stopping:
            print(f"supervisor: child exited {code} after stop request", file=sys.stderr)
            return 0
        ran = time.monotonic() - started
        backoff = 0.5 if ran > 10 else min(backoff * 2, 30)   # reset after a healthy run
        print(f"supervisor: child exited {code}; restarting in {backoff:.1f}s", file=sys.stderr)
        time.sleep(backoff)

if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:] or ["python3", "-c", "import time; time.sleep(1); raise SystemExit(3)"]))

This is a miniature systemd Restart=on-failure with RestartSec backoff (OS Ch 33). In production, let systemd or Kubernetes supervise; write this once to understand what they do.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36