Exceptions, Errors and Stack Unwinding

When something goes wrong deep inside a chain of function calls, the runtime needs to stop what it is doing, walk back up the chain until it finds code that said "I can handle this", run any clean-up code on the way, and continue from the handler — or, if n…

Learning Objectives

Classify compile-time, syntax, name-resolution, type and runtime errors; represent errors as values or exception objects.

Explain throwing and catching, exception tables, handler search, stack unwinding, finally, destructors/RAII, suppressed exceptions and fatal errors.

Produce stack traces with source mapping; propagate errors through interpreted calls and across native boundaries; compare panics and recovery; weigh error-handling designs and test exceptional paths.

Plain-Language Explanation

When something goes wrong deep inside a chain of function calls, the runtime needs to stop what it is doing, walk back up the chain until it finds code that said "I can handle this", run any clean-up code on the way, and continue from the handler — or, if nobody handles it, report where it happened and stop.

Precise Technical Explanation

Compile-time errors versus runtime errors Compile-time errors are found before execution (syntax, static semantics, static types); runtime errors happen during execution (division by zero, null dereference, failed casts, out-of-bounds access).

Reported by the parser with a position; nothing runs (in Python, a module with a syntax error does not execute at all).

Undefined names: static in Java and C#; at run time in Python (NameError), JavaScript (ReferenceError) and MiniLang (late-bound globals).

Static (compile errors in typed languages) or dynamic (TypeError in Python and JavaScript; MiniLang's operator checks).

Language-level signals of exceptional conditions: ArithmeticException, ZeroDivisionError, RangeError.

Error values and error objects Some languages return errors as values (Go's error, Rust's Result); exception-based languages use objects carrying a type, message, stack trace and often a cause chain.

Throwing and catching exceptions throw/raise transfers control to the nearest enclosing handler whose type matches; catch/except handles it.

Compiled code records protected ranges in a table — (start offset, end offset, handler offset, exception type) — instead of emitting checks on the normal path ("zero-cost" exceptions). The JVM, CLR and CPython ≥ 3.11 use exception tables.

On a throw, the runtime looks up the current instruction offset in the current function's table; if no entry matches, it pops the frame and searches the caller at its call site, and so on.

Popping frames during handler search, releasing their locals and running cleanup code.

Cleanup/finally semantics finally blocks run whether the protected code completes normally, returns or throws; compilers duplicate them on each exit path or route all exits through a shared handler.

Destructors and RAII interactions In C++ unwinding runs destructors of stack objects (RAII); throwing from a destructor during unwinding calls std::terminate. Managed runtimes use finally, using/try-with-resources/with instead.

If cleanup throws while another exception is propagating, one is lost unless recorded: Java's try-with-resources adds the cleanup exception as *suppressed*; Python chains them (__context__).

Fatal runtime errors Conditions the program cannot recover from: out of memory in some runtimes, stack overflow in some (the CLR terminates on stack overflow), internal VM errors, failed assertions in release mode.

Stack traces and source mapping Built from frames and line tables at the time of the throw (Java fills the trace at construction; MiniLang snapshots frames when the error is raised).

Error propagation through interpreted calls The interpreter's own host-language stack unwinds in step with the language's frames: MiniLang's tree-walker uses host exceptions (RuntimeError), and its VM catches errors in the dispatch loop.

Exceptions crossing native boundaries Language exceptions must not unwind through native frames that do not expect them: JNI requires checking ExceptionCheck after calls;.NET marshals exceptions at P/Invoke boundaries; C callbacks must catch everything.

Panic and recovery concepts Go's panic/recover and Rust's panic (unwinding or aborting) are for bugs, not ordinary errors, which are values.

Error-handling design trade-offs Exceptions keep the normal path clean but hide control flow; error values are explicit but verbose; checked exceptions (Java) document failure but burden APIs.

Testing exceptional paths Test that errors are raised with the right type and message, that cleanup runs, that resources are released and that state stays consistent after a failure.

Java example (Temurin JDK 21):

Exact exception classes and behaviour are language-specific: Java integer division by zero throws ArithmeticException, but Java floating-point division by zero yields Infinity, JavaScript yields Infinity, and MiniLang defines a runtime error. Not all languages use exceptions for every runtime error.

public class DivideByZero {
    public static void main(String[] args) {
        try {
            int result = 10 / 0;
            System.out.println(result);
        } catch (ArithmeticException ex) {
            System.out.println("Cannot divide by zero");
        }
    }
}
Cannot divide by zero

Why It Matters in Real Systems

Correct unwinding and cleanup prevent leaked locks, files and connections; good traces shorten every investigation; exception performance matters when exceptions are used for control flow.

Key Terms

(start, end, handler, type) entries per function Popping frames while searching for a handler Cleanup that runs on every exit path

Secondary exception attached to the primary one Error the runtime does not let the program handle

Diagram, Execution Trace or State Transition

Text equivalent: the runtime checks the throwing frame's exception table, then pops frames one at a time (running cleanup) and checks each caller at its call site, until a handler matches or the stack is empty and the error is reported as uncaught.

flowchart TB
  T[throw in frame C] --> L{handler in C's exception table?}
  L -- yes --> H[jump to handler in C]
  L -- no --> P[run C's cleanup, pop C] --> L2{handler in B at its call site?}
  L2 -- yes --> H2[jump to handler in B]
  L2 -- no --> P2[pop B ... until the entry point] --> U[uncaught: print trace, exit non-zero]

Syntax or Instruction Format

Java try { … } catch (T e) { … } finally { … }; Python try/except/else/finally; C# try/catch/finally; JavaScript try/catch/finally; in bytecode, exception tables (shown below).

Complete Example

The JVM's exception table for a try/catch/finally (Temurin JDK 21 javac + javap -c; offsets depend on the compiler version):

Python: unwinding through frames, finally running on every path, and a chained exception (CPython 3.13.1):

MiniLang has no try/catch; an uncaught runtime error unwinds every frame and is reported with a trace (both back ends):

public class Handlers {
    static int parse(String s) {
        try {
            return Integer.parseInt(s);
        } catch (NumberFormatException e) {
            return -1;
        } finally {
            System.out.println("cleanup for " + s);
        }
    }
}
Compiled from "Handlers.java"
public class Handlers {
  public Handlers();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  static int parse(java.lang.String);
    Code:
       0: aload_0
       1: invokestatic  #7                  // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I
       4: istore_1
       5: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
       8: aload_0
       9: invokedynamic #19,  0             // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
      14: invokevirtual #23                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      17: iload_1
      18: ireturn
      19: astore_1
      20: iconst_m1
      21: istore_2
      22: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
      25: aload_0
      26: invokedynamic #19,  0             // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
      31: invokevirtual #23                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      34: iload_2
     
…
import traceback

log = []


def level3(x):
    try:
        return 10 / x
    finally:
        log.append("level3 finally")


def level2(x):
    return level3(x) + 1


def level1(x):
    try:
        return level2(x)
    except ZeroDivisionError as e:
        log.append("level1 caught " + type(e).__name__)
        return None


print(level1(5), log)
log.clear()
print(level1(0), log)

try:
    try:
        {}["missing"]
    except KeyError:
        int("not a number")              # raised while handling the KeyError
except ValueError as e:
    print("ValueError, context:", type(e.__context__).__name__)

try:
    level3(0)
except ZeroDivisionError:
    frames = traceback.extract_tb(__import__("sys").exc_info()[2])
    print("trace:", [f.name for f in frames])
3.0 ['level3 finally']
None ['level3 finally', 'level1 caught ZeroDivisionError']
ValueError, context: KeyError
trace: ['<module>', 'level3']
fun parse(n) { if (n < 0) return n / 0; return n; }
fun handle(n) { return parse(n) + 1; }
print(handle(4));
print(handle(-1));
print("never printed");
5
Runtime error [line 1:34]: Division by zero.
  in parse() [line 1]
  in handle() [line 2]
  in script [line 4]

Line-by-Line Explanation

javap: the Exception table lists protected ranges and handler targets; any entries implement finally for every path, including exceptions thrown inside the catch block. The finally body appears several times in the bytecode — once per exit path.

Python: finally in level3 runs both when returning normally and when the division fails; the exception unwinds level3 and level2 and is caught in level1. __context__ records the KeyError that was being handled when the ValueError happened.

MiniLang: the first call succeeds; the second raises in parse, unwinds handle and the script, and stops the program.

Expected Output

As shown; javap offsets are specific to this javac.

Step-by-Step Runtime Behavior

Python, level1(0): level1 → level2 → level3 raises → level3's finally runs → frame popped → level2 has no handler → popped → level1's except matches → returns None.

Common Misconceptions

"Exceptions are slow, always." Zero-cost tables make the non-throwing path free; throwing (and building stack traces) is what costs.

"finally runs if the process is killed." Not on kill -9, power loss or os._exit.

"All runtime errors are exceptions." Some are fatal (CLR stack overflow), some are values (Go), some produce special values (NaN).

Common Bugs and Failure Modes

Swallowing exceptions (except: pass), catching too broadly, losing the original exception when cleanup fails, returning from finally (which discards the exception), leaking resources on error paths, letting exceptions cross native boundaries.

Corrected or Safer Patterns

Catch specific types, re-raise with context (raise … from e), use with/using/try-with-resources, never return from finally, test the error paths.

Debugging and Inspection Techniques

Full traces with chained causes, "break on exception" in debuggers, javap -c to see handlers, logging exception type, message and trace together.

Performance Considerations

Avoid exceptions for ordinary control flow in hot paths; constructing stack traces is expensive (Java allows disabling them for specific exceptions).

Security Considerations

Do not leak stack traces or internal messages to untrusted users; log them server-side. Ensure error paths release locks and do not leave security state half-updated.

Portability and Version Caveats

CPython switched to zero-cost exception tables in 3.11; exception class hierarchies and messages differ by language and version.

Real-World Use Cases

Request handlers mapping exceptions to HTTP errors, transaction rollback on failure, retry logic, crash reporting.

Best Practices

Handle errors where you can act on them, clean up deterministically, preserve causes, test failure paths.

Exercises

E1 · Beginner. In the Java example, why is "Cannot divide by zero" printed and result never printed?

Answer: 10 / 0 throws ArithmeticException before println(result) runs; control jumps to the matching catch block.

E2 · Intermediate. What does def f(): try: return 1 finally: return 2 return in Python, and why is it a bad pattern?

Answer: 2 — the return in finally overrides the earlier return (and would also silently discard an exception). It hides control flow and errors.

E3 · Advanced challenge. Design try/catch for MiniLang's VM.

Answer: Add PUSH_HANDLER offset and POP_HANDLER instructions (or a per-chunk exception table). Each frame keeps a handler stack with (handler ip, stack depth). On a runtime error, search the current frame's handlers; if found, truncate the stack to the saved depth, push the error value and jump; otherwise close upvalues, pop the frame and repeat in the caller; if no frame handles it, report uncaught. Teach the verifier the handler's entry stack depth.

Quiz

? What does an exception table entry contain?
+ A protected instruction range, a handler offset and the exception type caught. :: Searched when an exception is thrown.
- The text of the error message. :: Messages live in exception objects.
- A list of all exceptions ever thrown. :: No.
- The program's stack trace. :: Traces are built at throw time.

? When does a `finally` block NOT run?
+ When the process is killed abruptly (e.g. os._exit or kill -9). :: Normal returns and exceptions do run it.
- When the try block returns normally. :: It runs.
- When an exception is caught. :: It runs.
- When an exception propagates. :: It runs.

? Which are good error-handling practices? (select all that apply)
+ Catch specific exception types :: Avoid hiding bugs.
+ Preserve the original cause when re-raising :: Chaining keeps context.
- Return from finally to simplify code :: It discards exceptions.
+ Release resources with with/using/try-with-resources :: Deterministic cleanup.

Summary and Key Takeaways

Errors are found at compile time or run time; runtime errors propagate by unwinding frames via exception tables, running cleanup, until a handler matches or the program reports an uncaught error with a trace. Clean up deterministically, preserve causes and never let exceptions escape across native boundaries.

Further Reading

*The Java Virtual Machine Specification*, §3.12 "Throwing and Handling Exceptions".

CPython, "Exception handling in CPython" (InternalDocs/exception_handling.md).

Itanium C++ ABI: Exception Handling.