Python Part II · Module 32 · Python, Tcl and Bash in EDA Flows
Propagating Failure Across Language Boundaries
A flow is only as honest as its narrowest channel, and the narrowest channel in this module is a single integer between 0 and 255 that has to survive three handoffs. This chapter runs one failing chain eight different ways — all three interpreters are installed, so nothing here is hypothetical — and finds that half the variants report a clean result over a script that exited with an error. The worst of them is not an omission but an arithmetic accident, and it turns the two most broken runs in a regression into the two that pass.
Advanced22 min readPythonPart IIAutomationExit CodesReliability
Part II · Module 32 · Chapter 32.7 · Propagating Failure Across Language Boundaries
1. One Integer, Three Handoffs
CHAIN = [
("run.tcl", "tcl", "exit 3", "the tool's script decides"),
("the tool", "binary", "forwards its script's status", "usually"),
("run.py", "python", "sys.exit(...)", "must choose what to forward"),
("run.sh", "bash", "exit $?", "must capture before anything else runs"),
]
print(f"{'program':<12} {'language':<9} {'how it reports':<32} note")
for prog, lang, how, note in CHAIN:
print(f"{prog:<12} {lang:<9} {how:<32} {note}")
print()
print("hops :", len(CHAIN) - 1)
print("channel width :", "one integer, 0 to 255")
print("places to drop it:", len(CHAIN) - 1)program language how it reports note
run.tcl tcl exit 3 the tool's script decides
the tool binary forwards its script's status usually
run.py python sys.exit(...) must choose what to forward
run.sh bash exit $? must capture before anything else runs
hops : 3
channel width : one integer, 0 to 255
places to drop it: 3Three hops, and the thing being passed is smaller than any other data in the flow. Chapter 32.2 Section 4 counted four channels crossing a boundary; this is the narrowest of them, and it is the one a regression's verdict rests on.
2. The Annotation
# run.sh -> run.py -> dc_shell -> run.tcl
# every hop must pass the status up, or the flow reports greenUnusually, the annotation is a correct and complete statement of the requirement. It is a comment rather than code, and the chapter's job is to find out how many ways there are to violate it:
WAYS = [
("the tcl script does not exit non-zero", "32.4 §7"),
("the tool does not forward its script's status", "tool-specific"),
("python ignores the returncode", "§5"),
("python exits with a number above 255", "§4"),
("bash runs something else before reading $?", "§5"),
("bash pipes the command", "32.3 §3"),
("bash collapses the code with || exit 1", "32.3 §4"),
("set -e is suspended by the calling context", "§5"),
]
print("ways to violate a one-line requirement:")
for what, where in WAYS:
print(f" {what:<46} {where}")
print()
print("count:", len(WAYS))
print("all of them produce:", "a flow that reports green")ways to violate a one-line requirement:
the tcl script does not exit non-zero 32.4 §7
the tool does not forward its script's status tool-specific
python ignores the returncode §5
python exits with a number above 255 §4
bash runs something else before reading $? §5
bash pipes the command 32.3 §3
bash collapses the code with || exit 1 32.3 §4
set -e is suspended by the calling context §5
count: 8
all of them produce: a flow that reports greenEight ways to get one line wrong, three of which earlier chapters have already measured. The rest are this chapter's.
3. The Chain, Built — and Broken by Chapter 32.4
Before the matrix, an accident worth keeping. The first version of this chapter's own test chain used a Tcl script that printed a tag in brackets:
import subprocess
from pathlib import Path
Path("inner.tcl").write_text('puts " [tcl] constraint file missing"\nexit 3\n',
encoding="utf-8")
r = subprocess.run(["tclsh", "inner.tcl"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, stdin=subprocess.DEVNULL)
print("intended exit code:", 3)
print("actual returncode :", r.returncode)
print("what happened :", r.stdout.strip().splitlines()[0])intended exit code: 3
actual returncode : 1
what happened : invalid command name "tcl"Tcl executed a command called tcl. The [...] inside double quotes is command substitution — Chapter 32.4 Section 3's finding — so the script died before reaching its exit 3.
The symptom is the subtle part: a status did propagate, all the way to bash, correctly. It was just the wrong status, reporting a Tcl syntax problem where the script meant to report a missing constraint file. A chain can be perfectly honest about a failure that is not the one that happened.
Braces fix it, and the rest of the chapter uses that version:
import subprocess
from pathlib import Path
Path("inner.tcl").write_text('puts { [tcl] constraint file missing}\nexit 3\n',
encoding="utf-8")
r = subprocess.run(["tclsh", "inner.tcl"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, stdin=subprocess.DEVNULL)
print("returncode:", r.returncode)
print("output :", r.stdout.strip())returncode: 3
output : [tcl] constraint file missing4. sys.exit(error_count) Reports Success at 256 Errors
This is the chapter's worst finding and the one that is pure arithmetic:
import subprocess
from pathlib import Path
Path("cnt.py").write_text(
"import sys\n"
"n = int(sys.argv[1])\n"
"print(f'found {n} errors')\n"
"sys.exit(n)\n", encoding="utf-8")
Path("w.sh").write_text(
'python3 cnt.py "$1"\n'
'rc=$?\n'
'if [ $rc -eq 0 ]; then echo " verdict: PASS"; '
'else echo " verdict: FAIL rc=$rc"; fi\n'
'exit $rc\n', encoding="utf-8")
print(f"{'errors found':>13} {'bash $?':>9} verdict")
for n in (0, 1, 5, 255, 256, 257, 512):
r = subprocess.run(["bash", "w.sh", str(n)], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
verdict = [l for l in r.stdout.splitlines() if "verdict" in l][0].strip()
print(f"{n:>13} {r.returncode:>9} {verdict.split(': ', 1)[1]}") errors found bash $? verdict
0 0 PASS
1 1 FAIL rc=1
5 5 FAIL rc=5
255 255 FAIL rc=255
256 0 PASS
257 1 FAIL rc=1
512 0 PASS256 errors reports PASS. 512 errors reports PASS. The exit status a process returns is taken modulo 256, so sys.exit(n) for any multiple of 256 is indistinguishable from success.
sys.exit(error_count) is a natural thing to write — it reads as "exit with the number of problems" — and the two values at which it lies are not obscure corner cases. A long UVM regression that reports every mismatch can easily produce exactly 256 errors, and a run with 257 reports 1, which is also wrong: it looks like one error.
Tcl has the same arithmetic, as Chapter 32.2 measured: exit 256 from a Tcl script returns 0.
import subprocess
from pathlib import Path
Path("cnt.py").write_text(
"import sys\n"
"n = int(sys.argv[1])\n"
"sys.exit(0 if n == 0 else 1)\n", encoding="utf-8")
print("the fix: exit a STATUS, and carry the count elsewhere")
print()
print(f"{'errors':>8} {'exit code':>10} meaning")
for n in (0, 1, 255, 256, 512):
r = subprocess.run(["python3", "cnt.py", str(n)])
print(f"{n:>8} {r.returncode:>10} {'ok' if r.returncode == 0 else 'the run failed'}")
print()
print("codes used:", {0, 1})
print("the count belongs in:", "the result record and the report — 25.6, 30.1")
print("the exit code answers:", "did this step fail, yes or no")the fix: exit a STATUS, and carry the count elsewhere
errors exit code meaning
0 0 ok
1 1 the run failed
255 1 the run failed
256 1 the run failed
512 1 the run failed
codes used: {0, 1}
the count belongs in: the result record and the report — 25.6, 30.1
the exit code answers: did this step fail, yes or no5. Where Bash Drops It
Three of the eight ways are on the bash side, and two are the ones from Chapter 32.3 seen in a chain:
import subprocess
from pathlib import Path
Path("fail.py").write_text("import sys\nprint('the step failed')\nsys.exit(3)\n",
encoding="utf-8")
VARIANTS = {
"bare call, last line": "python3 fail.py\n",
"capture then exit": "python3 fail.py\nrc=$?\nexit $rc\n",
"echo before reading $?": "python3 fail.py\necho done\nexit $?\n",
"piped to tee": "python3 fail.py | tee out.log\nexit $?\n",
"piped, with pipefail": "set -o pipefail\npython3 fail.py | tee out.log\nexit $?\n",
"|| exit 1": "python3 fail.py || exit 1\nexit 0\n",
"set -e, direct": "set -e\npython3 fail.py\nexit 0\n",
"set -e, inside an if": ("set -e\n"
"f() { python3 fail.py; echo after; }\n"
"if f; then echo then; fi\nexit 0\n"),
}
print(f"{'variant':<26} {'rc':>4} outcome")
for label, script in VARIANTS.items():
Path("o.sh").write_text(script, encoding="utf-8")
r = subprocess.run(["bash", "o.sh"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
if r.returncode == 0:
outcome = "LOST"
elif r.returncode == 3:
outcome = "propagated, cause kept"
else:
outcome = f"propagated as {r.returncode}, cause lost"
print(f"{label:<26} {r.returncode:>4} {outcome}")variant rc outcome
bare call, last line 3 propagated, cause kept
capture then exit 3 propagated, cause kept
echo before reading $? 0 LOST
piped to tee 0 LOST
piped, with pipefail 3 propagated, cause kept
|| exit 1 1 propagated as 1, cause lost
set -e, direct 3 propagated, cause kept
set -e, inside an if 0 LOSTEight bash forms, three of which lose the failure entirely and one of which keeps it while destroying the cause.
The third row is the one that surprises people most. echo done between the call and exit $? means $? holds echo's status, which is zero — so adding a progress message to a working wrapper silently disables its error reporting. The fix is in row two: capture $? into a variable on the line immediately after the call.
The last row is bash 3.2.57's set -e suspension, and it is worth stating precisely because it looks like set -e is protecting the script. Inside a function invoked in an if condition, set -e does not apply — so python3 fail.py failed, echo after ran anyway, the function's status became echo's zero, and the if took the then branch. The same suspension applies to a function used with ||, &&, or in a command substitution.
import subprocess
from pathlib import Path
Path("fail.py").write_text("import sys\nsys.exit(3)\n", encoding="utf-8")
CONTEXTS = {
"f": "set -e\nf() { python3 fail.py; echo after; }\nf\necho reached\n",
"if f; then": "set -e\nf() { python3 fail.py; echo after; }\nif f; then echo then; fi\necho reached\n",
"f || true": "set -e\nf() { python3 fail.py; echo after; }\nf || true\necho reached\n",
"v=$(f)": ("set -e\nf() { python3 fail.py; echo after; }\n"
"v=$(f)\necho \"captured: [$v]\"\necho reached\n"),
}
for label, script in CONTEXTS.items():
Path("o.sh").write_text(script, encoding="utf-8")
r = subprocess.run(["bash", "o.sh"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
ran_on = "after" in r.stdout
print(f" {label:<14} rc={r.returncode} continued past the failure={ran_on}") f rc=3 continued past the failure=False
if f; then rc=0 continued past the failure=True
f || true rc=0 continued past the failure=True
v=$(f) rc=0 continued past the failure=TrueOne context honours set -e and three suspend it. The call site decides, and nothing inside the function can see which context it was called from — which is why set -e is not a substitute for checking a status.
6. The Whole Chain, Eight Ways
import os
import subprocess
from pathlib import Path
Path("inner.tcl").write_text('puts { [tcl] constraint file missing}\nexit 3\n',
encoding="utf-8")
Path("mid.py").write_text(
'import os, subprocess, sys\n'
'r = subprocess.run(["tclsh", "inner.tcl"], stdin=subprocess.DEVNULL)\n'
'mode = os.environ.get("MODE", "forward")\n'
'print(f" [py] tool returned {r.returncode}")\n'
'if mode == "forward": sys.exit(r.returncode)\n'
'if mode == "swallow": sys.exit(0)\n'
'if mode == "count": sys.exit(256)\n'
'sys.exit(1)\n', encoding="utf-8")
VARIANTS = {
"every hop forwards": ("forward", "python3 mid.py\nexit $?\n"),
"python swallows": ("swallow", "python3 mid.py\nexit $?\n"),
"python exits 256": ("count", "python3 mid.py\nexit $?\n"),
"bash pipes to tee": ("forward", "python3 mid.py | tee out.log\nexit $?\n"),
"bash echoes first": ("forward", "python3 mid.py\necho done\nexit $?\n"),
"bash uses || exit 1": ("forward", "python3 mid.py || exit 1\nexit 0\n"),
"set -e inside an if": ("forward",
"set -e\nf() { python3 mid.py; echo after; }\n"
"if f; then echo then; fi\nexit 0\n"),
"pipefail + tee": ("forward",
"set -o pipefail\npython3 mid.py | tee out.log\nexit $?\n"),
}
print(f"{'variant':<24} {'final rc':>9} verdict")
lost = kept = 0
for label, (mode, script) in VARIANTS.items():
Path("o.sh").write_text(script, encoding="utf-8")
e = dict(os.environ)
e["MODE"] = mode
r = subprocess.run(["bash", "o.sh"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, env=e)
if r.returncode == 0:
verdict, = ["LOST — reports green"]
lost += 1
elif r.returncode == 3:
verdict = "propagated, cause kept"
kept += 1
else:
verdict = f"propagated as {r.returncode}, cause lost"
print(f"{label:<24} {r.returncode:>9} {verdict}")
print()
print("variants:", len(VARIANTS))
print("report green over a failing chain:", lost)
print("preserve the original cause (3) :", kept)variant final rc verdict
every hop forwards 3 propagated, cause kept
python swallows 0 LOST — reports green
python exits 256 0 LOST — reports green
bash pipes to tee 0 LOST — reports green
bash echoes first 0 LOST — reports green
bash uses || exit 1 1 propagated as 1, cause lost
set -e inside an if 0 LOST — reports green
pipefail + tee 3 propagated, cause kept
variants: 8
report green over a failing chain: 5
preserve the original cause (3) : 2Four of eight report green, and of the four that do propagate something, only two preserve the 3 that says a constraint file was missing rather than merely something failed.
That is the whole chapter in one table. A chain of four programs in three languages, each hop written by somebody reasonable, and a coin-flip chance that a failing run arrives at the regression dashboard as a pass.
Note which variants fail. None of them is exotic: piping to tee to keep a log, echoing progress, using || exit 1 for brevity, wrapping steps in shell functions and calling them from if. Every one is a thing a careful engineer does for a good reason.
7. One Obligation Per Hop
What each hop owes the hop above it
Stage 2 is the hop this module cannot demonstrate, because the tool it describes does not exist here — and it is worth saying plainly that it is not guaranteed. A tool shell that runs a script which exits 3 may itself exit 0, 1 or 3 depending on the tool and its options, so the only honest advice is to measure it once for the tools you use and write the answer down. Chapter 32.6 Section 4 measured it for the one simulator available, and the answer there was that the exit code reports the tool, not the script.
8. The Chain, Assembled Honestly
import os
import subprocess
import sys
from pathlib import Path
Path("inner.tcl").write_text(
'# GENERATED by gen_sta.py — do not edit\n'
'set rc 0\n'
'if {[catch {\n'
' puts { [tcl] reading constraints}\n'
' error "constraint file sdc/top.sdc not found"\n'
'} msg]} { puts " FAILED: $msg" ; set rc 3 }\n'
'exit $rc\n', encoding="utf-8")
Path("run.py").write_text(
'import subprocess, sys\n'
'from pathlib import Path\n'
'\n'
'EXIT = {"OK": 0, "TOOL_FAILED": 1, "CONSTRAINTS": 3, "INFRA": 4}\n'
'\n'
'run_dir = Path("runs/sta"); run_dir.mkdir(parents=True, exist_ok=True)\n'
'log = run_dir / "tool.log"\n'
'\n'
'if not Path("inner.tcl").is_file():\n'
' print(" [py] no script to run", file=sys.stderr)\n'
' sys.exit(EXIT["INFRA"])\n'
'\n'
'r = subprocess.run(["tclsh", "inner.tcl"], stdout=subprocess.PIPE,\n'
' stderr=subprocess.STDOUT, text=True,\n'
' stdin=subprocess.DEVNULL)\n'
'log.write_text(r.stdout, encoding="utf-8")\n'
'print(r.stdout, end="")\n'
'print(f" [py] tool returncode {r.returncode}")\n'
'\n'
'failed_in_log = "FAILED" in r.stdout\n'
'print(f" [py] log says failed: {failed_in_log}")\n'
'\n'
'if r.returncode == 3 or failed_in_log:\n'
' status = "CONSTRAINTS"\n'
'elif r.returncode != 0:\n'
' status = "TOOL_FAILED"\n'
'else:\n'
' status = "OK"\n'
'print(f" [py] status {status} -> exit {EXIT[status]}")\n'
'sys.exit(EXIT[status])\n', encoding="utf-8")
Path("run.sh").write_text(
'#!/bin/bash\n'
'set -o pipefail\n'
'python3 run.py 2>&1 | tee runs/sta/wrapper.log\n'
'rc=$?\n'
'echo "[bash] python exited $rc"\n'
'case $rc in\n'
' 0) echo "[bash] PASS" ;;\n'
' 3) echo "[bash] FAIL: constraints" ;;\n'
' 4) echo "[bash] FAIL: infrastructure" ;;\n'
' *) echo "[bash] FAIL: unclassified" ;;\n'
'esac\n'
'exit $rc\n', encoding="utf-8")
Path("runs/sta").mkdir(parents=True, exist_ok=True)
r = subprocess.run(["bash", "run.sh"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
print(r.stdout, end="")
print()
print("final returncode:", r.returncode)
print("the cause survived three hops:", r.returncode == 3)
print("files kept:", sorted(p.name for p in Path("runs/sta").iterdir())) [tcl] reading constraints
FAILED: constraint file sdc/top.sdc not found
[py] tool returncode 3
[py] log says failed: True
[py] status CONSTRAINTS -> exit 3
[bash] python exited 3
[bash] FAIL: constraints
final returncode: 3
the cause survived three hops: True
files kept: ['tool.log', 'wrapper.log']The cause survived three hops and two language boundaries, arriving at bash as a code that case turns back into a sentence a human reads.
Five decisions made that possible, each from an earlier chapter. The Tcl catch sets a classified code rather than letting an uncaught error produce a bare 1 (Chapter 32.4 Section 7). Python reads both the returncode and the log, because Chapter 32.6 Section 4 showed the returncode alone is not a verdict. Python's exit codes are a small fixed set with meanings, not a count (Section 4). pipefail is set, so the | tee that keeps the log does not swallow the status (Chapter 32.3 Section 3). And rc=$? is captured on the line immediately after the call, before the echo that would otherwise overwrite it (Section 5).
Note the or in r.returncode == 3 or failed_in_log. That is deliberate redundancy of the kind Chapter 31.6 Section 7 argued for: two independent signals of the same failure, so a tool that forwards its script's status and a tool that does not both produce the right answer.
It earned its keep while this section was being written. An earlier draft of the generated Tcl wrote its failure message as puts " [tcl] FAILED: $msg" — double quotes, with brackets inside — so Tcl executed a command named tcl, the script died at that line, and the returncode came back as 1 instead of 3. The verdict was still correct, because failed_in_log was true. A chain with only the returncode check would have reported TOOL_FAILED and sent somebody looking at the tool.
9. A Signal Is Not an Exit Code
Every status so far has been something a program chose to return. A killed process returns nothing, and the two languages report that differently:
import signal
import subprocess
import sys
from pathlib import Path
Path("hang.py").write_text(
"import os, sys, time\n"
"print(os.getpid(), flush=True)\n"
"time.sleep(60)\n", encoding="utf-8")
p = subprocess.Popen([sys.executable, "hang.py"], stdout=subprocess.PIPE,
text=True)
p.stdout.readline()
p.send_signal(signal.SIGTERM)
p.wait()
print("python's subprocess returncode:", p.returncode)
print("is it negative? :", p.returncode < 0)
print("the signal it names :", -p.returncode,
f"(SIGTERM = {int(signal.SIGTERM)})")python's subprocess returncode: -15
is it negative? : True
the signal it names : 15 (SIGTERM = 15)Python reports -15. A negative returncode means the child did not exit at all — it was killed by signal 15 — and Python encodes that by negating the signal number.
Bash encodes the same event differently:
import subprocess
from pathlib import Path
Path("hang.py").write_text("import time\ntime.sleep(60)\n", encoding="utf-8")
Path("k.sh").write_text(
'python3 hang.py &\n'
'pid=$!\n'
'sleep 0.3\n'
'kill -TERM $pid\n'
'wait $pid\n'
'echo "bash sees $?"\n', encoding="utf-8")
r = subprocess.run(["bash", "k.sh"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
line = [l for l in r.stdout.splitlines() if "bash sees" in l][0]
print(line)
print("128 + 15 =", 128 + 15)
print()
print("the same event, two encodings:")
print(" python:", -15, "(negative signal number)")
print(" bash :", 143, "(128 + signal number)")
print(" neither is in 0-255 as a chosen code:", True)bash sees 143
128 + 15 = 143
the same event, two encodings:
python: -15 (negative signal number)
bash : 143 (128 + signal number)
neither is in 0-255 as a chosen code: True-15 and 143 describe one event. Bash adds 128 to the signal number because a shell's $? is a single unsigned byte and cannot be negative; Python's returncode is a plain int and can be.
Three consequences for a flow, each executable:
RC_CASES = [
(0, "exited cleanly, success"),
(3, "exited with a chosen code"),
(-9, "killed by SIGKILL — out of memory, or the scheduler"),
(-15, "killed by SIGTERM — a timeout, or a human"),
(143, "what bash reports for that same SIGTERM"),
(137, "what bash reports for SIGKILL (128 + 9)"),
]
print(f"{'returncode':>11} interpretation")
for rc, meaning in RC_CASES:
print(f"{rc:>11} {meaning}")
print()
print("a check of `rc != 0` catches all of them:",
all(rc != 0 for rc, _ in RC_CASES[1:]))
print("a check of `rc == 1` catches :", 0)
print("a check of `rc > 0` MISSES :",
[rc for rc, _ in RC_CASES if rc < 0]) returncode interpretation
0 exited cleanly, success
3 exited with a chosen code
-9 killed by SIGKILL — out of memory, or the scheduler
-15 killed by SIGTERM — a timeout, or a human
143 what bash reports for that same SIGTERM
137 what bash reports for SIGKILL (128 + 9)
a check of `rc != 0` catches all of them: True
a check of `rc == 1` catches : 0
a check of `rc > 0` MISSES : [-9, -15]rc > 0 misses every killed process. That is the practical finding: a Python wrapper written as if r.returncode > 0: fail() treats a SIGKILLed simulation as a success, and -9 is precisely the code an out-of-memory kill produces — so the runs most likely to be killed are the heaviest ones, which are the ones a regression most wants to hear about.
rc != 0 is correct, and classifying further is worth the four lines:
import signal
def classify(rc):
if rc == 0:
return "PASS"
if rc is None:
return "TIMEOUT"
if rc < 0:
sig = -rc
name = signal.Signals(sig).name if sig in {s.value for s in signal.Signals} else f"SIG{sig}"
return f"KILLED ({name})"
if rc > 128:
return f"KILLED (signal {rc - 128}, via a shell)"
return f"FAILED (code {rc})"
for rc in (0, 1, 3, -9, -15, 137, 143, None):
print(f" {str(rc):>5} {classify(rc)}")
print()
print("statuses that are a design failure :", "FAILED")
print("statuses that are infrastructure :", "KILLED, TIMEOUT")
print("which 30.1 §4 showed is the split a reviewer needs") 0 PASS
1 FAILED (code 1)
3 FAILED (code 3)
-9 KILLED (SIGKILL)
-15 KILLED (SIGTERM)
137 KILLED (signal 9, via a shell)
143 KILLED (signal 15, via a shell)
None TIMEOUTEight inputs, four statuses, and the split is the one Chapter 30.1 Section 4 identified as the most useful line in a regression report: FAILED is a design problem and KILLED is a machine problem, and a flow that reports them identically sends somebody to debug RTL when the machine ran out of memory.
The None row is Python's own convention from Chapter 23.5: subprocess.run with a timeout raises rather than returning, so a wrapper that records returncode=None for that case keeps the three outcomes distinguishable — which is Chapter 30.1 Section 6's absent-versus-empty rule, applied to a status.
10. Predict, Run, Observe
import subprocess
from pathlib import Path
Path("fail.py").write_text("import sys\nsys.exit(3)\n", encoding="utf-8")
def run(script):
Path("o.sh").write_text(script, encoding="utf-8")
return subprocess.run(["bash", "o.sh"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode
# A
print("A:", run("python3 fail.py\nrc=$?\nexit $rc\n"))
# B
print("B:", run("python3 fail.py\necho done\nexit $?\n"))
# C
Path("c.py").write_text("import sys\nsys.exit(256)\n", encoding="utf-8")
print("C:", subprocess.run(["python3", "c.py"]).returncode)
# D
print("D:", run("set -e\nf(){ python3 fail.py; echo after; }\nif f; then echo t; fi\nexit 0\n"))The verified output:
A: 3
B: 0
C: 0
D: 0A — captured immediately, the cause survives.
B — one echo between the call and $? destroys it.
C — sys.exit(256) is success. The arithmetic, in one line.
D — set -e suspended by the if, the function continued past the failure, and the wrapper reports green.
11. Exercises
Exercise 1 — your chain. Write out your flow's chain of programs and count the hops.
Exercise 2 — eight ways. For each of Section 2's eight, say whether your flow is exposed to it.
Exercise 3 — the wrong cause. Find a failure in your history where the status propagated and described the wrong problem.
Exercise 4 — brackets in a message. Check your generated Tcl for [...] inside double quotes.
Exercise 5 — exit the count. Search your flow for sys.exit applied to a length or a counter.
Exercise 6 — find your 256. For one such call, say what number of errors would report success.
Exercise 7 — 257 is also wrong. Explain what 257 errors would report and why that is worse than a crash.
Exercise 8 — Tcl does it too. Confirm on your own tclsh that exit 256 returns 0.
Exercise 9 — classification, not measurement. Rewrite one count-based exit as a status, and say where the count went.
Exercise 10 — the eight bash forms. Reproduce Section 5's table on your own shell and compare.
Exercise 11 — the echo that broke it. Add a progress message before exit $? in a test wrapper and confirm the status is lost.
Exercise 12 — capture immediately. Audit your wrappers for $? read more than one line after the call.
Exercise 13 — your | tee. Find one and say what status it reports.
Exercise 14 — pipefail. Add it and report which of your steps newly fails.
Exercise 15 — set -e suspension. Demonstrate all four contexts from Section 5 on your own shell.
Exercise 16 — the call site decides. Explain why nothing inside a function can tell whether set -e applies.
Exercise 17 — not a substitute. Say in two sentences why set -e does not replace checking a status.
Exercise 18 — run the matrix. Build Section 6's eight-variant chain for your own flow and report how many report green.
Exercise 19 — none of them is exotic. For each losing variant, name the good reason somebody wrote it that way.
Exercise 20 — stage 2. Measure whether your tool forwards its script's exit status, and write the answer down.
Exercise 21 — the five decisions. Check your own chain against the five that made Section 8 work.
Exercise 22 — two signals. Add the returncode-or-log redundancy to one hop and say what it protects against.
Exercise 23 — codes to sentences. Write the case that turns your exit codes back into sentences.
Exercise 24 — the module, end to end. For each of 32.2 to 32.7, name the one thing you will change in your flow.
12. Summary
- Three hops carry one integer between 0 and 255, and it is the narrowest of the four channels a boundary has.
- The annotation is a correct one-line requirement, and there are eight ways to violate it — three already measured in earlier chapters.
- A chain can be perfectly honest about the wrong failure. This chapter's own test script hit Chapter 32.4's injection hazard:
puts " [tcl] ..."executed a command namedtcl, so rc=1 propagated faithfully and described a syntax error instead of a missing constraint file. sys.exit(error_count)reports PASS at 256 errors and at 512, because the status is taken modulo 256 — and 257 reports1, which looks like one error.- An exit code is a classification, never a measurement. Anything with a magnitude belongs in the record, the log and the report.
- Three of eight bash forms lose the failure entirely, and
|| exit 1keeps it while destroying the cause. - One
echobetween the call andexit $?destroys the status, because$?then holdsecho's. Capture into a variable on the next line. set -eis suspended in three of four calling contexts — inside anif, with||, and in a command substitution — and the call site decides, invisibly from inside the function.- Of eight variants of one failing chain, four report green and only two preserve the original cause.
- None of the losing variants is exotic: keeping a log with
tee, echoing progress,|| exit 1for brevity, and shell functions called fromif. - Whether a tool forwards its script's exit status is tool-specific and not guaranteed — measure it once and write the answer down.
- The honest chain needed five decisions: a Tcl
catchwith a classified code, Python reading both the returncode and the log, a small fixed set of exit codes,pipefailbefore thetee, andrc=$?captured immediately. - Use two independent signals where you can.
r.returncode == 3 or "FAILED" in logis right whether or not the tool forwards its script's status. - A killed process returns no code at all, and the two languages encode that differently: Python reports
-15and bash reports143for one SIGTERM. rc > 0misses every killed process, so a wrapper written that way treats an out-of-memory-9as a success — and the heaviest runs are the ones most likely to be killed.rc != 0is the correct test, and four lines classify further:PASS,FAILED (code n),KILLED (signal n),TIMEOUT.FAILEDis a design problem andKILLEDis a machine problem — Chapter 30.1 Section 4's most useful split, and a flow that reports them identically sends somebody to debug RTL after an OOM kill.returncode=Nonerecords a timeout distinctly, keeping three outcomes apart rather than two.
Module 33 turns from the seams between languages to the discipline that keeps the Python itself trustworthy — golden fixtures, failure-path tests, and breaking the parser on purpose to prove the tests can fail.
Where this fits
Part of the Python curriculum.
