Reconsider original extensible design #118
Description
I understand that it's frustrating to change to another design and then be advised to change back. But the discussion that led to that decision did not consider a number of important points, nor did it consider modifications to the original design that would have addressed its hypothetical weaknesses. Unfortunately the points that were not considered are the ones that most affect long-term possibilities for WebAssembly, and that leaves us with no real path for improvement/extension to accommodate more tooling or languages. As such, I encourage us to reconsider the original design strategy, albeit with some modifications, which does have a clear path for improvement/extension. The following is a summary of the issues that have been identified, a concrete suggestion based on these discussions, and an illustration of how that suggestion is extensible in ways the current proposal is not.
exnref
has no exception-specific use
Firstly, I get the impression that many impose more purpose on exnref
than it really has, and part of its appeal in the first place was people imagining it to be more than it is. So I want to start by tackling what exnref
really is. To simplify this discussion, let's suppose anyref
is back.
Suppose there were some way to declare "tags", like tag $mytag : [i32 externref]
, and we had operations new_tagged $tag : [t*] -> [anyref]
(where tag $tag : [t*]
), and extract_tagged $tag : [anyref] -> [t*]
or br_on_tagged $tag $l : [anyref] -> [anyref]
(where $l
is a label of type [t*]
). This is something that could be generally useful outside of exception handling. The point is that it gives us a way to create, test for, and branch on tagged references, i.e. open case types.
Then throw
could take an arbitrary anyref
, and catch
could catch an arbitrary anyref
, and rethrow
has no purpose and could be removed. br_on_exn
would simply be br_on_tagged
.
This transformation loses no exception-handling functionality, which demonstrates that the current proposal has an extremely simple notion of exception handling, and that exnref
has no real functionality specific to exception handling that a general-purpose open case type couldn't provide.
Stack traces
The counterargument I expect to hear is that exnref
implicitly has a stack trace. But this stack trace is not visible within WebAssembly; it is not visible in the JS API; and it is not visible in C++. It's only visible as debug information. But we could recover that stack trace without exnref
if we left the stack in tact when an exception is uncaught.
In some discussions, JavaScript's exception-handling design has been lamented for not associating a stack trace with the exception, which was one of the rationale's behind the design of exnref
. But not having a stack trace with all throwable values is really not the problem with JS's exception handling. The problem is that JS's design makes it so hard to tell when an exception will not be caught that in the common case most of the stack has been unwound by the time the exception reaches the root of control. If not for that, a browser could print out the stack trace from typically its original throw after determining the exception was uncaught. The current proposal recreates that exact problem because it is essentially the same exception-handling design as JS. So the hidden stack traces of exnref
are just a bandage over a much bigger problem with both JS and the current proposal.
Filtering exceptions
This issue of having unwound the stack by the time an exception is caught is much less prevalent in other languages because they have filtering mechanisms for determining whether a given catcher is really meant for a given exception. Unfortunately, it is very difficult to do this filtering in a general-purpose manner for WebAssembly without explicitly requiring two-phase exception handling (in which the search for the handler is done in one pass, potentially followed by unwinding of the stack in a second pass). Making throw
and catch
specify a tag (or event) does some filtering, but it is far too course-grained to be useful for most languages (including C++, due to subtyping). (Using tags is still helpful for determining that modules are throwing/catching completely unrelated exceptions, e.g. because they are compiled from different languages, and because tags make it so that we do not need anyref
or memory management to do exception handling.)
A nice quality of the current design is that it's compatible with single-phase exception handling, which might be useful for simpler engines. But, unlike the original design before exnref
, the current proposal produces code that is incompatible with two-phase exception handling due to bundling unwinding code with searching and catching code (JS, on the other hand, at least has finally
clauses to separate these concerns).
So although it's reasonable, and in fact useful, to not have filtering in the MVP, it's concerning that it's already been established that an entirely new exception-handling design will be necessary to support more features like exception filtering and resumable exceptions.
Unwinding
It seems the main reason that the exnref
design was chosen over the original was to avoid hypothetical duplication of destructor code (though no one noted that it doesn't actually do this in the common case where only one type of exception is caught). But this can be resolved by having a try
/unwind
instruction that executes the body of unwind
when an exception is thrown from within the try
block. The unwind
block always has type [] -> []
, meaning it does not get to examine the exception and by default returns control to the "unwinder" at the end. (Because of this, try
/unwind
actually doesn't need any type annotation, unlike try
/catch
.)
In addition to reducing code-duplication of destructors, try
/unwind
has the advantage of separating unwinding code from catching code, making it possible to add features for filtering and resuming exceptions without having to come up with an entire new exception-handling design. It also can be used to actually reduce code-duplication of destructors, because we could add (though probably not in the MVP) instructions like return_unwinding
that would execute all unwind
blocks the statement is nested within before returning from the function.
Rethrowing
There seems to be a misconception about rethrow
. This instruction does not correspond to rethrowing in surface languages. In C++, try {...} catch (int x) {throw;}
is semantically equivalent to try {...} catch (int x) {throw x;}
(though this is not true for all exception types, though for reasons related to copy constructors rather than to rethrow
). Python has a specific stack-trace semantics for rethrowing that is not served by rethrow
. A rethrown exception in C# captures a new stack trace (actually, the stack trace is often collected during unwinding if it is needed at all).
The main purpose of rethrow
is to propagate exceptions that the catcher does not understand after executing unwinding code. This value is obviated by making catchers only catch exceptions they understand (via tags) and by separating out unwinding code that does not care about the specific exception at all. In other words, rethrow
only solves problems that the current proposal creates.
Putting it all together
Since I understand it helps to have concrete suggestions, especially given the late phase, here's what I would do to make exception-handling still simple and compatible with single-phase exception handling but also forwards-compatible with two-phase exception handling and the various exception-handling extensions that require that (as well as interactive debugging).
- Rename events to tags (or something of the like) so that we have declarations like
tag $tag : [t*]
. They have utility beyond exception handling (though going into them here would be a digression) and so in the long run might be served by a more general name. And it's already been established that one of the reasons for naming them events and switching toexnref
—namely supporting algebraic effects or resumable exceptions in the future—doesn't pan out. - Have an instruction
throw $tag : [t*] -> unreachable
, wheretag $tag : [t*]
, that looks for the first matching handler up the stack, trapping if none exists. - Have an instruction
try (do instr*) (catch $tag $l) : [ti*] -> [to*]
, whereinstr* : [ti*] -> [to*]
andtag $tag : [t*]
andlabel $l : [t*]
, that is a matching handler forthrow $tag
while control is withindo
, executing unwinders on the stack until control reaches$l
. - Have an instruction
try (do instr1*) (unwind instr2*) : [ti*] -> [to*]
, whereinstr1* : [ti*] -> [to*]
andinstr2* : [] -> []
, that is an unwinder while control is withindo
. (If control exits theunwind
clause other than reaching its end, that ends the unwinding process.)
An important point of flexibility is that handlers are not limited to catch
, and unwinders are not limited to unwind
. In particular, the host can have their own handlers and unwinders. For example, JS finally
clauses can be considered unwinders. Also, to support single-phase exception handling a host just needs to have a (conceptual) universal handler at the root of the stack just to guarantee one always exists somewhere; these particular instructions cannot observe the difference between unwinding as you look for the matching handler and unwinding after you find the matching handler, so long as you know a matching handler exists.
The JS API would, as suggested above, treat finally
clauses as unwinders. It would also treat JS throws and catches as using a special $jstag
, which wasm modules could import with tag type [externref]
. That's it; no need for wrapping and unwrapping. JS code doesn't (currently) have a way to catch wasm exceptions directly, but if a wasm throw has no handler, it'll trap, and JS can catch that trap. Of course, JS code always has the option of creating a wasm module to catch wasm exceptions, and we could make a JS API shorthand for that functionality if it's pressing.
Extensions
The main advantage of this change is that we can extend this variant much more easily. To illustrate that point, here are a few exceptions that come to mind.
- Add an
unwinding
instruction modifier. That is, it's an instruction that modifies the behavior of the following (appropriate) instruction. In this case, the following instruction must be one that redirects control up the stack, specificallyreturn
andbr
-and-variants. Its effect is to execute all unwinders on the stack between that instruction and the targeted point of control. This in particular would be useful for implementingfinally
clauses in surface languages, as well as C++ destructors in more recent C++ semantics, instead of duplicating the code across the "successful" and "exceptional" paths. - Add
try (do instr1*) (handle $tag instr2*) end : [ti*] -> [to*]
, where[instr1*] : [ti*] -> [to*]
andtag $tag : [t*]
andinstr2* : [t*] -> []
. This is a matching handler forthrow $tag
while control is withindo
, but unlikecatch
it doesn't necessarily cause the stack to unwind. Instead, thehandle
body is executed during the search phase, and if control reaches the end of the body then the search phase is continued. So you can dohandle (if some-filter-expression then (unwinding (br $catcher)))
to implement exception filtering. Or you can implement resumable exceptions by doingtry (throw $exn_tag) (catch $resume_tag $resumer)
around the exception throw, and then doinghandle (... (throw $resume_tag))
in the handler to end the search and transfer control to$resumer
with appropriate values.catch $tag $l
itself is just shorthand forhandle $tag (unwinding (br $l))
. - Add
catch_all
andhandle_all
, which take no inputs and are universal handlers. At this point, it might be useful to let atry
have multiple handlers, all for distinct tags, and then a universal handler that applies to all but those tags. Alternatively,catch_all
/handle_all
could take a list of tags they don't handle.
The list goes on, but the point is not to suggest that these be added now. The point is to illustrate that they can be added in a way that composes naturally with the suggested changes above (but which does not compose naturally with the current proposal).
Summary
As frustrating and painful as it is, I believe the above considerations strongly suggest that we should take exception handling back to its original, more extensible, design. Not doing so now will lead to even more frustration and pain down the line, since we'll want to create a two-phase-compatible exception-handling design at some point to support more languages and (debugging) tools, and then we'll be in the horrible spot of having two exception-handling designs in the same system, with one forcing single-phase exception handling and the other forcing two-phase exception handling, each jumping through hoops to interop with the other as best as they can manage.