Cannot properly unwind stack as a second phase #122
Description
First, before going any further, I want to clarify that this issue is not about extending the current proposal with two-phase exception handling; rather, the issue is about the potential for such an extension.
Motivation for Two-Phase Exception Handling
Exception handling is often implemented in two phases:
- Inspect the stack to figure out what to do with the exception.
- If appropriate, unwind a portion of the stack that has been discarded (i.e. that control has left).
Many languages are designed to support both single-phase stack unwinding (in which the stack is unwound while inspecting the stack) and two-phase stack unwinding. But many tools and languages specifically require two-phase stack unwinding, such as interactive debuggers wanting the stack to be in tact when intercepting an uncaught exception, and such as C# when
clauses requiring (stateful) user-specified exception-filtering code to be run before unwinding code like C++ destructors and C# finally
blocks is executed. Furthermore, due to WebAssembly's general-purpose goal, many languages will likely need at least custom filtering/delegating wasm code to be run just to find out if a given handler is actually intended for a given exception. Thus it seems likely that WebAssembly will eventually need to support two-phase exception handling.
Designing for Two-Phase Exception Handling
In general, this first-phase code will need to run arbitrary instructions and inspect the exception at hand in order to decide to do one of the following:
- Continue the search for a suitable handler.
- Resume the exception without unwinding the stack. That is, provide the thrower of the exception with information it needs to continue executing.
- Delegate control to an appropriate handler (e.g. the label for one of the many surface-level
catch
clauses associated with a given surface-leveltry
block) after unwinding the stack.
Note in particular that the second phase—stack unwinding—only happens in this third case and it has a destination: where on the stack to unwinding up to and where in the code to delegate control to after stack unwinding is finished.
Incompatibility with Two-Phase Exception Handling
Now consider the current proposal and what it would require to extend it with a design for two-phase exception handling. The first phase would have to skip the current catch
blocks in order to avoid executing unwinding code associated with them. That means that the second phase, if it happens, would need to somehow execute this unwinding code. The issue is that the unwinding code in catch
blocks is not clearly delimited, so there's no easy way for a second phase to know when unwinding associated with a particular catch
is done and it can move on to unwinding further up the stack (or to transferring control to the target destination). One might think that the end
of the catch
block is clearly such a static delimiter, but realize that it's perfectly valid for a catch
block to simply br
to some label expecting an exnref
. In reality, the delimiter is determined dynamically when the exnref
that was caught by the catch
is passed to rethrow
. That's why I'm being careful to use the phrase "unwinding code associated with" rather than "unwinding code within".
Due to this dynamic determination of the end of unwinding code via rethrow
, the second phase's only option for interoperating with the current catch
blocks seems to be to give a catch
that needs to be unwound a special exnref
that specifies how to continue the second phase once the exnref
is rethrown.
One issue this raises is that during the first phase one would like to keep track of the unwinders on the stack that were encountered while searching for a suitable handler in order to quickly iterate through them in the second phase, but because an exnref
can escape the scope of the catch
block this list of unwinders can be invalidated, meaning one has to search for the next unwinder each time the special exnref
is rethrown.
Another issue is that the special exnref
needs to store the information for continuing the second phase once it's rethrown. In particular, it needs to store the destination of the second phase, say as a combination of a stack-frame pointer (how far up the stack to unwind to) and a code pointer (the address of the code of the determined handler). But this special exnref
is a first-class value. It can be passed to and rethrown from another thread, or it can be passed to and rethrown from another stack that may or may not be (temporarily) fused with the one the exnref
points into. How do we specify the semantics of these unintended interactions of features? Worse yet, the exnref
can outlive the validity of the stack frame it points to. Even worse, that stack frame can be replaced with a new stack frame so that it appears valid, but that new stack frame might not be associated with the code pointer in the exnref
, making it unsound to redirect to.
Conclusion
Based on my analysis above, the current design seems to make any extension of itself to two-phase exception handling at the least less efficient, likely excessively complicated (at least to formalize and reason about), and possibly unsound or intractable. The primary cause of these problems seems to be the dynamic, rather than static, delimiting of unwinding code, and in particular dynamically delimiting via a value that can escape the stack being unwound or can outlive the validity of the unwinding destination.