Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

25. finally is inlined at every exit the compiler can see

  • Status: Accepted
  • Date: 2026-09-04

Context

ADR 0017 settled what try / except / finally mean, and the tree-walking interpreter implements those meanings directly: it runs the body, catches what comes back, and runs the finally on the way out whatever happened. Its exact contract, which the VM has to reproduce:

  • finally runs on every way out: falling off the end, return, break, continue, a caught exception, an uncaught one.
  • If the finally itself exits some other way, that wins, replacing whatever the body or handler was doing, including a pending return and an exception still propagating.

Reproducing that in bytecode is the hardest part of the milestone, because a return in the middle of a try is no longer just an instruction. It has to run an arbitrary block first, and if that block returns, the original return must never happen.

The classic answer is a subroutine: compile finally once, jump into it, and have it jump back to whoever called it. The JVM did this with jsr/ret and spent years on the verification problems it caused, because "jump back to wherever you came from" is exactly the construct that makes control flow unanalysable. Modern compilers stopped doing it.

Decision

Split by whether the compiler can see the exit.

Statically visible exits get the finally inlined. Falling off the end of the body, falling off the end of a handler, and any return / break / continue written lexically inside them are all points the compiler is standing on when it emits them. Each gets its own copy of the compiled finally, placed immediately before the real instruction.

This needs no runtime bookkeeping at all, and it makes "the finally wins" fall out for free rather than being a rule to enforce: if the inlined copy contains a return, that return simply executes, and the exit it interrupted is never reached. There is no pending-action register to consult and get wrong.

Exceptions arriving from elsewhere get a runtime handler. An exception raised in a nested call, or by a faulting operation, cannot be routed at compile time. Op::PushHandler records where the except and finally bodies are; the unwinder walks the handler stack, discards the frames the exception escaped, restores the operand stack, and jumps to the except body, or to the finally with the exception held in a single pending register that Op::EndFinally resumes.

Two details that are easy to get wrong and are therefore written down:

  • Entering an except body replaces the handler with one covering just that body, so an exception raised inside the handler still runs the finally on its way out but is not caught by the same except a second time. When the try has no finally this replacement has nothing to do; it exists only so the compiler's matching PopHandler has something to pop, and the unwinder treats it as a pass-through.
  • While a finally is being inlined into an exit, its own try is off the compiler's stack. A return written inside a finally is leaving the enclosing try statements, but it is not leaving that one again, and compiling it as though it were inlines the block into itself without end.

Consequences

  • The runtime carries one handler stack and one pending slot. Everything else is ordinary jumps, which the disassembler shows and a reader can follow.
  • A finally body is compiled once per static exit. A try with a body that falls through, a return, and a break gets three copies. Bodies are small and exits are few, so this is bytecode size traded for having no runtime mechanism, and it is the trade CPython and javac also make.
  • Nested try composes without extra machinery: return walks outward through every enclosing try in the function, emitting each one's finally in turn, innermost first. The handler stack does the same for exceptions.
  • A finally that swallows an exception is silent, exactly as ADR 0017 says it should be. The mechanism does not make that easier to notice.
  • The order in which a finally and the exit it interrupted appear in the bytecode is the reverse of how they read in source. The disassembler is the tool for that, and it exists partly for this reason.

Alternatives considered

  • A subroutine: compile the finally once, jump in, return to the caller. Smallest bytecode. It is also jsr/ret, whose problems are well documented and were serious enough that the JVM removed it.
  • One pending register holding the interrupted action, for every exit. This was the original plan for this milestone. It moves return, break, and continue into runtime state that EndFinally must dispatch on, which means four ways for the resume logic to be subtly wrong instead of zero. Inlining eliminates three of the four cases outright.
  • Run finally from a Rust Drop guard. Ties Korrin's control flow to the host's unwinding, which is precisely the coupling ADR 0004 avoided by making Signal a value in the first place.