Skip to content
This repository was archived by the owner on Apr 25, 2025. It is now read-only.
This repository was archived by the owner on Apr 25, 2025. It is now read-only.

Reconsider original extensible design #118

Closed
@RossTate

Description

@RossTate

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).

  1. 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 to exnref—namely supporting algebraic effects or resumable exceptions in the future—doesn't pan out.
  2. Have an instruction throw $tag : [t*] -> unreachable, where tag $tag : [t*], that looks for the first matching handler up the stack, trapping if none exists.
  3. Have an instruction try (do instr*) (catch $tag $l) : [ti*] -> [to*], where instr* : [ti*] -> [to*] and tag $tag : [t*] and label $l : [t*], that is a matching handler for throw $tag while control is within do, executing unwinders on the stack until control reaches $l.
  4. Have an instruction try (do instr1*) (unwind instr2*) : [ti*] -> [to*], where instr1* : [ti*] -> [to*] and instr2* : [] -> [], that is an unwinder while control is within do. (If control exits the unwind 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.

  1. 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, specifically return and br-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 implementing finally 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.
  2. Add try (do instr1*) (handle $tag instr2*) end : [ti*] -> [to*], where [instr1*] : [ti*] -> [to*] and tag $tag : [t*] and instr2* : [t*] -> []. This is a matching handler for throw $tag while control is within do, but unlike catch it doesn't necessarily cause the stack to unwind. Instead, the handle 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 do handle (if some-filter-expression then (unwinding (br $catcher))) to implement exception filtering. Or you can implement resumable exceptions by doing try (throw $exn_tag) (catch $resume_tag $resumer) around the exception throw, and then doing handle (... (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 for handle $tag (unwinding (br $l)).
  3. Add catch_all and handle_all, which take no inputs and are universal handlers. At this point, it might be useful to let a try 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions