Skip to content

Catch the exception a program raised - #101

Merged
tamnd merged 1 commit into
mainfrom
try-except
Aug 29, 2026
Merged

Catch the exception a program raised#101
tamnd merged 1 commit into
mainfrom
try-except

Conversation

@tamnd

@tamnd tamnd commented Aug 29, 2026

Copy link
Copy Markdown
Owner

try with all four of its clauses: except, except E as e, except (A, B), a bare except, else and finally, nested inside each other and inside loops and functions. This is the second half of the exception work; #100 was raise.

What it does

A clause catches its class and everything under it, so except ArithmeticError catches a ZeroDivisionError, and an exception nothing matched carries on out to whatever is outside the statement. except Exception does not catch a KeyboardInterrupt or a SystemExit, which is the hierarchy from the last PR finally being read by something. A clause naming something that is not an exception class raises TypeError: catching classes that do not inherit from BaseException is not allowed, and a tuple inside a tuple is that same mistake, verified against 3.14 rather than remembered.

What a handler binds is the object that was raised, so raise e and the except ... as e catching it are the same object. One that never was an object grows one when it is caught, since a division by zero raises out of Rust and except ZeroDivisionError as e still has to bind something. as takes the name away again at the end of the clause, and the cleanup is itself a finally so it happens even when the handler raises.

Three decisions worth reviewing

The except chain is desugared in the lowerer rather than carried as a handler list into the HIR. An except clause is a test and a body and the HIR already has a statement for that. The chain ends in a raise of the slot, so the try order and the fall-through are visible in kohebi hir output instead of living in a rule only the interpreter knows.

finally is compiled twice, once for the way out that worked and once for the way out that did not. That is what CPython does and it is shorter than teaching the interpreter to remember what it was in the middle of. break, continue and return inside a try cannot just jump: they pop their handler entries and replay every finally they are leaving. A return settles its value first, so try: return x finally: x = 99 returns what x was, and a return written inside a finally replays the ones outside it and not itself.

The VM catches in one place. A frame keeps a handler stack; every instruction still returns its failure rather than jumping, and the loop around them pops a handler, materialises the exception into its register and jumps. A frame with nothing on its stack hands the exception to its caller, so catching what a call three frames down raised needs no extra machinery.

Deliberately not here

__context__, so an exception raised while handling another prints on its own rather than under CPython's During handling of the above exception, another exception occurred. And a bare raise is resolved where it is written rather than where it runs, so except: raise works but a bare raise in a function called from a handler still says No active exception to reraise. Both are the same follow-up and both are written down in the module docs where they are missing.

Checking

21 new interpreter and lowering tests, 4 bytecode layout tests, 3 core unit tests and 2 CLI tests. 55 differential programs run against a live CPython 3.14.7 match on output and exit code, apart from str not being a builtin yet and the __context__ preamble above.

`try` with all four clauses: `except`, `except E as e`, `except (A, B)`,
a bare `except`, `else` and `finally`, nested inside each other and
inside loops and functions.

The `except` clauses are desugared in the lowerer into a chain of ifs
over one slot, ending in a `raise` of that slot so an exception nothing
matched carries on out. That puts the order the clauses are tried in,
and the fall-through, in the printed HIR rather than in a rule only the
interpreter knows.

`finally` is compiled twice, once for the way out that worked and once
for the way out that did not, which is what CPython does and which means
the interpreter never has to remember an interrupted action. A `return`,
a `break` or a `continue` leaving a `try` pops its handler entries and
replays every `finally` it is leaving, and a `return` settles its value
first so `try: return x finally: x = 99` returns what `x` was.

The interpreter grew a handler stack per frame and three instructions.
Every other instruction still returns its failure rather than jumping,
and the loop around them is the single place that catches one.

`__context__` and a fully dynamic bare `raise` are a follow-up, and both
are written down where they are missing.
@tamnd
tamnd merged commit 0e653c0 into main Aug 29, 2026
9 checks passed
@tamnd
tamnd deleted the try-except branch August 29, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant