Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: 3

Three 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# run.sh -> run.py -> dc_shell -> run.tcl
# every hop must pass the status up, or the flow reports green

Unusually, 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 green

Eight 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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())
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
returncode: 3
output    : [tcl] constraint file missing

4. sys.exit(error_count) Reports Success at 256 Errors

This is the chapter's worst finding and the one that is pure arithmetic:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
 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  PASS

256 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 no

5. 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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  LOST

Eight 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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=True

One 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)  : 2

Four 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

A seven-stage flow of obligations when propagating a failure: the Tcl script exits with a classified code, the tool forwards its script's status, Python captures the return code, Python also derives a status from the log, Python exits with a code below 256, bash captures dollar-question-mark immediately, and bash exits with it.01TCL EXITS A CODEcatch, then a classified exit02THE TOOL FORWARDS ITverify this — it may not03PYTHON CAPTURES RCreturncode, not just output04PYTHON READS THE LOGthe rc is not the verdict — 32.605PYTHON EXITS < 256a status, never a count06BASH CAPTURES $?on the very next line07BASH EXITS WITH ITor is the last command
Seven obligations, and the three marked as checks are the ones measured as most often skipped. Stage 5 is the arithmetic one: a code above 255 wraps, so the status must be a classification and never a count.

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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()))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  [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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)})")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
      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  TIMEOUT

Eight 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: 3
B: 0
C: 0
D: 0

A — 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 named tcl, 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 reports 1, 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 1 keeps it while destroying the cause.
  • One echo between the call and exit $? destroys the status, because $? then holds echo's. Capture into a variable on the next line.
  • set -e is suspended in three of four calling contexts — inside an if, 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 1 for brevity, and shell functions called from if.
  • 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 catch with a classified code, Python reading both the returncode and the log, a small fixed set of exit codes, pipefail before the tee, and rc=$? captured immediately.
  • Use two independent signals where you can. r.returncode == 3 or "FAILED" in log is 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 -15 and bash reports 143 for one SIGTERM.
  • rc > 0 misses every killed process, so a wrapper written that way treats an out-of-memory -9 as a success — and the heaviest runs are the ones most likely to be killed.
  • rc != 0 is the correct test, and four lines classify further: PASS, FAILED (code n), KILLED (signal n), TIMEOUT.
  • FAILED is a design problem and KILLED is 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=None records 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.