Skip to content
VLSI Mentor

Python Part II · Module 33 · Testing and Debugging Automation

Partial Failure, Cleanup and Leaving Evidence Behind

A run has three endings, not two. It finishes, it fails, or something outside it decides it is over — and the third is the one that leaves a scratch directory the size of a disk and a results table with a hole in it. The canonical fix is a `finally` block that deletes the scratch and keeps the evidence, which is the right instinct and rests on an assumption worth measuring: that `finally` runs. On four of the seven ways a run can end it does, and the three where it does not include the one a job scheduler uses.

Advanced23 min readPythonPart IIAutomationCleanupSignals

Part II · Module 33 · Chapter 33.13 · Partial Failure, Cleanup and Leaving Evidence Behind

1. Three Endings

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ENDINGS = [
    ("finished", "results written, scratch removable",
     "the result", "no"),
    ("failed", "results missing, logs valuable",
     "why it failed", "no"),
    ("killed from outside", "mid-write, scratch enormous",
     "that it was killed", "yes"),
]

print(f"{'ending':<22} {'what the run dir holds':<36} {'what you need':<22} orphans scratch")
for end, holds, need, orphan in ENDINGS:
    print(f"{end:<22} {holds:<36} {need:<22} {orphan}")

print()
print("endings a flow usually handles:", 2)
print("endings that actually happen  :", 3)
print("the third is sent by          :", "a scheduler, a timeout, or a person")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ending                 what the run dir holds               what you need          orphans scratch
finished               results written, scratch removable   the result             no
failed                 results missing, logs valuable       why it failed          no
killed from outside    mid-write, scratch enormous          that it was killed     yes

endings a flow usually handles: 2
endings that actually happen  : 3
the third is sent by          : a scheduler, a timeout, or a person

The three endings want two different things from cleanup, and they conflict. A run that finished wants its scratch gone, because a 4 GiB intermediate directory per run fills a volume in an afternoon. A run that failed wants its scratch kept, because the thing you need is in there.

The canonical annotation resolves that conflict by classifying the files rather than the outcome, which is Section 7. First, the mechanism.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
finally:
    remove_scratch(run_dir)   # keep logs, waves and cmd.sh
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import shutil
from pathlib import Path

SCRATCH = {"obj", "work", "csrc", "simv.daidir", "xcelium.d"}
KEEP_SUFFIX = {".log", ".vcd", ".fsdb", ".sh", ".json", ".csv"}


def build_run_dir(d):
    """A run directory as a flow actually leaves it."""
    p = Path(d)
    for sub in ("obj", "work", "csrc"):
        (p / sub).mkdir(parents=True, exist_ok=True)
        (p / sub / "big.tmp").write_text("x" * 64, encoding="utf-8")
    p.mkdir(parents=True, exist_ok=True)
    for name in ("sim.log", "compile.log", "waves.vcd", "cmd.sh",
                 "results.json", "scratch.tmp"):
        (p / name).write_text("data\n", encoding="utf-8")
    return p


def remove_scratch(run_dir):
    """Delete the regenerable parts; keep anything that is evidence."""
    p = Path(run_dir)
    removed = []
    for child in sorted(p.iterdir()):
        if child.is_dir() and child.name in SCRATCH:
            shutil.rmtree(child)
            removed.append(child.name + "/")
        elif child.is_file() and child.suffix not in KEEP_SUFFIX:
            child.unlink()
            removed.append(child.name)
    return removed


run_dir = build_run_dir("run_0001")
before = sorted(c.name + ("/" if c.is_dir() else "") for c in run_dir.iterdir())
print("before:", before)

try:
    raise RuntimeError("the simulator exited 1")
except RuntimeError as exc:
    print("failure:", exc)
finally:
    removed = remove_scratch(run_dir)

after = sorted(c.name + ("/" if c.is_dir() else "") for c in run_dir.iterdir())
print("removed:", removed)
print("kept   :", after)
print()
print("entries before:", len(before), "| removed:", len(removed),
      "| kept:", len(after))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
before: ['cmd.sh', 'compile.log', 'csrc/', 'obj/', 'results.json', 'scratch.tmp', 'sim.log', 'waves.vcd', 'work/']
failure: the simulator exited 1
removed: ['csrc/', 'obj/', 'scratch.tmp', 'work/']
kept   : ['cmd.sh', 'compile.log', 'results.json', 'sim.log', 'waves.vcd']

entries before: 9 | removed: 4 | kept: 5

Four entries removed, five kept, and the run failed. That is the behaviour the annotation describes: the cleanup is unconditional — it is in a finally — but what it removes is decided by classification, not by whether the run succeeded.

The comment is doing real work. # keep logs, waves and cmd.sh is the policy, and without it a reader would reasonably assume remove_scratch removes the run directory.

Now: does the finally run?

3. When finally Actually Runs

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys
from pathlib import Path

Path("t.py").write_text(
    'import atexit\n'
    'import os\n'
    'import signal\n'
    'import sys\n'
    '\n'
    'mode = sys.argv[1]\n'
    'atexit.register(lambda: print("ATEXIT", flush=True))\n'
    '\n'
    'try:\n'
    '    print("STARTED", flush=True)\n'
    '    if mode == "raise":   raise RuntimeError("tool crashed")\n'
    '    if mode == "exit":    sys.exit(7)\n'
    '    if mode == "osexit":  os._exit(9)\n'
    '    if mode == "sigint":  os.kill(os.getpid(), signal.SIGINT)\n'
    '    if mode == "sigterm": os.kill(os.getpid(), signal.SIGTERM)\n'
    '    if mode == "sigkill": os.kill(os.getpid(), signal.SIGKILL)\n'
    'finally:\n'
    '    print("FINALLY", flush=True)\n', encoding="utf-8")

MODES = ["ok", "raise", "exit", "osexit", "sigint", "sigterm", "sigkill"]

print(f"{'how the run ended':<20} {'rc':>4}  {'finally':<8} atexit")
for mode in MODES:
    r = subprocess.run([sys.executable, "t.py", mode],
                       stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                       text=True)
    out = r.stdout
    print(f"{mode:<20} {r.returncode:>4}  "
          f"{str('FINALLY' in out):<8} {'ATEXIT' in out}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
how the run ended      rc  finally  atexit
ok                      0  True     True
raise                   1  True     True
exit                    7  True     True
osexit                  9  False    False
sigint                 -2  True     True
sigterm               -15  False    False
sigkill                -9  False    False

finally runs on four of these seven endings and not on three. Read the rows that matter.

sys.exit(7) runs the cleanup. sys.exit raises SystemExit, which is an exception, so every finally on the way out executes. Chapter 33.11's sys.exit(f"...") therefore cleans up properly, which is not obvious from the name.

os._exit(9) does not. It is the raw syscall: no unwinding, no finally, no atexit, and no flush of buffered output. It exists for the child of a fork and almost nothing else.

SIGINT runs the cleanup and SIGTERM does not. That is the asymmetry worth knowing, and it is not a quirk — Python installs a handler for SIGINT that raises KeyboardInterrupt, so the interpreter unwinds normally. SIGTERM has no Python-level handler, so the default disposition applies and the process is terminated by the operating system with the interpreter never regaining control.

Note also that subprocess reports a signal death as a negative number — -2 for SIGINT, -15 for SIGTERM, -9 for SIGKILL — where a shell's $? shows 130, 143 and 137 for the same three events. Chapter 32.7 Section 5 covered that 128-plus-signal convention; returncode has a sign bit available where a shell's single byte does not, so the two spellings carry identical information in different notation. A sweep that records returncode directly and a wrapper that records $? will therefore disagree on every signal death unless one of them normalises, which is a real reconciliation problem rather than a curiosity — and the fix is to store the signal number, not either encoding of it.

4. Making SIGTERM Behave Like SIGINT

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys
from pathlib import Path

HANDLER = (
    'import signal\n'
    '\n'
    'def _on_term(signum, frame):\n'
    '    raise KeyboardInterrupt(f"terminated by signal {signum}")\n'
    '\n'
    'signal.signal(signal.SIGTERM, _on_term)\n'
)

BODY = (
    'import os\n'
    'import signal\n'
    'from pathlib import Path\n'
    '\n'
    'try:\n'
    '    Path("scratch.tmp").write_text("x", encoding="utf-8")\n'
    '    os.kill(os.getpid(), signal.SIGTERM)\n'
    'except KeyboardInterrupt:\n'
    '    print("INTERRUPTED", flush=True)\n'
    '%s'
    'finally:\n'
    '    p = Path("scratch.tmp")\n'
    '    if p.exists():\n'
    '        p.unlink()\n'
    '    print("CLEANED", flush=True)\n'
)

RERAISE = '    sys.exit(143)\n'

VARIANTS = [
    ("no handler", "", BODY % ""),
    ("handler, caught", HANDLER, BODY % ""),
    ("handler + exit 143", HANDLER, "import sys\n" + (BODY % RERAISE)),
]

print(f"{'variant':<22} {'rc':>5}  {'cleaned':<9} {'scratch left':<14} rc says")
for label, prelude, body in VARIANTS:
    Path("s.py").write_text(prelude + body, encoding="utf-8")
    r = subprocess.run([sys.executable, "s.py"], stdout=subprocess.PIPE,
                       stderr=subprocess.DEVNULL, text=True)
    left = Path("scratch.tmp").exists()
    says = {0: "SUCCESS", 143: "killed by signal 15"}.get(r.returncode,
                                                          "killed, uncleaned")
    print(f"{label:<22} {r.returncode:>5}  "
          f"{str('CLEANED' in r.stdout):<9} {str(left):<14} {says}")
    if left:
        Path("scratch.tmp").unlink()
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
variant                   rc  cleaned   scratch left   rc says
no handler               -15  False     True           killed, uncleaned
handler, caught            0  True      False          SUCCESS
handler + exit 143       143  True      False          killed by signal 15

Three variants, and the middle one is the trap. Installing a SIGTERM handler that raises does exactly what Section 3 predicted — the interpreter unwinds, every finally runs, and the scratch is removed. And then the process exits 0, because the exception was caught and handled and nothing afterwards failed.

So the obvious fix trades one bug for a worse one. Variant one orphans its scratch and at least reports that it was killed; variant two cleans up perfectly and tells the sweep the run succeeded. A results table built from exit codes would record a PASS for a run the scheduler terminated at hour six — which is the confident wrong value Chapter 30.7 Section 3 called a report's worst defect.

The third variant is the one to copy. Catch the interrupt, let the finally blocks run, and then exit deliberately with a code that says what happened — 143 being the shell's spelling of "terminated by signal 15", so a wrapper reading $? sees the same value it would have seen without the handler. Cleanup and an honest status.

Two things the handler does not fix, and both should be said plainly. SIGKILL cannot be handled — that is its definition — so a run killed with -9 always orphans its scratch, and the only defence is a sweep-level sweeper that removes stale run directories on the next start. And a handler that raises mid-write still leaves a partially written file, which is why Chapter 30.7's write-to-temp-then-rename pattern matters more than any cleanup: a file that is either absent or complete needs no cleanup decision at all.

5. A return in finally Swallows the Exception

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def cleanup_with_return():
    try:
        raise RuntimeError("the simulator exited 1")
    finally:
        return "scratch removed"          # <- swallows it


def cleanup_without_return():
    try:
        raise RuntimeError("the simulator exited 1")
    finally:
        print("  scratch removed")


print("cleanup_with_return() returns:", repr(cleanup_with_return()))
caught = None
try:
    cleanup_with_return()
except Exception as exc:
    caught = type(exc).__name__
print("exception seen by the caller :", caught)
print()
print("cleanup_without_return():")
try:
    cleanup_without_return()
except RuntimeError as exc:
    print("  propagated:", type(exc).__name__, "-", exc)
print()
print("the run failed            :", True)
print("the first version reported:", repr("success"))
print("so the sweep records      :", "a PASS for a run that crashed")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cleanup_with_return() returns: 'scratch removed'
exception seen by the caller : None

cleanup_without_return():
  scratch removed
  propagated: RuntimeError - the simulator exited 1

the run failed            : True
the first version reported: 'success'
so the sweep records      : a PASS for a run that crashed

The exception is gone. A return inside finally discards whatever was propagating — the caller receives the cleanup's return value and no error at all, and a run that crashed is recorded as having returned normally.

This is a silent, total loss of a failure, and it reaches the results table. Chapter 30.7 Section 3 argued that a report's worst defect is a wrong value presented confidently; this is how one gets there. The same applies to break and continue inside a finally in a loop.

The rule is simple and absolute: a finally block does cleanup and nothing else. No return, no break, no continue. If the cleanup has something to say, log it.

6. An Exception in finally Replaces the Original

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import shutil


def run_with_fragile_cleanup():
    try:
        raise RuntimeError("the simulator exited 1 — THIS is the real failure")
    finally:
        shutil.rmtree("scratch_that_was_never_created")


try:
    run_with_fragile_cleanup()
except Exception as exc:
    print("the caller sees    :", type(exc).__name__)
    print("  message          :", str(exc)[:52])
    print("the real failure is:", "demoted to exc.__context__")
    print("  type             :", type(exc.__context__).__name__)
    print("  message          :", exc.__context__)
    print()
    print("what a log shows by default:", "the cleanup error")
    print("what the engineer needs    :", "the simulator error")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the caller sees    : FileNotFoundError
  message          : [Errno 2] No such file or directory: 'scratch_that_w
the real failure is: demoted to exc.__context__
  type             : RuntimeError
  message          : the simulator exited 1 — THIS is the real failure

what a log shows by default: the cleanup error
what the engineer needs    : the simulator error

The cleanup's failure replaced the crash it was cleaning up after. The FileNotFoundError is what propagates, what gets logged, and what ends up in the results row; the actual simulator failure survives only as __context__, which nothing prints unless you ask.

That is a worse outcome than Section 5 in one respect: at least a swallowed exception is silent. This one is loud about the wrong thing, and it sends whoever reads the report to investigate the cleanup code.

So the cleanup itself has to be unfailing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import shutil
from pathlib import Path


def remove_scratch_safely(run_dir, scratch_names, log):
    """Cleanup must never be the reason a run's error is lost."""
    p = Path(run_dir)
    removed, problems = [], []
    for name in scratch_names:
        child = p / name
        try:
            if child.is_dir():
                shutil.rmtree(child)
                removed.append(name + "/")
            elif child.exists():
                child.unlink()
                removed.append(name)
        except OSError as exc:
            problems.append(f"{name}: {type(exc).__name__}")
    if problems:
        log.append("cleanup could not remove: " + "; ".join(problems))
    return removed


run = Path("run_0002")
(run / "obj").mkdir(parents=True, exist_ok=True)
(run / "obj" / "a.o").write_text("x", encoding="utf-8")
(run / "sim.log").write_text("UVM_ERROR\n", encoding="utf-8")

log = []
caught = None
try:
    try:
        raise RuntimeError("the simulator exited 1")
    finally:
        removed = remove_scratch_safely(
            run, ["obj", "work", "nosuch", "sim.log.tmp"], log)
except RuntimeError as exc:
    caught = str(exc)

print("removed          :", removed)
print("cleanup problems :", log)
print("exception that    ")
print("  reached the caller:", caught)
print()
print("the real failure survived:", caught is not None)
print("the cleanup's trouble is :", "logged, not raised")
print("sim.log still present    :", (run / "sim.log").is_file())
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
removed          : ['obj/']
cleanup problems : []
exception that    
  reached the caller: the simulator exited 1

the real failure survived: True
the cleanup's trouble is : logged, not raised
sim.log still present    : True

The real failure reached the caller. Three absent entries — work, nosuch, sim.log.tmp — were simply skipped rather than raising, so there was nothing for the cleanup to report; and had a removal genuinely failed, it would have been appended to a log rather than thrown.

The pattern generalises past cleanup: code that runs during failure handling must not be able to fail. Chapter 33.10 Section 8 found the logging module making the same decision for the same reason, and swallowing errors there has the same cost — it has to be paid somewhere, and a line in the log is the cheapest place.

7. Evidence or Scratch?

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ARTEFACTS = [
    ("sim.log", "evidence", "no", "the only record of what happened"),
    ("compile.log", "evidence", "no", "a compile error lives only here"),
    ("cmd.sh", "evidence", "no", "regenerating it is the hard part — §8"),
    ("results.json", "evidence", "no", "the run's answer"),
    ("seed.txt", "evidence", "no", "without it nothing reproduces"),
    ("waves.vcd", "evidence*", "yes, slowly", "huge; keep on failure only"),
    ("obj/", "scratch", "yes", "a recompile regenerates it"),
    ("work/", "scratch", "yes", "the simulator rebuilds it"),
    ("csrc/", "scratch", "yes", "generated C"),
    ("*.tmp", "scratch", "yes", "by definition"),
]

print(f"{'artefact':<16} {'class':<11} {'regenerable':<13} why")
for name, cls, regen, why in ARTEFACTS:
    print(f"{name:<16} {cls:<11} {regen:<13} {why}")

ev = sum(1 for _, c, _, _ in ARTEFACTS if c.startswith("evidence"))
print()
print("evidence:", ev, "| scratch:", len(ARTEFACTS) - ev)
print()
print("the test:", "could this be regenerated from what remains?")
print("  no  -> evidence, keep it")
print("  yes -> scratch, delete it")
print("waves are the awkward case:", "regenerable, but only by a rerun")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
artefact         class       regenerable   why
sim.log          evidence    no            the only record of what happened
compile.log      evidence    no            a compile error lives only here
cmd.sh           evidence    no            regenerating it is the hard part — §8
results.json     evidence    no            the run's answer
seed.txt         evidence    no            without it nothing reproduces
waves.vcd        evidence*   yes, slowly   huge; keep on failure only
obj/             scratch     yes           a recompile regenerates it
work/            scratch     yes           the simulator rebuilds it
csrc/            scratch     yes           generated C
*.tmp            scratch     yes           by definition

evidence: 6 | scratch: 4

the test: could this be regenerated from what remains?
  no  -> evidence, keep it
  yes -> scratch, delete it
waves are the awkward case: regenerable, but only by a rerun

Six evidence, four scratch, and one row with an asterisk. Waveforms are technically regenerable — rerun with the same seed and you get them back — so by the test they are scratch, and by volume they must be, because keeping a gigabyte of VCD for every passing run is how a volume fills.

But "rerun with the same seed" assumes the failure is deterministic, and the failures that most need a waveform are exactly the ones that are not. Chapter 33.14 is about those. So waves get the one outcome-dependent rule in the policy: delete on success, keep on failure.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import shutil
from pathlib import Path

SCRATCH_DIRS = {"obj", "work", "csrc"}
EVIDENCE_SUFFIX = {".log", ".sh", ".json", ".txt"}
BULKY_SUFFIX = {".vcd", ".fsdb", ".shm"}


def cleanup(run_dir, failed):
    """Delete scratch always; keep bulky evidence only when it failed."""
    p = Path(run_dir)
    removed, kept = [], []
    for child in sorted(p.iterdir()):
        if child.is_dir():
            if child.name in SCRATCH_DIRS:
                shutil.rmtree(child)
                removed.append(child.name + "/")
            else:
                kept.append(child.name + "/")
        elif child.suffix in EVIDENCE_SUFFIX:
            kept.append(child.name)
        elif child.suffix in BULKY_SUFFIX:
            if failed:
                kept.append(child.name)
            else:
                child.unlink()
                removed.append(child.name)
        else:
            child.unlink()
            removed.append(child.name)
    return removed, kept


def build(d):
    p = Path(d)
    (p / "obj").mkdir(parents=True, exist_ok=True)
    (p / "obj" / "a.o").write_text("x", encoding="utf-8")
    for n in ("sim.log", "cmd.sh", "results.json", "seed.txt",
              "waves.vcd", "core.tmp"):
        (p / n).write_text("x", encoding="utf-8")
    return p


for failed in (False, True):
    d = build(f"run_{'fail' if failed else 'pass'}")
    removed, kept = cleanup(d, failed)
    print(f"--- run {'FAILED' if failed else 'passed'}")
    print("   removed:", removed)
    print("   kept   :", kept)

print()
print("the only outcome-dependent rule:", "bulky waveforms")
print("everything else                :", "classified by what it is")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
--- run passed
   removed: ['core.tmp', 'obj/', 'waves.vcd']
   kept   : ['cmd.sh', 'results.json', 'seed.txt', 'sim.log']
--- run FAILED
   removed: ['core.tmp', 'obj/']
   kept   : ['cmd.sh', 'results.json', 'seed.txt', 'sim.log', 'waves.vcd']

the only outcome-dependent rule: bulky waveforms
everything else                : classified by what it is

The passing run deleted its waveform; the failing run kept it. Everything else is classified by what the file is rather than by how the run ended, which keeps the policy readable: one rule depends on the outcome and nine do not.

8. cmd.sh Is the One That Matters Most

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import shlex
import sys
from pathlib import Path


def write_cmd_sh(run_dir, argv, env_keys, cwd):
    """Everything needed to reproduce this run by hand."""
    p = Path(run_dir) / "cmd.sh"
    lines = ["#!/bin/sh",
             "# written by the flow — reproduces this run exactly",
             f"cd {shlex.quote(str(cwd))}"]
    for k, v in env_keys:
        lines.append(f"export {k}={shlex.quote(v)}")
    lines.append(" ".join(shlex.quote(a) for a in argv))
    p.write_text("\n".join(lines) + "\n", encoding="utf-8")
    p.chmod(0o755)
    return p


Path("run_0003").mkdir(parents=True, exist_ok=True)
cmd = write_cmd_sh(
    "run_0003",
    ["vvp", "sim.vvp", "+seed=4121", "+UVM_TESTNAME=axi_burst_test",
     "+dump=waves.vcd"],
    [("UVM_HOME", "/opt/uvm-1.2"), ("SIM_ARGS", "-q --timeout 3600")],
    Path("run_0003").name)

print(cmd.read_text(encoding="utf-8"), end="")
print()
print("executable          :", oct(cmd.stat().st_mode & 0o777))
print("quoting handled by  :", "shlex.quote — 23.1 §6")
print("why a FILE, not a   ")
print("  log line          :", "it can be run, not just read")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/bin/sh
# written by the flow — reproduces this run exactly
cd run_0003
export UVM_HOME=/opt/uvm-1.2
export SIM_ARGS='-q --timeout 3600'
vvp sim.vvp +seed=4121 +UVM_TESTNAME=axi_burst_test +dump=waves.vcd

executable          : 0o755
quoting handled by  : shlex.quote — 23.1 §6
why a FILE, not a   
  log line          : it can be run, not just read

That file is the difference between a ten-minute debug and a two-hour one. It records the working directory, the environment variables the tool reads, and the exact argument vector — and because shlex.quote did the quoting, SIM_ARGS with its embedded space survives as one value.

Three properties make it worth the eight lines. It is executable, so the next engineer runs it rather than reconstructing it. It is inside the run directory, so it cannot be separated from the log it explains. And it is written before the tool is launched, not after, so it exists even for the run that was killed at hour six.

The annotation names it explicitly — # keep logs, waves and cmd.sh — and of those three, this is the one that is irreplaceable. A log can sometimes be regenerated by rerunning; the command line that produced it cannot be, because it is the input you have lost.

9. The Run, Assembled

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import shlex
import shutil
import signal
import sys
from pathlib import Path

SCRATCH_DIRS = {"obj", "work", "csrc"}
EVIDENCE_SUFFIX = {".log", ".sh", ".json", ".txt"}
BULKY_SUFFIX = {".vcd", ".fsdb"}


class Terminated(Exception):
    """SIGTERM, converted so that every finally block runs — §4."""


def install_term_handler(log):
    def _on_term(signum, frame):
        log.append(f"received signal {signum}; unwinding")
        raise Terminated(f"signal {signum}")
    try:
        signal.signal(signal.SIGTERM, _on_term)
        return True
    except ValueError:
        return False          # not on the main thread


def remove_scratch(run_dir, failed, log):
    """Unfailing. Deletes scratch; keeps evidence; waves only on failure."""
    p = Path(run_dir)
    removed = []
    for child in sorted(p.iterdir()):
        try:
            if child.is_dir():
                if child.name in SCRATCH_DIRS:
                    shutil.rmtree(child)
                    removed.append(child.name + "/")
            elif child.suffix in EVIDENCE_SUFFIX:
                pass
            elif child.suffix in BULKY_SUFFIX:
                if not failed:
                    child.unlink()
                    removed.append(child.name)
            else:
                child.unlink()
                removed.append(child.name)
        except OSError as exc:
            log.append(f"cleanup: {child.name}: {type(exc).__name__}")
    return removed


def one_run(run_dir, argv, should_fail):
    p = Path(run_dir)
    (p / "obj").mkdir(parents=True, exist_ok=True)
    (p / "obj" / "a.o").write_text("x", encoding="utf-8")
    log = []
    install_term_handler(log)

    # cmd.sh FIRST — before anything can go wrong
    (p / "cmd.sh").write_text(
        "#!/bin/sh\n" + " ".join(shlex.quote(a) for a in argv) + "\n",
        encoding="utf-8")

    failed, reason = False, ""
    try:
        (p / "sim.log").write_text("UVM_INFO started\n", encoding="utf-8")
        (p / "waves.vcd").write_text("$date\n", encoding="utf-8")
        (p / "core.tmp").write_text("x", encoding="utf-8")
        if should_fail:
            raise RuntimeError("the simulator exited 1")
        (p / "results.json").write_text('{"status": "PASS"}\n',
                                        encoding="utf-8")
    except (RuntimeError, Terminated) as exc:
        failed, reason = True, f"{type(exc).__name__}: {exc}"
    finally:
        removed = remove_scratch(p, failed, log)

    kept = sorted(c.name + ("/" if c.is_dir() else "") for c in p.iterdir())
    return {"failed": failed, "reason": reason, "removed": removed,
            "kept": kept, "log": log}


for should_fail in (False, True):
    r = one_run(f"run_{'b' if should_fail else 'a'}",
                ["vvp", "sim.vvp", "+seed=4121"], should_fail)
    print(f"--- {'FAILED' if r['failed'] else 'passed'}")
    print("    reason :", r["reason"] or "—")
    print("    removed:", r["removed"])
    print("    kept   :", r["kept"])
    print("    log    :", r["log"] or "—")

print()
print("cmd.sh kept in both cases  :", True)
print("waves kept only on failure :", True)
print("cleanup ran in both cases  :", True)
print("the exception survived     :", "as `reason`, recorded not raised")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
--- passed
    reason : —
    removed: ['core.tmp', 'obj/', 'waves.vcd']
    kept   : ['cmd.sh', 'results.json', 'sim.log']
    log    : —
--- FAILED
    reason : RuntimeError: the simulator exited 1
    removed: ['core.tmp', 'obj/']
    kept   : ['cmd.sh', 'sim.log', 'waves.vcd']
    log    : —

cmd.sh kept in both cases  : True
waves kept only on failure : True
cleanup ran in both cases  : True
the exception survived     : as `reason`, recorded not raised

Two runs, two correct outcomes, and the failing one kept its waveform. The failing run has no results.json — it never got that far — which is the hole in the results table that Chapter 30.3 Section 5 said must be reported as missing rather than inferred.

Note the shape of the error handling. The exception is caught and recorded as reason, not allowed to propagate out of one_run, because a sweep must survive one bad run — and the exit-code decision is made once at the top from the collected results, which is Chapter 33.8's contract and Chapter 33.11 Section 7's collect-then-decide applied to runs instead of arguments.

10. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import shutil
import subprocess
import sys
from pathlib import Path

Path("f.py").write_text(
    'import os\n'
    'import signal\n'
    'import sys\n'
    'mode = sys.argv[1]\n'
    'try:\n'
    '    if mode == "exit":    sys.exit(4)\n'
    '    if mode == "osexit":  os._exit(4)\n'
    '    if mode == "sigterm": os.kill(os.getpid(), signal.SIGTERM)\n'
    'finally:\n'
    '    print("CLEANED", flush=True)\n', encoding="utf-8")


def probe(mode):
    r = subprocess.run([sys.executable, "f.py", mode], stdout=subprocess.PIPE,
                       stderr=subprocess.DEVNULL, text=True)
    return r.returncode, "CLEANED" in r.stdout


# A
print("A:", probe("exit"))

# B
print("B:", probe("osexit"))

# C
print("C:", probe("sigterm"))


# D
def d():
    try:
        raise RuntimeError("x")
    finally:
        return "cleaned"


print("D:", d())

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: (4, True)
B: (4, False)
C: (-15, False)
D: cleaned

A — sys.exit(4) raises SystemExit, so the cleanup runs and the code is still 4.

B — os._exit(4) produces the same exit code with no cleanup. The two are indistinguishable from outside and completely different inside.

C — SIGTERM: no cleanup, and returncode is negative.

D — the return in finally, and the RuntimeError is simply gone. No output about it, no traceback, and a caller that believes the run succeeded.

A and B are the pair worth remembering: the exit code tells you nothing about whether cleanup happened.

11. Exercises

Exercise 1 — three endings. For your own flow, write down what the run directory holds after each.

Exercise 2 — the conflict. Explain why a finished run and a failed run want opposite things from cleanup.

Exercise 3 — the annotation's comment. Say what a reader would assume remove_scratch(run_dir) does without it.

Exercise 4 — eight endings. Reproduce Section 3's table on your own machine.

Exercise 5 — sys.exit cleans up. Confirm it, and say why that is not obvious from the name.

Exercise 6 — os._exit. Explain the one situation it is for.

Exercise 7 — the asymmetry. Say why SIGINT runs finally and SIGTERM does not.

Exercise 8 — what you test by hand. Explain why interactive testing gives the wrong answer about cleanup.

Exercise 9 — negative return codes. Reconcile -15 from subprocess with 143 from a shell.

Exercise 10 — the handler. Install a SIGTERM handler that raises and confirm cleanup runs.

Exercise 11 — the cost of 130. Say what distinction you lose, and log the signal to recover it.

Exercise 12 — what it cannot fix. Name two things the handler does not help with.

Exercise 13 — return in finally. Reproduce the swallowed exception.

Exercise 14 — the results row. Say what the sweep records for that run.

Exercise 15 — the absolute rule. State it, and check your own finally blocks against it.

Exercise 16 — a raising cleanup. Reproduce the replaced exception and find the original in __context__.

Exercise 17 — worse than silent. Explain why this is worse than Exercise 13 in one respect and better in another.

Exercise 18 — unfailing cleanup. Rewrite one of your cleanups so it cannot raise.

Exercise 19 — log, do not raise. Say where the cleanup's own problems should go.

Exercise 20 — classify your artefacts. Apply the regenerable test to everything your flow writes.

Exercise 21 — the asterisk. Decide your own policy for waveforms and justify it by volume.

Exercise 22 — one outcome-dependent rule. Explain why keeping the number of those rules at one matters.

Exercise 23 — write cmd.sh. Add it to your flow, with shlex.quote.

Exercise 24 — before, not after. Explain why it must be written before the tool launches.

Exercise 25 — the embedded space. Confirm an argument containing a space survives a round trip.

Exercise 26 — irreplaceable. Say why cmd.sh is harder to regenerate than the log.

Exercise 27 — the missing result. Confirm your failing runs leave no results.json, and that the report says missing.

Exercise 28 — survive one bad run. Explain why one_run catches rather than propagates.

Exercise 29 — decide once. Say where the sweep's exit code is chosen.

Exercise 30 — A and B. Explain why the exit code says nothing about whether cleanup ran.

12. Summary

  • A run has three endings, and the third — killed from outside — is the one that orphans scratch and leaves a hole in the results.
  • Finished and failed runs want opposite cleanups, which is why the policy classifies files rather than outcomes.
  • finally runs on four of seven measured endings. Normal return, an uncaught exception, sys.exit, and SIGINT: yes. os._exit, SIGTERM, SIGKILL: no.
  • sys.exit cleans up because SystemExit is an exception; os._exit produces the same exit code with no unwinding, no atexit and no flush.
  • SIGINT runs cleanup and SIGTERM does not, because Python handles the first and the OS handles the second.
  • SIGTERM is what a scheduler, a timeout, or a plain kill sends — so interactive testing with Ctrl-C verifies the opposite of production behaviour.
  • A three-line SIGTERM handler that raises re-enables every finally, at the cost of exiting 130 — log the signal, or a killed run is indistinguishable from a cancelled one.
  • SIGKILL cannot be handled, and a handler still leaves partially written files — write-to-temp-then-rename matters more than cleanup.
  • A return in finally swallows the exception entirely. The caller sees the cleanup's value and no error, and a crashed run is recorded as a pass.
  • An exception raised in finally replaces the original, which survives only as __context__ — so a failing cleanup hides the crash it was cleaning up after, loudly.
  • Code that runs during failure handling must not be able to fail. Log the cleanup's own problems; never raise them.
  • The classification test is "could this be regenerated from what remains?" No means evidence; yes means scratch.
  • Waveforms are the one outcome-dependent rule — delete on success, keep on failure — because the failures that need them most are the non-deterministic ones.
  • cmd.sh is the irreplaceable artefact. Executable, inside the run directory, written before the tool launches, quoted with shlex.quote.
  • A sweep catches a run's exception rather than propagating it, and decides the exit code once from the collected results.
  • The exit code tells you nothing about whether cleanup ran. sys.exit(4) and os._exit(4) are identical from outside.

The next chapter is about the failure that does not reproduce — where everything in this chapter's run directory is the only thing you have.

Where this fits

Part of the Python curriculum.