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:
finallyruns on every way out: falling off the end,return,break,continue, a caught exception, an uncaught one.- If the
finallyitself exits some other way, that wins, replacing whatever the body or handler was doing, including a pendingreturnand 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
exceptbody replaces the handler with one covering just that body, so an exception raised inside the handler still runs thefinallyon its way out but is not caught by the sameexcepta second time. When thetryhas nofinallythis replacement has nothing to do; it exists only so the compiler's matchingPopHandlerhas something to pop, and the unwinder treats it as a pass-through. - While a
finallyis being inlined into an exit, its owntryis off the compiler's stack. Areturnwritten inside afinallyis leaving the enclosingtrystatements, 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
pendingslot. Everything else is ordinary jumps, which the disassembler shows and a reader can follow. - A
finallybody is compiled once per static exit. Atrywith a body that falls through, areturn, and abreakgets 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 andjavacalso make. - Nested
trycomposes without extra machinery:returnwalks outward through every enclosingtryin the function, emitting each one'sfinallyin turn, innermost first. The handler stack does the same for exceptions. - A
finallythat 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
finallyand 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
finallyonce, jump in, return to the caller. Smallest bytecode. It is alsojsr/ret, whose problems are well documented and were serious enough that the JVM removed it. - One
pendingregister holding the interrupted action, for every exit. This was the original plan for this milestone. It movesreturn,break, andcontinueinto runtime state thatEndFinallymust 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
finallyfrom a RustDropguard. Ties Korrin's control flow to the host's unwinding, which is precisely the coupling ADR 0004 avoided by makingSignala value in the first place.